diff --git a/.ci/Arch/Dockerfile b/.ci/Arch/Dockerfile index b08e568f3..36cf5c4ae 100644 --- a/.ci/Arch/Dockerfile +++ b/.ci/Arch/Dockerfile @@ -8,13 +8,10 @@ RUN pacman --sync --refresh --sysupgrade --needed --noconfirm \ gtest \ mariadb-libs \ ninja \ - openssl \ protobuf \ qt6-base \ - qt6-declarative \ qt6-imageformats \ qt6-multimedia \ - qt6-shadertools \ qt6-svg \ qt6-tools \ qt6-translations \ diff --git a/.ci/Debian12/Dockerfile b/.ci/Debian12/Dockerfile index e3df94ab5..202405b84 100644 --- a/.ci/Debian12/Dockerfile +++ b/.ci/Debian12/Dockerfile @@ -15,15 +15,12 @@ RUN apt-get update && \ libprotobuf-dev \ libqt6multimedia6 \ libqt6sql6-mysql \ - libssl-dev \ ninja-build \ protobuf-compiler \ qt6-image-formats-plugins \ qt6-l10n-tools \ qt6-multimedia-dev \ - qt6-declarative-dev \ qt6-svg-dev \ - qt6-shadertools-dev \ qt6-tools-dev \ qt6-tools-dev-tools \ qt6-websockets-dev \ diff --git a/.ci/Debian13/Dockerfile b/.ci/Debian13/Dockerfile index 60e490c98..d7ab6ac86 100644 --- a/.ci/Debian13/Dockerfile +++ b/.ci/Debian13/Dockerfile @@ -16,15 +16,12 @@ RUN apt-get update && \ libprotobuf-dev \ libqt6multimedia6 \ libqt6sql6-mysql \ - libssl-dev \ ninja-build \ protobuf-compiler \ qt6-image-formats-plugins \ qt6-l10n-tools \ qt6-multimedia-dev \ - qt6-declarative-dev \ qt6-svg-dev \ - qt6-shadertools-dev \ qt6-tools-dev \ qt6-tools-dev-tools \ qt6-websockets-dev \ diff --git a/.ci/Fedora43/Dockerfile b/.ci/Fedora43/Dockerfile index 4005bbf67..27570cf99 100644 --- a/.ci/Fedora43/Dockerfile +++ b/.ci/Fedora43/Dockerfile @@ -7,9 +7,8 @@ RUN dnf install -y \ git \ mariadb-devel \ ninja-build \ - openssl-devel \ protobuf-devel \ - qt6-{qtdeclarative,qtshadertools,qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ + qt6-{qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ qt6-qtimageformats \ rpm-build \ xz-devel \ diff --git a/.ci/Fedora44/Dockerfile b/.ci/Fedora44/Dockerfile index e0224cdc6..e6c8da7f3 100644 --- a/.ci/Fedora44/Dockerfile +++ b/.ci/Fedora44/Dockerfile @@ -7,9 +7,8 @@ RUN dnf install -y \ git \ mariadb-devel \ ninja-build \ - openssl-devel \ protobuf-devel \ - qt6-{qtdeclarative,qtshadertools,qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ + qt6-{qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ qt6-qtimageformats \ rpm-build \ xz-devel \ diff --git a/.ci/Servatrice_Debian12/Dockerfile b/.ci/Servatrice_Debian12/Dockerfile index 321aa7c0f..21f6a036e 100644 --- a/.ci/Servatrice_Debian12/Dockerfile +++ b/.ci/Servatrice_Debian12/Dockerfile @@ -12,7 +12,6 @@ RUN apt-get update && \ libmariadb-dev-compat \ libprotobuf-dev \ libqt6sql6-mysql \ - libssl-dev \ ninja-build \ protobuf-compiler \ qt6-tools-dev \ diff --git a/.ci/Ubuntu24.04/Dockerfile b/.ci/Ubuntu24.04/Dockerfile index 10adc5e64..809b2e43a 100644 --- a/.ci/Ubuntu24.04/Dockerfile +++ b/.ci/Ubuntu24.04/Dockerfile @@ -15,15 +15,12 @@ RUN apt-get update && \ libprotobuf-dev \ libqt6multimedia6 \ libqt6sql6-mysql \ - libssl-dev \ ninja-build \ protobuf-compiler \ qt6-image-formats-plugins \ qt6-l10n-tools \ qt6-multimedia-dev \ - qt6-declarative-dev \ qt6-svg-dev \ - qt6-shadertools-dev \ qt6-tools-dev \ qt6-tools-dev-tools \ qt6-websockets-dev \ diff --git a/.ci/Ubuntu26.04/Dockerfile b/.ci/Ubuntu26.04/Dockerfile index 1b6cf825f..7b0cd389f 100644 --- a/.ci/Ubuntu26.04/Dockerfile +++ b/.ci/Ubuntu26.04/Dockerfile @@ -16,15 +16,12 @@ RUN apt-get update && \ libprotobuf-dev \ libqt6multimedia6 \ libqt6sql6-mysql \ - libssl-dev \ ninja-build \ protobuf-compiler \ qt6-image-formats-plugins \ qt6-l10n-tools \ qt6-multimedia-dev \ - qt6-declarative-dev \ qt6-svg-dev \ - qt6-shadertools-dev \ qt6-tools-dev \ qt6-tools-dev-tools \ qt6-websockets-dev \ diff --git a/.ci/compile.sh b/.ci/compile.sh index bd8c900c8..8a16d3243 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -149,9 +149,6 @@ if [[ $MAKE_TEST ]]; then fi if [[ $USE_CCACHE ]]; then flags+=("-DUSE_CCACHE=1") - # PCH-aware caching is required or ccache refuses to cache any TU that - # consumes a precompiled header, silently recompiling everything on every run. - ccache --set-config sloppiness=pch_defines,time_macros if [[ $CCACHE_SIZE ]]; then # note, this setting persists after running the script ccache --max-size "$CCACHE_SIZE" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 75fbc59f1..58ca87573 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -40,7 +40,7 @@ jobs: steps: - name: "Checkout repository" - uses: actions/checkout@v7 + uses: actions/checkout@v6 - name: "Initialize CodeQL" uses: github/codeql-action/init@v4 @@ -68,9 +68,7 @@ jobs: libprotobuf-dev \ ninja-build \ protobuf-compiler \ - qt6-declarative-dev \ qt6-multimedia-dev \ - qt6-shadertools-dev \ qt6-svg-dev \ qt6-tools-dev \ qt6-tools-dev-tools \ diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index e080bdc93..4c2cef4e4 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -176,12 +176,8 @@ jobs: shell: bash run: | source .ci/docker.sh - args=() - [[ $GITHUB_REF == "refs/heads/master" ]] && args+=(--evict-ccache "$CCACHE_EVICTION_AGE") - args+=(--ccache "$CCACHE_SIZE") - args+=(--cmake-generator "$CMAKE_GENERATOR") - - RUN --server --debug --test "${args[@]}" + RUN --server --debug --test --ccache "$CCACHE_SIZE" \ + --cmake-generator "$CMAKE_GENERATOR" - name: "Build release package" id: build @@ -273,7 +269,7 @@ jobs: override_target: 13 package_suffix: "-macOS13_Intel" qt_version: 6.11.1 - qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools + qt_modules: qtimageformats qtmultimedia qtwebsockets soc: Intel type: Release use_ccache: 1 @@ -289,7 +285,7 @@ jobs: override_target: 14 package_suffix: "-macOS14" qt_version: 6.11.1 - qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools + qt_modules: qtimageformats qtmultimedia qtwebsockets soc: Apple type: Release use_ccache: 1 @@ -305,7 +301,7 @@ jobs: override_target: 15 package_suffix: "-macOS15" qt_version: 6.11.1 - qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools + qt_modules: qtimageformats qtmultimedia qtwebsockets soc: Apple type: Release use_ccache: 1 @@ -318,7 +314,7 @@ jobs: ccache_eviction_age: 7d cmake_generator: Ninja qt_version: 6.11.1 - qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools + qt_modules: qtimageformats qtmultimedia qtwebsockets soc: Apple type: Debug use_ccache: 1 @@ -333,7 +329,7 @@ jobs: make_package: 1 package_suffix: "-Win10" qt_version: 6.11.1 - qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools + qt_modules: qtimageformats qtmultimedia qtwebsockets type: Release name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }} diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 967d94c58..e679132ac 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -127,7 +127,7 @@ jobs: steps: - name: "Download digests" - uses: actions/download-artifact@v8 + uses: actions/download-artifact@v7 with: path: ${{ runner.temp }}/digests pattern: digest-* diff --git a/CMakeLists.txt b/CMakeLists.txt index 0da073464..27fecc979 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,23 +5,23 @@ # This file sets all the variables shared between the projects # like the installation path, compilation flags etc.. -# 3.16 required for Qt6 and target_precompile_headers() -cmake_minimum_required(VERSION 3.16) +# cmake 3.16 is required if using qt6 +cmake_minimum_required(VERSION 3.10) -# Use compiler cache (ccache) +# Early detect ccache option(USE_CCACHE "Cache the build results with ccache" ON) # Treat warnings as errors (Debug builds only) option(WARNING_AS_ERROR "Treat warnings as errors in debug builds" ON) # Check for translation updates option(UPDATE_TRANSLATIONS "Update translations on compile" OFF) -# Compile Cockatrice -option(WITH_CLIENT "Build Cockatrice client" ON) -# Compile Oracle -option(WITH_ORACLE "Build Cockatrice card database tool (Oracle)" ON) -# Compile Servatrice -option(WITH_SERVER "Build Cockatrice server (Servatrice)" OFF) +# Compile servatrice +option(WITH_SERVER "build servatrice" OFF) +# Compile cockatrice +option(WITH_CLIENT "build cockatrice" ON) +# Compile oracle +option(WITH_ORACLE "build oracle" ON) # Compile tests -option(TEST "Build tests" OFF) +option(TEST "build tests" OFF) # Use vcpkg regardless of OS option(USE_VCPKG "Use vcpkg regardless of OS" OFF) @@ -39,24 +39,13 @@ else() ) endif() -# ccache does not support MSVC and must not auto-engage on Windows -# (it is installed unintentionally on the Windows CI runner). -# NOTE: this keys off the target OS, so a mingw/Ninja configuration on Windows -# also opts out of ccache even though the GNUCXX branch below supports it. -if(USE_CCACHE AND NOT WIN32) +if(USE_CCACHE) find_program(CCACHE_PROGRAM ccache) if(CCACHE_PROGRAM) # Support Unix Makefiles and Ninja set_property(GLOBAL PROPERTY RULE_LAUNCH_COMPILE "${CCACHE_PROGRAM}") - # PCH-aware caching, matching .ci/compile.sh: without this ccache refuses - # to cache any TU that consumes a precompiled header, so every PCH-backed - # target recompiles from scratch on each build. - execute_process(COMMAND ${CCACHE_PROGRAM} --set-config sloppiness=pch_defines,time_macros) message(STATUS "Found CCache ${CCACHE_PROGRAM}") endif() -elseif(USE_CCACHE AND WIN32) - # An explicit opt-in must not disappear silently on Windows. - message(STATUS "ccache disabled: not supported for the MSVC toolchain on Windows") endif() if(WIN32 OR USE_VCPKG) @@ -195,9 +184,6 @@ elseif(CMAKE_COMPILER_IS_GNUCXX) set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} ${FLAG}") endif() endforeach() - - # Reduce compiler I/O by using pipes between stages instead of temp files - set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe") else() # other: osx/llvm, bsd/llvm set(CMAKE_CXX_FLAGS_RELEASE "-O2") @@ -206,9 +192,6 @@ else() else() set(CMAKE_CXX_FLAGS_DEBUG "-g -O0 -Wall -Wextra") endif() - - # Reduce compiler I/O by using pipes between stages instead of temp files - set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe") endif() # GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning @@ -256,6 +239,11 @@ if(WIN32) find_package(OpenSSL REQUIRED) if(OPENSSL_FOUND) include_directories(${OPENSSL_INCLUDE_DIRS}) + else() + message( + WARNING + "Could not find OpenSSL runtime libraries. They are not required for compiling, but needs to be available at runtime." + ) endif() endif() diff --git a/Dockerfile b/Dockerfile index 7d3deb5fb..382309d47 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,6 @@ RUN apt-get update \ libmariadb-dev-compat \ libprotobuf-dev \ libqt6sql6-mysql \ - libssl-dev \ qt6-websockets-dev \ protobuf-compiler \ qt6-tools-dev \ @@ -43,7 +42,6 @@ RUN apt-get update \ libprotobuf32t64 \ libqt6sql6-mysql \ libqt6websockets6 \ - libssl3 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* diff --git a/cmake/FindQtRuntime.cmake b/cmake/FindQtRuntime.cmake index 0259d12e1..8a3050813 100644 --- a/cmake/FindQtRuntime.cmake +++ b/cmake/FindQtRuntime.cmake @@ -18,13 +18,10 @@ if(WITH_CLIENT) Multimedia Network PrintSupport - ShaderTools Svg WebSockets Widgets Xml - Quick - QuickWidgets ) endif() if(WITH_ORACLE) diff --git a/cmake/pch/qtcore_pch.h b/cmake/pch/qtcore_pch.h deleted file mode 100644 index cc3dd12ee..000000000 --- a/cmake/pch/qtcore_pch.h +++ /dev/null @@ -1,24 +0,0 @@ -/** @file qtcore_pch.h - * @brief Precompiled header for all Qt targets (Qt Core only). - * - * Safe for every target that links Qt Core, including the headless - * Servatrice binary. Keep this header free of any widget/gui types. - */ - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include diff --git a/cmake/pch/qtwidgets_pch.h b/cmake/pch/qtwidgets_pch.h deleted file mode 100644 index 2c63f450e..000000000 --- a/cmake/pch/qtwidgets_pch.h +++ /dev/null @@ -1,30 +0,0 @@ -/** @file qtwidgets_pch.h - * @brief Precompiled header for GUI targets (Cockatrice client, Oracle). - * - * Includes the Qt Core precompiled header plus the heavy Gui, Widgets and - * Network layers that virtually every client translation unit re-parses. - * Do not use on Servatrice (headless, QT_DONT_USE_QTGUI). - */ - -#include "qtcore_pch.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index b6050a1bd..1924d86bf 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -214,7 +214,6 @@ set(cockatrice_SOURCES src/interface/widgets/deck_editor/deck_editor_printing_selector_dock_widget.cpp src/interface/widgets/deck_editor/deck_list_style_proxy.cpp src/interface/widgets/deck_editor/deck_state_manager.cpp - src/interface/widgets/deck_editor/deck_zone_dialog.cpp src/interface/widgets/deck_editor/printing_disabled_info_widget.cpp src/interface/widgets/general/background_sources.cpp src/interface/widgets/general/display/background_plate_widget.cpp @@ -231,7 +230,6 @@ set(cockatrice_SOURCES src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp src/interface/widgets/general/display/charts/pies/color_pie.cpp src/interface/widgets/general/home_styled_button.cpp - src/interface/widgets/general/home_tab_button_color.h src/interface/widgets/general/home_widget.cpp src/interface/widgets/general/layout_containers/flow_widget.cpp src/interface/widgets/general/layout_containers/overlap_control_widget.cpp @@ -272,7 +270,6 @@ set(cockatrice_SOURCES src/interface/widgets/server/user/user_context_menu.cpp src/interface/widgets/server/user/user_info_box.cpp src/interface/widgets/server/user/user_info_connection.cpp - src/interface/widgets/server/user/user_list_dialog.cpp src/interface/widgets/server/user/user_list_manager.cpp src/interface/widgets/server/user/user_list_painter.cpp src/interface/widgets/server/user/user_list_panel_widget.cpp @@ -400,27 +397,6 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.cpp 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/first_run_wizard.cpp - src/interface/widgets/onboarding/first_run_wizard.h - src/interface/widgets/onboarding/first_run_wizard_page.cpp - src/interface/widgets/onboarding/first_run_wizard_page.h - src/interface/widgets/onboarding/pages/account_setup_page.cpp - src/interface/widgets/onboarding/pages/account_setup_page.h - src/interface/widgets/onboarding/pages/card_database_setup_page.cpp - src/interface/widgets/onboarding/pages/card_database_setup_page.h - src/interface/widgets/onboarding/pages/finish_page.cpp - src/interface/widgets/onboarding/pages/finish_page.h - src/interface/widgets/onboarding/pages/preferences_setup_page.cpp - src/interface/widgets/onboarding/pages/preferences_setup_page.h - src/interface/widgets/onboarding/pages/theme_setup_page.cpp - src/interface/widgets/onboarding/pages/theme_setup_page.h - src/interface/widgets/onboarding/pages/welcome_page.cpp - src/interface/widgets/onboarding/pages/welcome_page.h - src/interface/widgets/onboarding/shader_banner_widget.cpp - src/interface/widgets/onboarding/shader_banner_widget.h - src/interface/widgets/onboarding/step_indicator_widget.cpp - src/interface/widgets/onboarding/step_indicator_widget.h src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.cpp src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.h src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.cpp @@ -517,30 +493,6 @@ qt6_add_executable( MANUAL_FINALIZATION ) -target_precompile_headers(cockatrice PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtwidgets_pch.h") - -qt6_add_shaders( - cockatrice - "onboarding_shaders" - PREFIX - "/onboarding/shaders" - BASE - "src/interface/widgets/onboarding/shaders" - FILES - src/interface/widgets/onboarding/shaders/brand_banner.frag -) - -qt6_add_resources( - cockatrice - "onboarding_qml" - PREFIX - "/onboarding/qml" - BASE - "src/interface/widgets/onboarding/qml" - FILES - src/interface/widgets/onboarding/qml/BrandBanner.qml -) - target_link_libraries( cockatrice PUBLIC libcockatrice_card diff --git a/cockatrice/cockatrice.qrc b/cockatrice/cockatrice.qrc index e21bdb0be..9c34929b7 100644 --- a/cockatrice/cockatrice.qrc +++ b/cockatrice/cockatrice.qrc @@ -2,7 +2,6 @@ resources/cardback.svg resources/cockatrice.svg - resources/cockatrice-logo-white.svg resources/hand.svg resources/hr.jpg diff --git a/cockatrice/resources/cockatrice-logo-white.svg b/cockatrice/resources/cockatrice-logo-white.svg deleted file mode 100644 index b3b31077f..000000000 --- a/cockatrice/resources/cockatrice-logo-white.svg +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - diff --git a/cockatrice/resources/cockatrice.svg b/cockatrice/resources/cockatrice.svg index 89ba62dcf..d2e22da31 100644 --- a/cockatrice/resources/cockatrice.svg +++ b/cockatrice/resources/cockatrice.svg @@ -2,20 +2,20 @@ + sodipodi:docname="cockatrice.svg"> - - + inkscape:current-layer="g3026" /> + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + + + + + + + @@ -159,7 +199,7 @@ image/svg+xml - + @@ -173,60 +213,170 @@ inkscape:export-xdpi="91.459999" inkscape:export-ydpi="91.459999"> + + + + + + + + + + + + + + diff --git a/cockatrice/resources/help/search.md b/cockatrice/resources/help/search.md index fd0a12507..0c8bdb450 100644 --- a/cockatrice/resources/help/search.md +++ b/cockatrice/resources/help/search.md @@ -52,7 +52,6 @@ In this list of examples below, each entry has an explanation and can be clicked
Edition:
[set:lea](#set:lea) (Cards that appear in Alpha, which has the set code LEA)
[e:lea OR e:leb](#e:lea OR e:leb) (Cards that appear in Alpha or Beta)
-
[e<8ED](#e<8ED) (Cards that appear before 8th edition)
Negate:
[c:wu -c:m](#c:wu -c:m) (Any card that is white or blue, but not multicolored)
diff --git a/cockatrice/src/game_graphics/deckview/deck_view.cpp b/cockatrice/src/game_graphics/deckview/deck_view.cpp index 1acd02a75..1278737a0 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include DeckViewCardDragItem::DeckViewCardDragItem(DeckViewCard *_item, @@ -380,10 +381,12 @@ void DeckViewScene::rebuildTree() addItem(container); } - // Cards in custom zones nested under a board are regular board cards in-game. - // They are collected recursively (like every other consumer) and reported with - // the top-level board zone as their origin, so that sideboard plans keep working. - for (auto *currentCard : deck->getCardNodes({currentZone->getName()})) { + for (int j = 0; j < currentZone->size(); j++) { + auto *currentCard = dynamic_cast(currentZone->at(j)); + if (!currentCard) { + continue; + } + for (int k = 0; k < currentCard->getNumber(); ++k) { auto *newCard = new DeckViewCard(container, currentCard->toCardRef(), currentZone->getName()); container->addCard(newCard); diff --git a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp index bc07ac183..23ed4316d 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp @@ -9,7 +9,6 @@ #include "../../interface/widgets/dialogs/dlg_load_deck_from_website.h" #include "../../interface/widgets/dialogs/dlg_load_remote_deck.h" #include "../../interface/widgets/tabs/tab_game.h" -#include "../../interface/widgets/visual_deck_storage/visual_deck_storage_widget.h" #include "deck_view.h" #include diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index 457f1b3f7..cd2b12828 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -44,16 +44,11 @@ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent) GameScene::~GameScene() { - // Sever all destroyed->removeAnimatedItem connections before the members below - // are destroyed: the base QGraphicsScene destructor destroys the remaining items, - // and their destroyed() signals must not reach slots that reference members that - // no longer exist. The connection handle overload is used because the string-based - // disconnect(nullptr, nullptr, this, nullptr) is invalid (the sender must never be - // nullptr) and would otherwise fail to sever these pointer-to-member connections. - for (auto it = animationItemConnections.constBegin(); it != animationItemConnections.constEnd(); ++it) { - QObject::disconnect(*it); - } - animationItemConnections.clear(); + // Sever all incoming connections (animated item destroy-tracking) before the + // members below are destroyed: the base QGraphicsScene destructor destroys the + // remaining items, and their destroyed() signals must not reach slots that + // reference members that no longer exist. + disconnect(this); delete animationTimer; animationTimer = nullptr; @@ -782,15 +777,8 @@ void GameScene::registerAnimationItem(IAnimatedItem *item) if (!object) { return; } - // Guard against duplicate connections using the connection map, not - // animatedItems: the animation timer removes entries from animatedItems when an - // animation completes, but the destroyed->removeAnimatedItem connection must - // persist until the object is destroyed. Relying on animatedItems here would let - // a re-registered item (e.g. a life counter that flashes repeatedly) accumulate - // duplicate destroyed connections, the older ones of which would survive teardown. - if (!animationItemConnections.contains(object)) { - animationItemConnections.insert(object, - connect(object, &QObject::destroyed, this, &GameScene::removeAnimatedItem)); + if (!animatedItems.contains(object)) { + connect(object, &QObject::destroyed, this, &GameScene::removeAnimatedItem); } animatedItems.insert(object, item); if (animationTimer && !animationTimer->isActive()) { @@ -809,7 +797,6 @@ void GameScene::unregisterAnimationItem(IAnimatedItem *item) void GameScene::removeAnimatedItem(QObject *item) { animatedItems.remove(item); - animationItemConnections.remove(item); if (animationTimer && animatedItems.isEmpty()) { animationTimer->stop(); } diff --git a/cockatrice/src/game_graphics/game_scene.h b/cockatrice/src/game_graphics/game_scene.h index 859d7a6eb..c12696189 100644 --- a/cockatrice/src/game_graphics/game_scene.h +++ b/cockatrice/src/game_graphics/game_scene.h @@ -54,11 +54,9 @@ private: QPointer hoveredCard; ///< Currently hovered card QBasicTimer *animationTimer; ///< Timer for scene animations QHash animatedItems; ///< Items currently animating - QHash - animationItemConnections; ///< destroyed->removeAnimatedItem handles per animated item - int playerRotation; ///< Rotation offset for player layout - bool rearranging = false; ///< Guard against re-entrant rearrange - bool needsReArrange = false; ///< Pending rearrange requested during a pass + int playerRotation; ///< Rotation offset for player layout + bool rearranging = false; ///< Guard against re-entrant rearrange + bool needsReArrange = false; ///< Pending rearrange requested during a pass /** * @brief Updates which card is currently hovered based on scene coordinates. diff --git a/cockatrice/src/game_graphics/player/menu/tally_menu.cpp b/cockatrice/src/game_graphics/player/menu/tally_menu.cpp index 08cb6cac9..7eb3945b3 100644 --- a/cockatrice/src/game_graphics/player/menu/tally_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/tally_menu.cpp @@ -12,13 +12,11 @@ TallyMenu::TallyMenu() aTallyNone = createTallyAction(TallyType::None); aTallySubtypes = createTallyAction(TallyType::Subtypes); aTallyTotalPower = createTallyAction(TallyType::TotalPower); - aTallyTotalToughness = createTallyAction(TallyType::TotalToughness); addAction(aTallyNone); addSeparator(); addAction(aTallySubtypes); addAction(aTallyTotalPower); - addAction(aTallyTotalToughness); retranslateUi(); } @@ -56,5 +54,4 @@ void TallyMenu::retranslateUi() aTallyNone->setText(tr("None")); aTallySubtypes->setText(tr("Subtypes")); aTallyTotalPower->setText(tr("Total Power")); - aTallyTotalToughness->setText(tr("Total Toughness")); } diff --git a/cockatrice/src/game_graphics/player/menu/tally_menu.h b/cockatrice/src/game_graphics/player/menu/tally_menu.h index 11802fd20..acd1daf67 100644 --- a/cockatrice/src/game_graphics/player/menu/tally_menu.h +++ b/cockatrice/src/game_graphics/player/menu/tally_menu.h @@ -24,7 +24,6 @@ private: QAction *aTallyNone = nullptr; QAction *aTallySubtypes = nullptr; QAction *aTallyTotalPower = nullptr; - QAction *aTallyTotalToughness = nullptr; QAction *createTallyAction(TallyType tallyType); }; diff --git a/cockatrice/src/game_graphics/tally/stats_tally.cpp b/cockatrice/src/game_graphics/tally/stats_tally.cpp index 7e05c3fb1..e7a6621fa 100644 --- a/cockatrice/src/game_graphics/tally/stats_tally.cpp +++ b/cockatrice/src/game_graphics/tally/stats_tally.cpp @@ -34,31 +34,3 @@ QList StatsTally::computeTotalPower(const QList &cards) QString name = QCoreApplication::translate("StatsTally", "Total Power"); return {TallyRow{name, QString::number(total)}}; } - -static int sumToughness(const QList &cards) -{ - int total = 0; - for (auto card : cards) { - QVariantList parsed = CardItem::parsePT(card->getPT()); - if (parsed.size() == 2) { - int toughness = parsed.at(1).toInt(); // toInt will default to 0 if it's not an int - total += qMax(toughness, 0); - } - } - return total; -} - -QList StatsTally::computeTotalToughness(const QList &cards) -{ - // don't bother if none of the cards have pt - bool hasPT = - std::any_of(cards.cbegin(), cards.cend(), [](const CardItem *card) { return !card->getPT().isEmpty(); }); - if (!hasPT) { - return {}; - } - - int total = sumToughness(cards); - - QString name = QCoreApplication::translate("StatsTally", "Total Toughness"); - return {TallyRow{name, QString::number(total)}}; -} diff --git a/cockatrice/src/game_graphics/tally/stats_tally.h b/cockatrice/src/game_graphics/tally/stats_tally.h index e499587eb..4c3d93b56 100644 --- a/cockatrice/src/game_graphics/tally/stats_tally.h +++ b/cockatrice/src/game_graphics/tally/stats_tally.h @@ -16,14 +16,6 @@ namespace StatsTally */ QList computeTotalPower(const QList &cards); -/** - * @brief Sums the toughness of all selected cards - * - * @param cards The list of selected card items to analyze. - * @return A single row containing the total, or an empty list if none of the cards have pt - */ -QList computeTotalToughness(const QList &cards); - } // namespace StatsTally #endif // COCKATRICE_STATS_TALLY_H diff --git a/cockatrice/src/game_graphics/tally/tally.cpp b/cockatrice/src/game_graphics/tally/tally.cpp index 21806ee84..aa2cae024 100644 --- a/cockatrice/src/game_graphics/tally/tally.cpp +++ b/cockatrice/src/game_graphics/tally/tally.cpp @@ -21,8 +21,6 @@ QList Tally::compute(const QList &cards, const TallyType t return SubtypeTally::countSubtypes(cards); case TallyType::TotalPower: return StatsTally::computeTotalPower(cards); - case TallyType::TotalToughness: - return StatsTally::computeTotalToughness(cards); } return {}; } diff --git a/cockatrice/src/game_graphics/tally/tally.h b/cockatrice/src/game_graphics/tally/tally.h index 84c54918f..97406cddb 100644 --- a/cockatrice/src/game_graphics/tally/tally.h +++ b/cockatrice/src/game_graphics/tally/tally.h @@ -21,8 +21,7 @@ enum class TallyType None, Subtypes, TotalPower, - TotalToughness, - MaxValue = TotalToughness // sentinel value + MaxValue = TotalPower // sentinel value }; namespace Tally diff --git a/cockatrice/src/game_graphics/zones/hand_zone.cpp b/cockatrice/src/game_graphics/zones/hand_zone.cpp index 1a8f7a910..b52a4955a 100644 --- a/cockatrice/src/game_graphics/zones/hand_zone.cpp +++ b/cockatrice/src/game_graphics/zones/hand_zone.cpp @@ -41,8 +41,7 @@ void HandZone::handleDropEvent(const QList &dragItems, } } } else { - bool sameZone = startZone == getLogic(); - x = calcDropIndexFromY(dropPoint.y(), !sameZone); + x = calcDropIndexFromY(dropPoint.y()); } Command_MoveCard cmd; diff --git a/cockatrice/src/game_graphics/zones/select_zone.cpp b/cockatrice/src/game_graphics/zones/select_zone.cpp index 470c70fcf..c58c41b92 100644 --- a/cockatrice/src/game_graphics/zones/select_zone.cpp +++ b/cockatrice/src/game_graphics/zones/select_zone.cpp @@ -83,7 +83,7 @@ SelectZone::StackLayoutParams SelectZone::buildStackParams(qreal minOffset) cons return {cardCount, boundingRect().height(), cardHeight, offset, minOffset}; } -int SelectZone::calcDropIndexFromY(qreal dropY, bool allowCountExpand, qreal minOffset) const +int SelectZone::calcDropIndexFromY(qreal dropY, qreal minOffset) const { const auto &cards = getLogic()->getCards(); if (cards.isEmpty()) { @@ -94,8 +94,7 @@ int SelectZone::calcDropIndexFromY(qreal dropY, bool allowCountExpand, qreal min if (effectiveOffset <= 0.0) { return 0; } - int max = allowCountExpand ? params.cardCount : params.cardCount - 1; - return qBound(0, qRound((dropY - start) / effectiveOffset), max); + return qBound(0, qRound((dropY - start) / effectiveOffset), params.cardCount - 1); } void SelectZone::restoreStaleEscapedCards() diff --git a/cockatrice/src/game_graphics/zones/select_zone.h b/cockatrice/src/game_graphics/zones/select_zone.h index b5d3ca37a..7408f29b6 100644 --- a/cockatrice/src/game_graphics/zones/select_zone.h +++ b/cockatrice/src/game_graphics/zones/select_zone.h @@ -104,12 +104,8 @@ protected: /** * @brief Computes the card index at a given y-coordinate within the zone's vertical layout. * Returns 0 if the zone has no cards or the offset is zero. - * - * @param dropY The y-coordinate that the card was dropped at - * @param allowCountExpand If false, clamps the index at the number of cards minus 1 - * @param minOffset Minimum offset to preserve */ - int calcDropIndexFromY(qreal dropY, bool allowCountExpand, qreal minOffset = 0.0) const; + int calcDropIndexFromY(qreal dropY, qreal minOffset = 0.0) const; /** * @brief Positions cards vertically with alternating left/right x-offsets. diff --git a/cockatrice/src/game_graphics/zones/stack_zone.cpp b/cockatrice/src/game_graphics/zones/stack_zone.cpp index ff62097c7..e9b14f13d 100644 --- a/cockatrice/src/game_graphics/zones/stack_zone.cpp +++ b/cockatrice/src/game_graphics/zones/stack_zone.cpp @@ -57,14 +57,18 @@ void StackZone::handleDropEvent(const QList &dragItems, return; } - bool sameZone = startZone == getLogic(); - int index = calcDropIndexFromY(dropPoint.y(), !sameZone, MIN_CARD_VISIBLE); - if (sameZone) { + const auto &cards = getLogic()->getCards(); + int index; + if (startZone == getLogic()) { + // Reordering within the zone: use drop position + index = calcDropIndexFromY(dropPoint.y(), MIN_CARD_VISIBLE); // Same-zone no-op: don't move a card onto itself - const auto &cards = getLogic()->getCards(); if (!cards.isEmpty() && cards.at(index)->getId() == dragItems.at(0)->getId()) { return; } + } else { + // Coming from another zone: append at end (top of stack, rendered on top) + index = static_cast(cards.size()); } Command_MoveCard cmd; diff --git a/cockatrice/src/interface/deck_loader/deck_loader.cpp b/cockatrice/src/interface/deck_loader/deck_loader.cpp index f03339da8..39a0c1071 100644 --- a/cockatrice/src/interface/deck_loader/deck_loader.cpp +++ b/cockatrice/src/interface/deck_loader/deck_loader.cpp @@ -375,32 +375,15 @@ void DeckLoader::saveToStream_DeckHeader(QTextStream &out, const DeckList &deckL void DeckLoader::saveToStream_DeckZone(QTextStream &out, const InnerDecklistNode *zoneNode, bool addComments, - bool addSetNameAndNumber, - const QString &boardZoneName) + bool addSetNameAndNumber) { - // Nested sub-zones keep their owning board's identity: the top-level call - // passes no board, so the zone's own name is used; recursive calls carry the - // owning board down so the sideboard marker survives sub-zone nesting. - const QString owningBoardZoneName = boardZoneName.isEmpty() ? zoneNode->getName() : boardZoneName; - // group cards by card type and count the subtotals QMultiMap cardsByType; QMap cardTotalByType; int cardTotal = 0; - QList subZones; for (int j = 0; j < zoneNode->size(); j++) { auto *card = dynamic_cast(zoneNode->at(j)); - if (!card) { - // Cards collected in nested sub-zones are exported by recursion so - // they don't end up invisible in the plain text output. They are - // deferred until after this zone's own header and cards so they read - // as part of this zone's block. - if (auto *subZone = dynamic_cast(zoneNode->at(j))) { - subZones.append(subZone); - } - continue; - } CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(card->getName()); QString cardType = info ? info->getMainCardType() : "unknown"; @@ -428,30 +411,25 @@ void DeckLoader::saveToStream_DeckZone(QTextStream &out, QList cards = cardsByType.values(cardType); - saveToStream_DeckZoneCards(out, cards, addComments, addSetNameAndNumber, owningBoardZoneName); + saveToStream_DeckZoneCards(out, zoneNode, cards, addComments, addSetNameAndNumber); if (addComments) { out << "\n"; } } - - // Nested sub-zones come last, after the parent's own header and cards. - for (const auto *subZone : subZones) { - saveToStream_DeckZone(out, subZone, addComments, addSetNameAndNumber, owningBoardZoneName); - } } void DeckLoader::saveToStream_DeckZoneCards(QTextStream &out, + const InnerDecklistNode *zoneNode, QList cards, bool addComments, - bool addSetNameAndNumber, - const QString &boardZoneName) + bool addSetNameAndNumber) { // QMultiMap sorts values in reverse order for (int i = cards.size() - 1; i >= 0; --i) { DecklistCardNode *card = cards[i]; - if (boardZoneName == DECK_ZONE_SIDE && addComments) { + if (zoneNode->getName() == DECK_ZONE_SIDE && addComments) { out << "SB: "; } @@ -532,26 +510,9 @@ bool DeckLoader::convertToCockatriceFormat(LoadedDeck &deck) void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode *node) { - if (!node || node->isEmpty()) { - return; - } - const int totalColumns = 2; - // Dispatch children by type instead of trusting a whole-node height: a deck - // node may hold direct cards and nested zones side by side (custom zones), - // and an empty node would previously crash on at(0). - QVector cards; - QVector subZones; - for (int i = 0; i < node->size(); i++) { - if (auto *card = dynamic_cast(node->at(i))) { - cards.append(card); - } else if (auto *zone = dynamic_cast(node->at(i))) { - subZones.append(zone); - } - } - - if (!cards.isEmpty()) { + if (node->height() == 1) { QTextBlockFormat blockFormat; QTextCharFormat charFormat; charFormat.setFontPointSize(11); @@ -562,9 +523,9 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode tableFormat.setCellPadding(0); tableFormat.setCellSpacing(0); tableFormat.setBorder(0); - QTextTable *table = cursor->insertTable(cards.size() + 1, totalColumns, tableFormat); - for (int i = 0; i < cards.size(); i++) { - const AbstractDecklistCardNode *card = cards[i]; + QTextTable *table = cursor->insertTable(node->size() + 1, totalColumns, tableFormat); + for (int i = 0; i < node->size(); i++) { + auto *card = dynamic_cast(node->at(i)); QTextCharFormat cellCharFormat; cellCharFormat.setFontPointSize(9); @@ -579,13 +540,7 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode cellCursor = cell.firstCursorPosition(); cellCursor.insertText(card->getName()); } - } - - for (const InnerDecklistNode *subZone : subZones) { - if (subZone->isEmpty()) { - continue; - } - + } else if (node->height() == 2) { QTextBlockFormat blockFormat; QTextCharFormat charFormat; charFormat.setFontPointSize(14); @@ -604,8 +559,10 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode tableFormat.setColumnWidthConstraints(constraints); QTextTable *table = cursor->insertTable(1, totalColumns, tableFormat); - QTextCursor cellCursor = table->cellAt(0, 0).firstCursorPosition(); - printDeckListNode(&cellCursor, subZone); + for (int i = 0; i < node->size(); i++) { + QTextCursor cellCursor = table->cellAt(0, (i * totalColumns) / node->size()).lastCursorPosition(); + printDeckListNode(&cellCursor, dynamic_cast(node->at(i))); + } } cursor->movePosition(QTextCursor::End); diff --git a/cockatrice/src/interface/deck_loader/deck_loader.h b/cockatrice/src/interface/deck_loader/deck_loader.h index b851c6895..ac23e1ee0 100644 --- a/cockatrice/src/interface/deck_loader/deck_loader.h +++ b/cockatrice/src/interface/deck_loader/deck_loader.h @@ -159,13 +159,12 @@ private: static void saveToStream_DeckZone(QTextStream &out, const InnerDecklistNode *zoneNode, bool addComments = true, - bool addSetNameAndNumber = true, - const QString &boardZoneName = QString()); + bool addSetNameAndNumber = true); static void saveToStream_DeckZoneCards(QTextStream &out, + const InnerDecklistNode *zoneNode, QList cards, bool addComments = true, - bool addSetNameAndNumber = true, - const QString &boardZoneName = QString()); + bool addSetNameAndNumber = true); }; #endif diff --git a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp index 9cde72c01..adae6e152 100644 --- a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp +++ b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp @@ -290,35 +290,17 @@ void PaletteEditorDialog::onSave() // Persist every scheme that changed, not just the one on screen. Each scheme // has its own file, so edits to the non-active scheme would otherwise be // silently discarded when the dialog closes. - // - // Save the loaded scheme last so commitPalette's global colour-scheme - // update (ThemeConfig::colorScheme) points at the active scheme. for (auto it = workingConfig.begin(); it != workingConfig.end(); ++it) { - if (it.key() == loadedScheme) { - continue; + const QString &scheme = it.key(); + if (it.value().colors == savedConfig.value(scheme).colors) { + continue; // unchanged — leave the on-disk file alone } - if (it.value().colors == savedConfig.value(it.key()).colors) { - continue; - } - if (!ThemeManager::commitPalette(saveDir, it.key(), it.value())) { - QMessageBox::warning(this, tr("Save failed"), - tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(it.key()), saveDir)); - return; - } - } - // Commit the active scheme last so the global colour scheme matches. - if (workingConfig[loadedScheme].colors != savedConfig.value(loadedScheme).colors) { - if (!ThemeManager::commitPalette(saveDir, loadedScheme, workingConfig[loadedScheme])) { + if (!ThemeManager::savePaletteConfig(saveDir, scheme, it.value())) { QMessageBox::warning(this, tr("Save failed"), - tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(loadedScheme), saveDir)); + tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(scheme), saveDir)); return; } - } else { - // No palette change but scheme may have switched -- still update global config. - ThemeConfig globalCfg = ThemeConfig::fromThemeDir(saveDir); - globalCfg.colorScheme = loadedScheme; - globalCfg.save(saveDir); } // Keep the saved snapshot in sync so Reset behaves correctly afterwards. @@ -326,6 +308,10 @@ void PaletteEditorDialog::onSave() savedConfig[it.key()] = it.value(); } + ThemeConfig globalCfg = ThemeConfig::fromThemeDir(saveDir); + globalCfg.colorScheme = loadedScheme; + globalCfg.save(saveDir); + themeManager->reloadCurrentTheme(); accept(); } diff --git a/cockatrice/src/interface/pixel_map_generator.cpp b/cockatrice/src/interface/pixel_map_generator.cpp index 9b8c4bcdc..5bfba1c8a 100644 --- a/cockatrice/src/interface/pixel_map_generator.cpp +++ b/cockatrice/src/interface/pixel_map_generator.cpp @@ -3,7 +3,6 @@ #include #include #include -#include #include #include #include @@ -419,57 +418,6 @@ QPixmap DropdownIconPixmapGenerator::generatePixmap(int height, bool expanded) QMap DropdownIconPixmapGenerator::pmCache; -namespace -{ -/// Longest side mana symbols are rendered at before being scaled to their final size. -constexpr int MASTER_ICON_SIZE = 128; - -QString manaSymbolCacheKey(const QString &symbol, const QSize &size) -{ - return symbol + QLatin1Char('|') + QString::number(size.width()) + QLatin1Char('x') + - QString::number(size.height()); -} -} // namespace - -const QPixmap &ManaSymbolPixmapGenerator::masterIcon(const QString &symbol) -{ - auto it = masterCache.constFind(symbol); - if (it != masterCache.constEnd()) { - return it.value(); - } - - QImageReader reader("theme:icons/mana/" + symbol); - QSize sourceSize = reader.size(); - if (!sourceSize.isEmpty()) { - sourceSize.scale(QSize(MASTER_ICON_SIZE, MASTER_ICON_SIZE), Qt::KeepAspectRatio); - reader.setScaledSize(sourceSize); - } - const QPixmap rendered = QPixmap::fromImageReader(&reader); - - return masterCache.insert(symbol, rendered).value(); -} - -QPixmap ManaSymbolPixmapGenerator::generatePixmap(const QString &symbol, const QSize &size) -{ - const QString key = manaSymbolCacheKey(symbol, size); - auto it = scaledCache.constFind(key); - if (it != scaledCache.constEnd()) { - return it.value(); - } - - const QPixmap &icon = masterIcon(symbol); - if (icon.isNull()) { - return {}; - } - - QPixmap scaled = icon.scaled(size, Qt::KeepAspectRatio, Qt::SmoothTransformation); - scaledCache.insert(key, scaled); - return scaled; -} - -QHash ManaSymbolPixmapGenerator::masterCache; -QHash ManaSymbolPixmapGenerator::scaledCache; - QPixmap loadColorAdjustedPixmap(const QString &name) { if (qApp->palette().windowText().color().lightness() > 200) { diff --git a/cockatrice/src/interface/pixel_map_generator.h b/cockatrice/src/interface/pixel_map_generator.h index 17720166a..22f44d8db 100644 --- a/cockatrice/src/interface/pixel_map_generator.h +++ b/cockatrice/src/interface/pixel_map_generator.h @@ -7,7 +7,6 @@ #ifndef PIXMAPGENERATOR_H #define PIXMAPGENERATOR_H -#include #include #include #include @@ -126,34 +125,6 @@ public: } }; -class ManaSymbolPixmapGenerator -{ -private: - static QHash masterCache; - static QHash scaledCache; - - /** - * @brief Renders \a symbol once at a fixed moderate size, so repeated scalings never - * re-rasterize the source file (SVG sources can be very expensive to rasterize). - */ - static const QPixmap &masterIcon(const QString &symbol); - -public: - /** - * @brief Returns a smooth-scaled rendering of the given mana symbol icon. - * - * Results are shared between all callers via a process-wide cache keyed by symbol - * and size, so scaling work is done once per distinct combination instead of once - * per widget creation or resize. - */ - static QPixmap generatePixmap(const QString &symbol, const QSize &size); - static void clear() - { - masterCache.clear(); - scaledCache.clear(); - } -}; - QPixmap loadColorAdjustedPixmap(const QString &name); #endif diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index e6b4b3c7f..8986a9f00 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -272,19 +272,6 @@ PaletteConfig ThemeManager::loadDefaultPaletteConfig(const QString &themeDirPath return cfg; } -bool ThemeManager::commitPalette(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg) -{ - if (!savePaletteConfig(themeDirPath, colorScheme, cfg)) { - return false; - } - - ThemeConfig globalCfg = ThemeConfig::fromThemeDir(themeDirPath); - globalCfg.colorScheme = colorScheme; - globalCfg.save(themeDirPath); - - return true; -} - void ThemeManager::setColorScheme(const QString &scheme) { const QString dirPath = getAvailableThemes().value(SettingsCache::instance().getThemeName()); diff --git a/cockatrice/src/interface/theme_manager.h b/cockatrice/src/interface/theme_manager.h index 79a1b6470..e3a40660b 100644 --- a/cockatrice/src/interface/theme_manager.h +++ b/cockatrice/src/interface/theme_manager.h @@ -91,10 +91,6 @@ public: // theme directory when it is absent from the resolved (user) directory. static PaletteConfig loadDefaultPaletteConfig(const QString &themeDirPath, const QString &themeName, const QString &colorScheme); - /** @brief Writes cfg to disk as the theme's palette-.toml and updates the - * theme's stored colour scheme to match. Shared by PaletteEditorDialog::onSave - * and FirstRunWizard's theme step so the two "generate + keep" paths can't drift. */ - static bool commitPalette(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg); void setColorScheme(const QString &scheme); void setStyleName(const QString &styleName); diff --git a/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp b/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp index 1ea1bcb10..3f0f30a27 100644 --- a/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp @@ -41,11 +41,6 @@ void ColorIdentityWidget::populateManaSymbolWidgets() // clear old layout QtUtils::clearLayoutRec(layout); - // The freshly created symbols haven't been sized yet, so force the next resize pass - // to apply the symbol size again. - lastIconSize = -1; - lastWidth = -1; - // populate mana symbols if (SettingsCache::instance().visualDeckStorage().getVisualDeckStorageDrawUnusedColorIdentities()) { for (const QString symbol : fullColorIdentity) { @@ -78,33 +73,20 @@ void ColorIdentityWidget::toggleUnusedVisibility() void ColorIdentityWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); + QList manaSymbols = findChildren(); - const int totalWidth = event->size().width(); - if (totalWidth == lastWidth && lastIconSize != -1) { - return; - } - lastWidth = totalWidth; + if (!manaSymbols.isEmpty()) { + int totalWidth = event->size().width(); + int totalHeight = totalWidth / 6; // Set height to 1/4 of the width + setFixedHeight(totalHeight); - const int totalHeight = totalWidth / 6; // Set height to 1/4 of the width - setFixedHeight(totalHeight); + int spacing = layout->spacing(); + int count = manaSymbols.size(); + int availableWidth = totalWidth - (spacing * (count - 1)); + int iconSize = qMin(availableWidth / count, totalHeight); // Ensure icons fit within the new height - const int count = layout->count(); - if (count == 0) { - return; - } - - const int spacing = layout->spacing(); - const int availableWidth = totalWidth - (spacing * (count - 1)); - const int iconSize = qMin(availableWidth / count, totalHeight); // Ensure icons fit within the new height - - if (iconSize == lastIconSize) { - return; - } - lastIconSize = iconSize; - - for (int i = 0; i < count; ++i) { - if (auto *w = qobject_cast(layout->itemAt(i)->widget())) { - w->setFixedSize(iconSize, iconSize); + for (ManaSymbolWidget *manaSymbol : manaSymbols) { + manaSymbol->setFixedSize(iconSize, iconSize); } } } diff --git a/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.h b/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.h index 315ac07d6..f776d4c77 100644 --- a/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.h +++ b/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.h @@ -30,8 +30,6 @@ public slots: private: QString colorIdentity; QHBoxLayout *layout; - int lastIconSize = -1; ///< The symbol size last applied, to skip redundant resize passes. - int lastWidth = -1; ///< The width last processed, to skip redundant resize passes. }; #endif // COLOR_IDENTITY_WIDGET_H diff --git a/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp b/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp index 18011909f..51247da7a 100644 --- a/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp @@ -1,15 +1,15 @@ #include "mana_symbol_widget.h" #include "../../../../client/settings/cache_settings.h" -#include "../../../pixel_map_generator.h" #include #include ManaSymbolWidget::ManaSymbolWidget(QWidget *parent, QString _symbol, bool _isActive, bool _mayBeToggled) - : QLabel(parent), symbol(std::move(_symbol)), isActive(_isActive), mayBeToggled(_mayBeToggled) + : QLabel(parent), symbol(_symbol), isActive(_isActive), mayBeToggled(_mayBeToggled) { - setPixmap(ManaSymbolPixmapGenerator::generatePixmap(symbol, QSize(50, 50))); + loadManaIcon(); + setPixmap(manaIcon.scaled(50, 50, Qt::KeepAspectRatio, Qt::SmoothTransformation)); setMaximumWidth(50); // Initialize opacity effect @@ -64,13 +64,16 @@ void ManaSymbolWidget::mousePressEvent(QMouseEvent *event) void ManaSymbolWidget::resizeEvent(QResizeEvent *event) { QLabel::resizeEvent(event); - const QSize newSize = event->size(); + setPixmap(manaIcon.scaled(event->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation)); +} - // Skip the rescale when the size didn't actually change: layout passes resize these - // widgets repeatedly with identical sizes. - if (newSize.isEmpty() || pixmap().size() == newSize) { - return; +void ManaSymbolWidget::loadManaIcon() +{ + QString filename = "theme:icons/mana/"; + + if (symbol == "W" || symbol == "U" || symbol == "B" || symbol == "R" || symbol == "G") { + filename += symbol; } - setPixmap(ManaSymbolPixmapGenerator::generatePixmap(symbol, newSize)); + manaIcon = QPixmap(filename); } diff --git a/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.h b/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.h index 705873dff..0f2d7acd1 100644 --- a/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.h +++ b/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.h @@ -33,6 +33,8 @@ public: return symbol[0]; } + void loadManaIcon(); + public slots: void resizeEvent(QResizeEvent *event) override; void mousePressEvent(QMouseEvent *event) override; @@ -42,6 +44,7 @@ signals: private: QString symbol; + QPixmap manaIcon; bool isActive; bool mayBeToggled; QGraphicsOpacityEffect *opacityEffect; diff --git a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp index bfbdd7e42..3f36e559c 100644 --- a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp @@ -174,18 +174,16 @@ void CardGroupDisplayWidget::updateCardDisplays() QModelIndex sourceIndex = proxy.mapToSource(proxyIndex); // 4. persist the source index - addCardWidgets(QPersistentModelIndex(sourceIndex)); - } -} + QPersistentModelIndex persistent(sourceIndex); -void CardGroupDisplayWidget::addCardWidgets(const QPersistentModelIndex &persistent) -{ - // Get the card amount - int cardAmount = persistent.sibling(persistent.row(), DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt(); + // Get the card amount + int cardAmount = + sourceIndex.sibling(sourceIndex.row(), DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt(); - // Create multiple widgets for the card count - for (int copy = 0; copy < cardAmount; ++copy) { - addToLayout(constructWidgetForIndex(persistent)); + // Create multiple widgets for the card count + for (int copy = 0; copy < cardAmount; ++copy) { + addToLayout(constructWidgetForIndex(persistent)); + } } } diff --git a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.h b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.h index a3bf70981..2308ccf8d 100644 --- a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.h @@ -35,7 +35,6 @@ public: void onSelectionChanged(const QItemSelection &selected, const QItemSelection &deselected); void refreshSelectionForIndex(const QPersistentModelIndex &persistent); void clearAllDisplayWidgets(); - void addCardWidgets(const QPersistentModelIndex &persistent); DeckListModel *deckListModel; QItemSelectionModel *selectionModel; diff --git a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp index b00d9db1e..eaf3a67b0 100644 --- a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp @@ -5,7 +5,6 @@ #include "libcockatrice/card/database/card_database_manager.h" #include -#include #include DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent, @@ -52,6 +51,11 @@ DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent, // User Interaction // ===================================================================================================================== +void DeckCardZoneDisplayWidget::onClick(QMouseEvent *event, const ExactCard &card) +{ + emit cardClicked(event, card, zoneName); +} + void DeckCardZoneDisplayWidget::onHover(const ExactCard &card) { emit cardHovered(card); @@ -91,18 +95,12 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex } auto categoryName = index.sibling(index.row(), DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); - // Cards in a custom zone belong to that zone, not the board zone, so that - // increment/decrement/swap actions target the custom zone. - const bool isCustomZone = index.data(DeckRoles::IsCustomZoneRole).toBool(); - const QString effectiveZoneName = isCustomZone ? categoryName : zoneName; - const auto routeCardClick = [this, effectiveZoneName](QMouseEvent *event, const ExactCard &card) { - emit cardClicked(event, card, effectiveZoneName); - }; if (displayType == DisplayType::Overlap) { auto *displayWidget = new OverlappedCardGroupDisplayWidget( - cardGroupContainer, deckListModel, selectionModel, index, effectiveZoneName, categoryName, - activeGroupCriteria, activeSortCriteria, subBannerOpacity, cardSizeWidget); - connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardClicked, this, routeCardClick); + cardGroupContainer, deckListModel, selectionModel, index, zoneName, categoryName, activeGroupCriteria, + activeSortCriteria, subBannerOpacity, cardSizeWidget); + connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardClicked, this, + &DeckCardZoneDisplayWidget::onClick); connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardHovered, this, &DeckCardZoneDisplayWidget::onHover); connect(displayWidget, &CardGroupDisplayWidget::cleanupRequested, this, @@ -113,9 +111,9 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex indexToWidgetMap.insert(index, displayWidget); } else if (displayType == DisplayType::Flat) { auto *displayWidget = new FlatCardGroupDisplayWidget(cardGroupContainer, deckListModel, selectionModel, index, - effectiveZoneName, categoryName, activeGroupCriteria, + zoneName, categoryName, activeGroupCriteria, activeSortCriteria, subBannerOpacity, cardSizeWidget); - connect(displayWidget, &FlatCardGroupDisplayWidget::cardClicked, this, routeCardClick); + connect(displayWidget, &FlatCardGroupDisplayWidget::cardClicked, this, &DeckCardZoneDisplayWidget::onClick); connect(displayWidget, &FlatCardGroupDisplayWidget::cardHovered, this, &DeckCardZoneDisplayWidget::onHover); connect(displayWidget, &CardGroupDisplayWidget::cleanupRequested, this, &DeckCardZoneDisplayWidget::cleanupInvalidCardGroup); @@ -128,18 +126,24 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex void DeckCardZoneDisplayWidget::displayCards() { - if (!trackedIndex.isValid()) { - return; - } + QSortFilterProxyModel proxy; + proxy.setSourceModel(deckListModel); + proxy.setSortRole(Qt::EditRole); + proxy.sort(DeckListModelColumns::CARD_NAME, Qt::AscendingOrder); - // Iterate the direct children of the tracked zone, keeping the tree view's row - // order (criteria groups first, then custom zones, both in the model's sort order). - QList rows; - for (int i = 0; i < deckListModel->rowCount(trackedIndex); ++i) { - rows.append(QPersistentModelIndex(deckListModel->index(i, 0, trackedIndex))); - } + // 1. trackedIndex is a source index → map it to proxy space + QModelIndex proxyParent = proxy.mapFromSource(trackedIndex); + + // 2. iterate children under the proxy parent + for (int i = 0; i < proxy.rowCount(proxyParent); ++i) { + QModelIndex proxyIndex = proxy.index(i, 0, proxyParent); + + // 3. map back to source + QModelIndex sourceIndex = proxy.mapToSource(proxyIndex); + + // 4. persist the source index + QPersistentModelIndex persistent(sourceIndex); - for (const QPersistentModelIndex &persistent : rows) { constructAppropriateWidget(persistent); } } diff --git a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.h b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.h index 53f3fa7cf..b426fca30 100644 --- a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.h +++ b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.h @@ -42,6 +42,7 @@ public: void addCardsToOverlapWidget(); public slots: + void onClick(QMouseEvent *event, const ExactCard &card); void onHover(const ExactCard &card); void cleanupInvalidCardGroup(CardGroupDisplayWidget *displayWidget); void constructAppropriateWidget(QPersistentModelIndex index); diff --git a/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp b/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp index 00388a3cd..7c782b074 100644 --- a/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp @@ -90,13 +90,6 @@ void CardDatabaseView::decrementCard(const QString &zoneName) emit cardDecremented(currentCardName(), zoneName); } -void CardDatabaseView::setZoneMenuProvider(const std::function>()> &provider, - const std::function &newZoneHandler) -{ - zoneMenuProvider = provider; - this->newZoneHandler = newZoneHandler; -} - void CardDatabaseView::updateCard(const QModelIndex ¤t, const QModelIndex & /*previous*/) { if (!current.isValid()) { @@ -149,50 +142,6 @@ void CardDatabaseView::openCustomMenu(QPoint point) [this, card] { emit cardAdded(card->getName(), DECK_ZONE_SIDE); }); connect(selectPrinting, &QAction::triggered, this, &CardDatabaseView::selectPrintingClicked); - if (zoneMenuProvider) { - QMenu *addToZoneMenu = menu.addMenu(tr("Add to Zone")); - const auto zoneBoards = zoneMenuProvider(); - for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { - // Boards with zones nest their children so no two menu entries - // share a visible name: "Maindeck ▸ { Maindeck (whole board), … }". - const QStringList customZones = [&zoneBoards, boardName] { - for (const auto &zoneBoard : zoneBoards) { - if (zoneBoard.first == boardName) { - return zoneBoard.second; - } - } - return QStringList(); - }(); - if (customZones.isEmpty()) { - QAction *action = addToZoneMenu->addAction(InnerDecklistNode::visibleNameFromName(boardName)); - connect(action, &QAction::triggered, this, - [this, card, boardName] { emit cardAdded(card->getName(), boardName); }); - } else { - QMenu *boardSubmenu = addToZoneMenu->addMenu(InnerDecklistNode::visibleNameFromName(boardName)); - QAction *wholeBoardAction = boardSubmenu->addAction(InnerDecklistNode::visibleNameFromName(boardName)); - connect(wholeBoardAction, &QAction::triggered, this, - [this, card, boardName] { emit cardAdded(card->getName(), boardName); }); - for (const QString &zoneName : customZones) { - QAction *action = boardSubmenu->addAction(zoneName); - connect(action, &QAction::triggered, this, - [this, card, zoneName] { emit cardAdded(card->getName(), zoneName); }); - } - } - } - - if (newZoneHandler) { - addToZoneMenu->addSeparator(); - - QAction *newZoneAction = addToZoneMenu->addAction(tr("Create &new zone...")); - connect(newZoneAction, &QAction::triggered, this, [this, card] { - const QString zoneName = newZoneHandler(); - if (!zoneName.isEmpty()) { - emit cardAdded(card->getName(), zoneName); - } - }); - } - } - if (canBeCommander(*card)) { QAction *edhRecCommander = menu.addAction(tr("Show on EDHRec (Commander)")); connect(edhRecCommander, &QAction::triggered, this, [this, card] { emit edhrecClicked(card, true); }); diff --git a/cockatrice/src/interface/widgets/deck_editor/card_database_view.h b/cockatrice/src/interface/widgets/deck_editor/card_database_view.h index 668444199..175ec12b9 100644 --- a/cockatrice/src/interface/widgets/deck_editor/card_database_view.h +++ b/cockatrice/src/interface/widgets/deck_editor/card_database_view.h @@ -4,7 +4,6 @@ #include "../../key_signals.h" #include -#include #include class CardDatabaseModel; @@ -20,13 +19,6 @@ class CardDatabaseView : public QTreeView KeySignals searchKeySignals; CardDatabaseDisplayModel *databaseDisplayModel; - /// Provides the custom zones available in the current deck, grouped by board zone. - /// The list contains (board zone name, custom zone names) pairs for every board. - std::function>()> zoneMenuProvider; - /// Handler invoked when the user picks "New zone..." from the add-to-zone menu. - /// Returns the name of the created zone, or an empty string if creation was cancelled. - std::function newZoneHandler; - public: explicit CardDatabaseView(QWidget *parent, CardDatabaseDisplayModel *model); @@ -41,17 +33,6 @@ public: return &searchKeySignals; } - /** - * @brief Sets the provider used to populate the "Add to zone" submenu of the context menu. - * If no provider is set, the submenu is not shown. - * - * @param provider Returns the custom zones of the current deck, grouped by board zone - * @param newZoneHandler Creates a new custom zone and returns its name, or an empty string - * if creation was cancelled. The menu entry is hidden when not provided. - */ - void setZoneMenuProvider(const std::function>()> &provider, - const std::function &newZoneHandler); - signals: void cardChanged(const QString &cardName); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.cpp index 6269f0323..2a491de4f 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.cpp @@ -1,12 +1,5 @@ #include "deck_editor_card_database_dock_widget.h" -#include "../../../interface/widgets/tabs/abstract_tab_deck_editor.h" -#include "card_database_view.h" -#include "deck_state_manager.h" -#include "deck_zone_dialog.h" - -#include - DeckEditorCardDatabaseDockWidget::DeckEditorCardDatabaseDockWidget(AbstractTabDeckEditor *parent) : QDockWidget(parent) { setObjectName("databaseDisplayDock"); @@ -22,27 +15,6 @@ void DeckEditorCardDatabaseDockWidget::createDatabaseDisplayDock(AbstractTabDeck { databaseDisplayWidget = new DeckEditorDatabaseDisplayWidget(this, deckEditor->databaseModel); - databaseDisplayWidget->getDatabaseView()->setZoneMenuProvider( - [deckEditor]() -> QList> { - QList> result; - auto *deckListModel = deckEditor->deckStateManager->getModel(); - for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { - result.append({boardName, deckListModel->getCustomZoneNames(boardName)}); - } - return result; - }, - [this, deckEditor]() -> QString { - QString boardName; - const QString zoneName = - DeckZoneDialog::promptForNewZone(this, {}, &boardName, [deckEditor](const QString &candidate) { - return deckEditor->deckStateManager->validateNewZoneName(candidate); - }); - if (!zoneName.isEmpty()) { - deckEditor->deckStateManager->createCustomZone(boardName, zoneName); - } - return zoneName; - }); - auto *frame = new QVBoxLayout; frame->setObjectName("databaseDisplayFrame"); frame->addWidget(databaseDisplayWidget); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp index e2175a358..14defc8e9 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp @@ -7,18 +7,15 @@ #include "../tabs/api/commander_spellbook/commander_bracket_widget.h" #include "deck_list_style_proxy.h" #include "deck_state_manager.h" -#include "deck_zone_dialog.h" #include #include #include #include -#include #include #include #include #include -#include #include #include #include @@ -775,213 +772,14 @@ void DeckEditorDeckDockWidget::offsetCountAtIndex(const QModelIndex &idx, bool i void DeckEditorDeckDockWidget::decklistCustomMenu(QPoint point) { - const QModelIndex sourceIndex = proxy->mapToSource(deckView->indexAt(point)); - QMenu menu; - const bool isCustomZoneRow = sourceIndex.isValid() && sourceIndex.data(DeckRoles::IsCustomZoneRole).toBool(); - const bool isBoardZoneRow = sourceIndex.isValid() && !isCustomZoneRow && !sourceIndex.parent().isValid(); - const bool isCardRow = - sourceIndex.isValid() && !isCustomZoneRow && !isBoardZoneRow && !getModel()->hasChildren(sourceIndex); - - // Walk the row up to its top-level node to find the hosting board. Cards in - // the tokens board cannot be moved (moveCardToZone bails for it), so the - // move menu is skipped for them. - QString currentBoardName; - QModelIndex board = sourceIndex.parent(); - while (board.isValid() && board.parent().isValid()) { - board = board.parent(); - } - if (board.isValid()) { - currentBoardName = board.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); - } - - if (isCardRow) { - if (currentBoardName != DECK_ZONE_TOKENS) { - addMoveToZoneMenu(&menu, sourceIndex, currentBoardName); - menu.addSeparator(); - } - } else if (isCustomZoneRow) { - const QString zoneName = - sourceIndex.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); - - QAction *renameAction = menu.addAction(tr("&Rename zone...")); - connect(renameAction, &QAction::triggered, this, [this, zoneName] { - // The unchanged name must not validate as a duplicate. - const QString newName = - DeckZoneDialog::promptForRename(this, zoneName, [this, zoneName](const QString &candidate) { - return candidate == zoneName ? QString() : deckStateManager->validateNewZoneName(candidate); - }); - if (!newName.isEmpty() && newName != zoneName) { - deckStateManager->renameCustomZone(zoneName, newName); - } - }); - - QMenu *boardMenu = menu.addMenu(tr("Change &board")); - addChangeBoardMenu(boardMenu, zoneName); - - QAction *deleteAction = menu.addAction(tr("&Delete zone")); - const bool zoneHasCards = getModel()->hasChildren(sourceIndex); - deleteAction->setEnabled(!zoneHasCards); - if (zoneHasCards) { - deleteAction->setToolTip(tr("Move or remove all cards first.")); - menu.setToolTipsVisible(true); - } - connect(deleteAction, &QAction::triggered, this, [this, zoneName] { - const auto result = - QMessageBox::warning(this, tr("Delete zone"), tr("Delete the zone \"%1\"?").arg(zoneName), - QMessageBox::Yes | QMessageBox::No, QMessageBox::No); - if (result == QMessageBox::Yes) { - deckStateManager->removeCustomZone(zoneName); - } - }); - menu.addSeparator(); - } else if (isBoardZoneRow) { - const QString boardName = - sourceIndex.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); - // Tokens cannot host custom zones, so only offer the action on real boards. - const bool canHostCustomZones = - boardName == DECK_ZONE_MAIN || boardName == DECK_ZONE_SIDE || boardName == DECK_ZONE_MAYBEBOARD; - if (canHostCustomZones) { - addNewZoneAction(&menu, boardName); - menu.addSeparator(); - } - } else if (!sourceIndex.isValid()) { - addNewZoneAction(&menu); - menu.addSeparator(); - } - QAction *selectPrinting = menu.addAction(tr("Select Printing")); connect(selectPrinting, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::showPrintingSelector); menu.exec(deckView->mapToGlobal(point)); } -void DeckEditorDeckDockWidget::addMoveToZoneMenu(QMenu *menu, - const QModelIndex &sourceCardIndex, - const QString ¤tBoardName) -{ - // The card's current *zone*, derived with the same ancestor walk as - // DeckStateManager::moveCardToZone (nearest custom-zone ancestor, else the - // top-level board/zone): a card inside "Removal" under the maindeck lives in - // "Removal", not "main". Comparing against that instead of the board keeps - // the enabled state and the same-zone no-op consistent with the move logic. - QString currentZoneName; - for (QModelIndex ancestor = sourceCardIndex.parent(); ancestor.isValid(); ancestor = ancestor.parent()) { - if (ancestor.data(DeckRoles::IsCustomZoneRole).toBool() || !ancestor.parent().isValid()) { - currentZoneName = ancestor.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); - break; - } - } - - const auto addMoveAction = [this, sourceCardIndex](QMenu *targetMenu, const QString &targetZoneName, - const QString &label, bool enabled) { - QAction *action = targetMenu->addAction(label); - action->setEnabled(enabled); - if (enabled) { - connect(action, &QAction::triggered, this, [this, sourceCardIndex, targetZoneName] { - deckStateManager->moveCardToZone(sourceCardIndex, targetZoneName); - }); - } - }; - - const auto tree = deckStateManager->getDeckListShared()->getTree(); - - QMenu *moveMenu = menu->addMenu(tr("Move to &zone")); - - for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { - const QString boardLabel = InnerDecklistNode::visibleNameFromName(boardName); - const auto customZones = tree->getCustomZones(boardName); - - // Boards with zones nest their children so no two menu entries share a - // visible name: "Maindeck ▸ { Maindeck (whole board), Removal, … }". - // The board the card already lives on is marked instead of offered. - if (!customZones.isEmpty()) { - QMenu *boardSubmenu = moveMenu->addMenu(boardLabel); - addMoveAction(boardSubmenu, boardName, boardLabel, boardName != currentZoneName); - for (const auto *customZone : customZones) { - addMoveAction(boardSubmenu, customZone->getName(), customZone->getName(), - customZone->getName() != currentZoneName); - } - } else { - addMoveAction(moveMenu, boardName, boardLabel, boardName != currentZoneName); - } - } - - moveMenu->addSeparator(); - - QAction *newZoneAction = moveMenu->addAction(tr("Create new zone and move &here...")); - connect(newZoneAction, &QAction::triggered, this, [this, sourceCardIndex, currentBoardName, currentZoneName] { - // Resolve the card's identity before creating the zone: - // createNewCustomZone rebuilds the model tree, so sourceCardIndex's - // internal pointer is freed by the time it would be used. - const QString cardName = - sourceCardIndex.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); - const QString providerId = - sourceCardIndex.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data(Qt::DisplayRole).toString(); - const QString collectorNumber = sourceCardIndex.siblingAtColumn(DeckListModelColumns::CARD_COLLECTOR_NUMBER) - .data(Qt::DisplayRole) - .toString(); - - const QString zoneName = createNewCustomZone(currentBoardName); - if (!zoneName.isEmpty()) { - // Re-find the card: the old index is no longer safe since rows were - // rebuilt. Mirror DeckStateManager::decrementCard's re-find pattern. - const QModelIndex refreshed = getModel()->findCard(cardName, currentZoneName, providerId, collectorNumber); - if (refreshed.isValid()) { - deckStateManager->moveCardToZone(refreshed, zoneName); - } - } - }); -} - -void DeckEditorDeckDockWidget::addChangeBoardMenu(QMenu *menu, const QString &zoneName) -{ - const auto tree = deckStateManager->getDeckListShared()->getTree(); - for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { - QAction *action = menu->addAction(InnerDecklistNode::visibleNameFromName(boardName)); - - // The board currently holding the zone is marked instead of offered. - // Duplicate names cannot come up through the editor, so this doubles as - // the uniqueness guard for imported decks. - bool holdsTheZone = false; - for (const auto *customZone : tree->getCustomZones(boardName)) { - if (customZone->getName() == zoneName) { - holdsTheZone = true; - break; - } - } - if (holdsTheZone) { - action->setCheckable(true); - action->setChecked(true); - continue; - } - - connect(action, &QAction::triggered, this, - [this, zoneName, boardName] { deckStateManager->moveCustomZone(zoneName, boardName); }); - } -} - -void DeckEditorDeckDockWidget::addNewZoneAction(QMenu *menu, const QString &initialBoardName) -{ - QAction *newZoneAction = menu->addAction(tr("Create &new zone...")); - connect(newZoneAction, &QAction::triggered, this, - [this, initialBoardName] { createNewCustomZone(initialBoardName); }); -} - -QString DeckEditorDeckDockWidget::createNewCustomZone(const QString &initialBoardName) -{ - QString boardName; - const QString zoneName = - DeckZoneDialog::promptForNewZone(this, initialBoardName, &boardName, [this](const QString &candidate) { - return deckStateManager->validateNewZoneName(candidate); - }); - if (!zoneName.isEmpty()) { - deckStateManager->createCustomZone(boardName, zoneName); - } - return zoneName; -} - void DeckEditorDeckDockWidget::refreshShortcuts() { ShortcutsSettings &shortcuts = SettingsCache::instance().shortcuts(); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h index 1e5f4e677..a55056bda 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h @@ -15,12 +15,8 @@ #include "deck_list_history_manager_widget.h" #include "deck_list_style_proxy.h" -#include -#include #include #include -#include -#include #include #include #include @@ -103,11 +99,6 @@ private: [[nodiscard]] QModelIndexList getSelectedCardNodeSourceIndices() const; void offsetCountAtIndex(const QModelIndex &idx, bool isIncrement); - void addMoveToZoneMenu(QMenu *menu, const QModelIndex &sourceCardIndex, const QString ¤tBoardName); - void addChangeBoardMenu(QMenu *menu, const QString &zoneName); - QString createNewCustomZone(const QString &initialBoardName = {}); - void addNewZoneAction(QMenu *menu, const QString &initialBoardName = {}); - private slots: void decklistCustomMenu(QPoint point); void updateCard(QModelIndex, const QModelIndex ¤t); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp index e563729a4..eda741728 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp @@ -2,7 +2,6 @@ #include #include -#include DeckStateManager::DeckStateManager(QObject *parent) : QObject(parent), deckList(QSharedPointer(new DeckList)), @@ -308,170 +307,6 @@ bool DeckStateManager::decrementCountAtIndex(const QModelIndex &idx) return offsetCountAtIndex(idx, -1); } -bool DeckStateManager::moveCardToZone(const QModelIndex &idx, const QString &targetZoneName) -{ - if (!idx.isValid()) { - return false; - } - - // Only actual card rows can be moved. Group or zone rows report an - // aggregate amount and must never be deleted by this operation. - if (!idx.data(DeckRoles::IsCardRole).toBool()) { - return false; - } - - QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); - QString providerId = idx.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data(Qt::DisplayRole).toString(); - int copies = idx.siblingAtColumn(DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt(); - - if (copies <= 0) { - return false; - } - - // Tokens only live in the tokens zone and cannot be moved into decks. - CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(cardName); - if (info && info->getIsToken()) { - return false; - } - - // Determine the zone the card currently lives in: the enclosing custom - // zone, or the nearest top-level zone (board zone or legacy zone). - QString currentZoneName; - for (QModelIndex ancestor = idx.parent(); ancestor.isValid(); ancestor = ancestor.parent()) { - bool isCustomZone = ancestor.data(DeckRoles::IsCustomZoneRole).toBool(); - if (isCustomZone || !ancestor.parent().isValid()) { - currentZoneName = ancestor.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); - break; - } - } - - if (currentZoneName == targetZoneName) { - return false; - } - - QString reason = tr("Moved %1 × \"%2\" (%3) to %4") - .arg(copies) - .arg(cardName) - .arg(providerId) - .arg(InnerDecklistNode::visibleNameFromName(targetZoneName)); - - return modifyDeck(reason, [&idx, &cardName, &providerId, &targetZoneName, copies](auto model) { - if (!model->removeRow(idx.row(), idx.parent())) { - return false; - } - - if (ExactCard card = CardDatabaseManager::query()->getCard({cardName, providerId})) { - for (int i = 0; i < copies; ++i) { - model->addCard(card, targetZoneName); - } - } else { - for (int i = 0; i < copies; ++i) { - model->addPreferredPrintingCard(cardName, targetZoneName, true); - } - } - - return true; - }); -} - -bool DeckStateManager::createCustomZone(const QString &boardZoneName, const QString &zoneName) -{ - const QString trimmedZoneName = zoneName.trimmed(); - if (trimmedZoneName.isEmpty()) { - return false; - } - - QString reason = - tr("Created zone \"%1\" in %2").arg(trimmedZoneName, InnerDecklistNode::visibleNameFromName(boardZoneName)); - - return modifyTree(reason, [&boardZoneName, &trimmedZoneName](DecklistNodeTree *tree) { - return tree->addCustomZone(boardZoneName, trimmedZoneName) != nullptr; - }); -} - -bool DeckStateManager::renameCustomZone(const QString &oldZoneName, const QString &newZoneName) -{ - const QString trimmedNewZoneName = newZoneName.trimmed(); - if (trimmedNewZoneName.isEmpty() || oldZoneName == trimmedNewZoneName) { - return false; - } - - QString reason = tr("Renamed zone \"%1\" to \"%2\"").arg(oldZoneName, trimmedNewZoneName); - - return modifyTree(reason, [&oldZoneName, &trimmedNewZoneName](DecklistNodeTree *tree) { - return tree->renameCustomZone(oldZoneName, trimmedNewZoneName); - }); -} - -bool DeckStateManager::moveCustomZone(const QString &zoneName, const QString &newBoardZoneName) -{ - const auto *tree = deckList->getTree(); - - // Locate the zone through the tree's own lookup, which walks every top-level - // zone (not just the standard boards) and covers the same-board no-op below. - const auto *zone = tree->findCustomZoneByName(zoneName); - if (!zone) { - return false; - } - - // Same-board moves are no-ops and must not pollute the history. - const QString currentBoardName = zone->getParent() ? zone->getParent()->getName() : QString(); - if (currentBoardName == newBoardZoneName) { - return true; - } - - // Zone names are deck-unique among zones created through this manager, so a - // same-named zone on the target board can only come from an imported deck. - // Refuse the move instead of silently stacking same-named zones. - for (const auto *targetZone : tree->getCustomZones(newBoardZoneName)) { - if (targetZone->getName() == zoneName) { - return false; - } - } - - QString reason = - tr("Moved zone \"%1\" to %2").arg(zoneName, InnerDecklistNode::visibleNameFromName(newBoardZoneName)); - - return modifyTree(reason, [&zoneName, &newBoardZoneName](DecklistNodeTree *tree) { - return tree->moveCustomZone(zoneName, newBoardZoneName); - }); -} - -bool DeckStateManager::removeCustomZone(const QString &zoneName) -{ - QString reason = tr("Deleted zone \"%1\"").arg(zoneName); - - return modifyTree(reason, [&zoneName](DecklistNodeTree *tree) { return tree->removeCustomZone(zoneName); }); -} - -QString DeckStateManager::validateNewZoneName(const QString &zoneName) const -{ - if (zoneName.trimmed().isEmpty()) { - return tr("Enter a zone name."); - } - - const QString trimmedZoneName = zoneName.trimmed(); - - // The standard zone names are reserved even before they exist. - if (trimmedZoneName == DECK_ZONE_MAIN || trimmedZoneName == DECK_ZONE_SIDE || - trimmedZoneName == DECK_ZONE_MAYBEBOARD || trimmedZoneName == DECK_ZONE_TOKENS) { - return tr("This name is reserved."); - } - - const auto *tree = deckList->getTree(); - - // Reuse the tree's own uniqueness contract: any top-level zone and any - // custom zone on *every* board claims the name (hasZoneName also reserves - // the standard board names, which we already rejected with a dedicated - // message above). Scanning only the standard boards here would miss a - // custom zone an imported deck carries under `tokens`. - if (tree->hasZoneName(trimmedZoneName)) { - return tr("A zone with this name already exists."); - } - - return {}; -} - bool DeckStateManager::offsetCountAtIndex(const QModelIndex &idx, int offset) { if (!idx.isValid()) { @@ -532,25 +367,6 @@ void DeckStateManager::requestHistorySave(const QString &reason) historyManager->save(deckList->createMemento(reason)); } -bool DeckStateManager::modifyTree(const QString &reason, const std::function &operation) -{ - DeckListMemento memento = deckList->createMemento(reason); - bool success = operation(deckList->getTree()); - - if (success) { - historyManager->save(memento); - deckListModel->rebuildTree(); - deckList->refreshDeckHash(); - emit deckListModel->deckHashChanged(); - // removeCustomZone can drop whole card sets the model never notified - // about (rebuildTree emits no cardNodesChanged), so tell the consumers. - emit deckListModel->cardNodesChanged(); - doCardModified(); - } - - return success; -} - /** * @brief Handles updating state and emitting signals whenever the cards are modified */ diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h index 2c8b34a39..b9c99903e 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h @@ -5,7 +5,6 @@ #include "deck_list_model.h" #include -#include #include class DeckListHistoryManager; @@ -237,68 +236,6 @@ public: */ bool decrementCountAtIndex(const QModelIndex &idx); - /** - * @brief Moves all copies of the card at the given index to the given zone. - * No-ops if the index is invalid, not a card node, the card is a token, or the - * card is already in the target zone. - * Saves the operation to history if successful. - * - * @param idx The model index of the card to move - * @param targetZoneName The zone to move the card to (board zone or custom zone name) - * @return Whether the operation was successfully performed - */ - bool moveCardToZone(const QModelIndex &idx, const QString &targetZoneName); - - /** - * @brief Creates a new custom zone nested under a board zone. - * Saves the operation to history if successful. - * - * @param boardZoneName The board zone to nest the custom zone under - * @param zoneName The name of the new custom zone. Gets trimmed and must be - * unique across the deck. - * @return Whether the zone was created - */ - bool createCustomZone(const QString &boardZoneName, const QString &zoneName); - - /** - * @brief Renames a custom zone. - * Saves the operation to history if successful. - * - * @param oldZoneName The current name of the custom zone - * @param newZoneName The new name. Gets trimmed and must be unique across the deck. - * @return Whether the rename succeeded - */ - bool renameCustomZone(const QString &oldZoneName, const QString &newZoneName); - - /** - * @brief Moves a custom zone (and its cards) to a different board zone. - * Same-board moves succeed without creating a history entry. - * Saves the operation to history if successful. - * - * @param zoneName The custom zone to move - * @param newBoardZoneName The board zone to move the custom zone under - * @return Whether the move succeeded - */ - bool moveCustomZone(const QString &zoneName, const QString &newBoardZoneName); - - /** - * @brief Removes a custom zone and all its cards. - * Saves the operation to history if successful. - * - * @param zoneName The custom zone to remove - * @return Whether the zone was removed - */ - bool removeCustomZone(const QString &zoneName); - - /** - * @brief Checks whether a candidate name is usable for a new custom zone. - * - * @param zoneName The candidate name - * @return An empty string when the name is usable, otherwise a user-facing - * error message describing the problem - */ - [[nodiscard]] QString validateNewZoneName(const QString &zoneName) const; - /** * Undoes n steps of the history, setting the decklist state and updating the current step in the historyManager. * @param steps Number of steps to undo. @@ -320,7 +257,6 @@ public slots: private: bool offsetCountAtIndex(const QModelIndex &idx, int offset); - bool modifyTree(const QString &reason, const std::function &operation); void doCardModified(); void doMetadataModified(); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.cpp deleted file mode 100644 index 9a0be2570..000000000 --- a/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.cpp +++ /dev/null @@ -1,145 +0,0 @@ -#include "deck_zone_dialog.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -DeckZoneDialog::DeckZoneDialog(QWidget *parent, - const QString &initialBoardName, - const std::function &_nameValidator, - bool _allowBoardSelection) - : QDialog(parent), nameValidator(_nameValidator), allowBoardSelection(_allowBoardSelection) -{ - nameLabel = new QLabel(this); - nameEdit = new QLineEdit(this); - nameEdit->setMaxLength(MAX_NAME_LENGTH); - - errorLabel = new QLabel(this); - errorLabel->hide(); - - boardLabel = new QLabel(this); - boardCombo = new QComboBox(this); - for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { - // Use the icon overload explicitly so `boardName` lands in the user data role - // (visible text is applied below in retranslateUi). The two-argument form - // addItem({}, boardName) would be ambiguous and resolve to the icon overload - // with empty user data, yielding empty entries and an empty getBoardName(). - boardCombo->addItem({}, {}, boardName); - } - if (!initialBoardName.isEmpty()) { - int idx = boardCombo->findData(initialBoardName); - if (idx != -1) { - boardCombo->setCurrentIndex(idx); - } - } - - buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); - buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false); - connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); - connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); - - auto *layout = new QVBoxLayout(this); - layout->addWidget(nameLabel); - layout->addWidget(nameEdit); - layout->addWidget(errorLabel); - if (allowBoardSelection) { - layout->addWidget(boardLabel); - layout->addWidget(boardCombo); - } else { - boardLabel->hide(); - boardCombo->hide(); - } - layout->addWidget(buttonBox); - - retranslateUi(); - - connect(nameEdit, &QLineEdit::textChanged, this, [this] { validateName(); }); - validateName(); - - nameEdit->setFocus(); -} - -QString DeckZoneDialog::getZoneName() const -{ - return nameEdit->text().trimmed(); -} - -QString DeckZoneDialog::getBoardName() const -{ - return boardCombo->currentData().toString(); -} - -void DeckZoneDialog::setZoneName(const QString &zoneName) -{ - nameEdit->setText(zoneName); - nameEdit->selectAll(); -} - -void DeckZoneDialog::changeEvent(QEvent *event) -{ - QDialog::changeEvent(event); - - if (event->type() == QEvent::LanguageChange) { - retranslateUi(); - } -} - -void DeckZoneDialog::retranslateUi() -{ - setWindowTitle(allowBoardSelection ? tr("New zone") : tr("Rename zone")); - - nameLabel->setText(tr("Zone &name:")); - nameLabel->setBuddy(nameEdit); - - boardLabel->setText(tr("&Parent zone:")); - boardLabel->setBuddy(boardCombo); - - for (int i = 0; i < boardCombo->count(); i++) { - boardCombo->setItemText(i, InnerDecklistNode::visibleNameFromName(boardCombo->itemData(i).toString())); - } -} - -void DeckZoneDialog::validateName() -{ - const QString zoneName = nameEdit->text().trimmed(); - QString error; - if (zoneName.isEmpty()) { - error = tr("Enter a zone name."); - } else if (nameValidator) { - error = nameValidator(zoneName); - } - - errorLabel->setText(error); - errorLabel->setVisible(!error.isEmpty()); - buttonBox->button(QDialogButtonBox::Ok)->setEnabled(error.isEmpty()); -} - -QString DeckZoneDialog::promptForNewZone(QWidget *parent, - const QString &initialBoardName, - QString *chosenBoardName, - const std::function &nameValidator) -{ - DeckZoneDialog dialog(parent, initialBoardName, nameValidator); - if (dialog.exec() != QDialog::Accepted) { - return {}; - } - - if (chosenBoardName) { - *chosenBoardName = dialog.getBoardName(); - } - return dialog.getZoneName(); -} - -QString DeckZoneDialog::promptForRename(QWidget *parent, - const QString ¤tZoneName, - const std::function &nameValidator) -{ - DeckZoneDialog dialog(parent, {}, nameValidator, false); - dialog.setZoneName(currentZoneName); - return dialog.exec() == QDialog::Accepted ? dialog.getZoneName() : QString(); -} diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h b/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h deleted file mode 100644 index 6f55617a8..000000000 --- a/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h +++ /dev/null @@ -1,123 +0,0 @@ -/** - * @file deck_zone_dialog.h - * @ingroup DeckEditorWidgets - * @brief Shared dialog for creating custom deck zones. - */ - -#ifndef DECK_ZONE_DIALOG_H -#define DECK_ZONE_DIALOG_H - -#include -#include -#include -#include - -class QComboBox; -class QDialogButtonBox; -class QLabel; -class QLineEdit; -class QWidget; - -/** - * @brief Modal dialog asking for the name and parent zone of a new custom deck zone. - * - * Menus construct the dialog transiently around exec(), so validation state only - * ever reflects the name currently typed. - */ -class DeckZoneDialog : public QDialog -{ - Q_OBJECT - -public: - /** - * @brief Constructs the dialog and runs the initial validation pass. - * - * @param parent The parent widget for the dialog - * @param initialBoardName The board zone to preselect in the combo. Unknown names - * fall back to main. - * @param _nameValidator Given the trimmed candidate name, returns an empty string - * when it is usable, otherwise a user-facing error message. May be empty. - * @param _allowBoardSelection When false the parent-zone combo is hidden and the - * dialog acts as a rename prompt for an existing zone. - */ - explicit DeckZoneDialog(QWidget *parent = nullptr, - const QString &initialBoardName = {}, - const std::function &_nameValidator = {}, - bool _allowBoardSelection = true); - - /** - * @brief The trimmed zone name entered by the user. - */ - [[nodiscard]] QString getZoneName() const; - - /** - * @brief The internal name of the board zone selected in the combo. - */ - [[nodiscard]] QString getBoardName() const; - - /** - * @brief Prefills the name field, e.g. with the current name when renaming. - * - * @param zoneName The text to put into the name field, selected for quick editing - */ - void setZoneName(const QString &zoneName); - - /** - * @brief Prompts the user for a new custom zone name and the board zone to nest it under. - * - * Convenience wrapper that runs DeckZoneDialog modally. - * - * @param parent The parent widget for the dialog - * @param initialBoardName The board zone to preselect in the dialog. Unknown names fall - * back to main. - * @param chosenBoardName (out) The internal name of the board zone the user chose - * @param nameValidator Optional validator forwarded to the dialog - * @return The trimmed zone name, or an empty string if the user cancelled - */ - static QString promptForNewZone(QWidget *parent, - const QString &initialBoardName, - QString *chosenBoardName, - const std::function &nameValidator = {}); - - /** - * @brief Prompts the user for a new name for an existing custom zone. - * - * Same inline validation as promptForNewZone, but without a parent-zone picker. - * - * @param parent The parent widget for the dialog - * @param currentZoneName The current name, prefilled for editing - * @param nameValidator Validator deciding whether a candidate name is usable. It sees - * the current name too, so callers wanting to allow unchanged names must - * special-case that themselves. - * @return The trimmed new name, or an empty string if the user cancelled - */ - static QString promptForRename(QWidget *parent, - const QString ¤tZoneName, - const std::function &nameValidator = {}); - -protected: - void changeEvent(QEvent *event) override; - -private: - /** - * @brief Sets every user-visible string. Runs on construction and on runtime - * language changes. - */ - void retranslateUi(); - - /** - * @brief Validates the current input, toggling Ok and the inline error label. - */ - void validateName(); - - QLabel *nameLabel; - QLineEdit *nameEdit; - QLabel *errorLabel; - QLabel *boardLabel; - QComboBox *boardCombo; - QDialogButtonBox *buttonBox; - std::function nameValidator; - bool allowBoardSelection; -}; - -#endif // DECK_ZONE_DIALOG_H diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp index a4a31d78d..198fa259b 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp @@ -1,17 +1,9 @@ #include "dlg_convert_deck_to_cod_format.h" -#include "../../../client/settings/cache_settings.h" -#include "../../deck_loader/deck_loader.h" - #include #include -#include -#include -#include #include -#include #include -#include DialogConvertDeckToCodFormat::DialogConvertDeckToCodFormat(QWidget *parent) : QDialog(parent) { @@ -46,71 +38,3 @@ bool DialogConvertDeckToCodFormat::dontAskAgain() const { return dontAskAgainCheckbox->isChecked(); } - -namespace -{ - -bool confirmOverwriteIfExists(QWidget *parent, const QString &filePath) -{ - QFileInfo fileInfo(filePath); - QString newFileName = QDir::toNativeSeparators(fileInfo.path() + "/" + fileInfo.completeBaseName() + ".cod"); - - if (QFile::exists(newFileName)) { - QMessageBox::StandardButton reply = - QMessageBox::question(parent, QObject::tr("Overwrite Existing File?"), - QObject::tr("A .cod version of this deck already exists. Overwrite it?"), - QMessageBox::Yes | QMessageBox::No); - return reply == QMessageBox::Yes; - } - return true; // Safe to proceed -} - -} // namespace - -bool DialogConvertDeckToCodFormat::promptIfRequired(QWidget *parent, - const QString &filePath, - const std::function &convert) -{ - if (DeckFileFormat::getFormatFromName(filePath) == DeckFileFormat::Cockatrice) { - return true; - } - - // Retrieve saved preference if the prompt is disabled - if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStoragePromptForConversion()) { - if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStorageAlwaysConvert()) { - return false; - } - - if (!confirmOverwriteIfExists(parent, filePath)) { - return false; - } - - return convert(); - } - - // Show the dialog to the user - DialogConvertDeckToCodFormat conversionDialog(parent); - if (conversionDialog.exec() != QDialog::Accepted) { - SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion( - !conversionDialog.dontAskAgain()); - SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(false); - - return false; - } - - // Try to convert file - if (!confirmOverwriteIfExists(parent, filePath)) { - return false; - } - - if (!convert()) { - return false; - } - - if (conversionDialog.dontAskAgain()) { - SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion(false); - SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(true); - } - - return true; -} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h b/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h index 526582135..6642ad8c6 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h @@ -13,9 +13,6 @@ #include #include #include -#include - -class QWidget; class DialogConvertDeckToCodFormat : public QDialog { @@ -27,21 +24,6 @@ public: [[nodiscard]] bool dontAskAgain() const; - /** - * @brief Checks whether the deck file at \a filePath can store tags. - * - * If the file is not a .cod deck, prompts the user for conversion to the - * Cockatrice format, honoring the saved "always convert / don't ask again" - * preference. On acceptance \a convert is called to perform the conversion. - * - * @param parent The widget to parent the prompt to. - * @param filePath The path of the deck file to check. - * @param convert Called to convert the deck once the user agrees. - * @return true if tags can be stored (no conversion needed, or the conversion - * was performed), false if the user declined to convert. - */ - static bool promptIfRequired(QWidget *parent, const QString &filePath, const std::function &convert); - private: QVBoxLayout *layout; QLabel *label; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp index 6ae8c9adb..fce99a1a7 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp @@ -1,62 +1,18 @@ #include "dlg_register.h" #include "../../../client/settings/cache_settings.h" -#include "../server/handle_public_servers.h" -#include "../server/user/user_info_connection.h" -#include +#include #include #include -#include #include #include #include -#include -#include -#include #include #include DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) { - // ── Server picker ────────────────────────────────────────────────── - previousHostButton = new QRadioButton(tr("Known Hosts"), this); - previousHosts = new QComboBox(this); - - btnDeleteServer = new QPushButton(this); - btnDeleteServer->setIcon(QPixmap("theme:icons/remove_row")); - btnDeleteServer->setToolTip(tr("Delete the currently selected saved server")); - btnDeleteServer->setFixedWidth(30); - - connect(btnDeleteServer, &QPushButton::clicked, this, &DlgRegister::actRemoveSavedServer); - - hps = new HandlePublicServers(this); - btnRefreshServers = new QPushButton(this); - btnRefreshServers->setIcon(QPixmap("theme:icons/sync")); - btnRefreshServers->setToolTip(tr("Refresh the server list with known public servers")); - btnRefreshServers->setFixedWidth(30); - - connect(hps, &HandlePublicServers::sigPublicServersDownloadedSuccessfully, this, [this] { rebuildComboBoxList(); }); - connect(hps, &HandlePublicServers::sigPublicServersDownloadedUnsuccessfully, this, - &DlgRegister::rebuildComboBoxList); - connect(btnRefreshServers, &QPushButton::released, this, &DlgRegister::downloadThePublicServers); - - newHostButton = new QRadioButton(tr("New Host"), this); - - auto *serverPickerRow = new QHBoxLayout; - serverPickerRow->addWidget(previousHosts); - serverPickerRow->addWidget(btnDeleteServer); - serverPickerRow->addWidget(btnRefreshServers); - - auto *serverGroupLayout = new QVBoxLayout; - serverGroupLayout->addWidget(previousHostButton); - serverGroupLayout->addLayout(serverPickerRow); - serverGroupLayout->addWidget(newHostButton); - - auto *serverGroupBox = new QGroupBox(tr("Server")); - serverGroupBox->setLayout(serverGroupLayout); - - // ── Registration fields ──────────────────────────────────────────── ServersSettings &servers = SettingsCache::instance().servers(); infoLabel = new QLabel(tr("Enter your information and the information of the server you'd like to register to.\n" "Your email will be used to verify your account.")); @@ -365,28 +321,26 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) realnameEdit->setMaxLength(MAX_NAME_LENGTH); realnameLabel->setBuddy(realnameEdit); - // ── Layout ───────────────────────────────────────────────────────── auto *grid = new QGridLayout; - grid->addWidget(serverGroupBox, 0, 0, 1, 2); - grid->addWidget(infoLabel, 1, 0, 1, 2); - grid->addWidget(hostLabel, 2, 0); - grid->addWidget(hostEdit, 2, 1); - grid->addWidget(portLabel, 3, 0); - grid->addWidget(portEdit, 3, 1); - grid->addWidget(playernameLabel, 4, 0); - grid->addWidget(playernameEdit, 4, 1); - grid->addWidget(passwordLabel, 5, 0); - grid->addWidget(passwordEdit, 5, 1); - grid->addWidget(passwordConfirmationLabel, 6, 0); - grid->addWidget(passwordConfirmationEdit, 6, 1); - grid->addWidget(emailLabel, 7, 0); - grid->addWidget(emailEdit, 7, 1); - grid->addWidget(emailConfirmationLabel, 8, 0); - grid->addWidget(emailConfirmationEdit, 8, 1); - grid->addWidget(countryLabel, 10, 0); - grid->addWidget(countryEdit, 10, 1); - grid->addWidget(realnameLabel, 11, 0); - grid->addWidget(realnameEdit, 11, 1); + grid->addWidget(infoLabel, 0, 0, 1, 2); + grid->addWidget(hostLabel, 1, 0); + grid->addWidget(hostEdit, 1, 1); + grid->addWidget(portLabel, 2, 0); + grid->addWidget(portEdit, 2, 1); + grid->addWidget(playernameLabel, 3, 0); + grid->addWidget(playernameEdit, 3, 1); + grid->addWidget(passwordLabel, 4, 0); + grid->addWidget(passwordEdit, 4, 1); + grid->addWidget(passwordConfirmationLabel, 5, 0); + grid->addWidget(passwordConfirmationEdit, 5, 1); + grid->addWidget(emailLabel, 6, 0); + grid->addWidget(emailEdit, 6, 1); + grid->addWidget(emailConfirmationLabel, 7, 0); + grid->addWidget(emailConfirmationEdit, 7, 1); + grid->addWidget(countryLabel, 9, 0); + grid->addWidget(countryEdit, 9, 1); + grid->addWidget(realnameLabel, 10, 0); + grid->addWidget(realnameEdit, 10, 1); auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgRegister::actOk); @@ -398,115 +352,13 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) setLayout(mainLayout); setWindowTitle(tr("Register to server")); - setMinimumWidth(360); - - connect(previousHostButton, &QRadioButton::toggled, this, &DlgRegister::previousHostSelected); - connect(newHostButton, &QRadioButton::toggled, this, &DlgRegister::newHostSelected); - connect(previousHosts, &QComboBox::currentTextChanged, this, &DlgRegister::updateDisplayInfo); - - previousHostButton->setChecked(true); - - preRebuildComboBoxList(); -} - -DlgRegister::~DlgRegister() = default; - -void DlgRegister::downloadThePublicServers() -{ - btnRefreshServers->setDisabled(true); - previousHosts->clear(); - previousHosts->addItem(placeHolderText); - hps->downloadPublicServers(); -} - -void DlgRegister::preRebuildComboBoxList() -{ - UserConnection_Information uci; - savedHostList = uci.getServerInfo(); - - if (savedHostList.size() == 1) { - downloadThePublicServers(); - } else { - rebuildComboBoxList(); - } -} - -void DlgRegister::rebuildComboBoxList(int failure) -{ - Q_UNUSED(failure); - - previousHosts->clear(); - - UserConnection_Information uci; - savedHostList = uci.getServerInfo(); - - auto &servers = SettingsCache::instance().servers(); - QString previousHostName = servers.getPrevioushostName(); - - for (const auto &pair : savedHostList) { - const auto &tmp = pair.second; - QString saveName = tmp.getSaveName(); - if (saveName.size()) { - previousHosts->addItem(saveName); - if (saveName.compare(previousHostName) == 0) { - previousHosts->setCurrentIndex(previousHosts->count() - 1); - } - } - } - - btnRefreshServers->setDisabled(false); -} - -void DlgRegister::previousHostSelected(bool state) -{ - if (state) { - previousHosts->setDisabled(false); - btnRefreshServers->setDisabled(false); - hostEdit->setDisabled(true); - portEdit->setDisabled(true); - } -} - -void DlgRegister::newHostSelected(bool state) -{ - if (state) { - previousHosts->setDisabled(true); - btnRefreshServers->setDisabled(true); - hostEdit->setDisabled(false); - hostEdit->clear(); - hostEdit->setPlaceholderText(tr("Server URL")); - portEdit->setDisabled(false); - portEdit->clear(); - portEdit->setPlaceholderText(tr("Communication Port")); - playernameEdit->setDisabled(false); - playernameEdit->clear(); - } else { - // Rebuild the list so the previously selected host's details are - // repopulated (mirrors DlgConnect::newHostSelected). - preRebuildComboBoxList(); - } -} - -void DlgRegister::updateDisplayInfo(const QString &saveName) -{ - if (saveName.isEmpty() || saveName == placeHolderText) { - return; - } - - UserConnection_Information uci; - QStringList _data = uci.getServerInfo(saveName); - - if (_data.size() < 7) { - return; - } - - hostEdit->setText(_data.at(1)); - portEdit->setText(_data.at(2)); - playernameEdit->setText(_data.at(3)); + setFixedHeight(sizeHint().height()); + setMinimumWidth(300); } void DlgRegister::actOk() { + //! \todo This stuff should be using QValidators. if (passwordEdit->text().length() < 8) { QMessageBox::critical(this, tr("Registration Warning"), tr("Your password is too short.")); return; @@ -523,29 +375,5 @@ void DlgRegister::actOk() return; } - ServersSettings &servers = SettingsCache::instance().servers(); - - if (newHostButton->isChecked()) { - // Persist the new host so it shows up in the Connect dialog later. - // The password is never stored: the account is not verified yet. - const QString host = hostEdit->text().trimmed(); - if (!host.isEmpty()) { - servers.addNewServer(host, host, portEdit->text().trimmed(), playernameEdit->text().trimmed(), QString(), - false); - servers.setPrevioushostName(host); - } - } else { - const QString saveName = previousHosts->currentText(); - if (!saveName.isEmpty() && saveName != placeHolderText) { - servers.setPrevioushostName(saveName); - } - } - accept(); } - -void DlgRegister::actRemoveSavedServer() -{ - SettingsCache::instance().servers().removeServer(hostEdit->text()); - previousHosts->removeItem(previousHosts->currentIndex()); -} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_register.h b/cockatrice/src/interface/widgets/dialogs/dlg_register.h index ce14eb427..abed9ff51 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_register.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_register.h @@ -1,24 +1,25 @@ +/** + * @file dlg_register.h + * @ingroup AccountDialogs + */ +//! \todo Document this file. + #ifndef DLG_REGISTER_H #define DLG_REGISTER_H #include #include #include -#include -class HandlePublicServers; class QLabel; class QPushButton; -class QRadioButton; -class UserConnection_Information; +class QCheckBox; class DlgRegister : public QDialog { Q_OBJECT public: explicit DlgRegister(QWidget *parent = nullptr); - ~DlgRegister() override; - [[nodiscard]] QString getHost() const { return hostEdit->text(); @@ -47,35 +48,15 @@ public: { return realnameEdit->text(); } - -public slots: - void downloadThePublicServers(); - private slots: void actOk(); - void previousHostSelected(bool state); - void newHostSelected(bool state); - void updateDisplayInfo(const QString &saveName); - void preRebuildComboBoxList(); - void rebuildComboBoxList(int failure = -1); - void actRemoveSavedServer(); private: - QRadioButton *newHostButton; - QRadioButton *previousHostButton; - QComboBox *previousHosts; - QPushButton *btnDeleteServer; - QPushButton *btnRefreshServers; - HandlePublicServers *hps; - QLabel *infoLabel, *hostLabel, *portLabel, *playernameLabel, *passwordLabel, *passwordConfirmationLabel, *emailLabel, *emailConfirmationLabel, *countryLabel, *realnameLabel; QLineEdit *hostEdit, *portEdit, *playernameEdit, *passwordEdit, *passwordConfirmationEdit, *emailEdit, *emailConfirmationEdit, *realnameEdit; QComboBox *countryEdit; - - QMap> savedHostList; - const QString placeHolderText = tr("Downloading..."); }; -#endif // DLG_REGISTER_H +#endif diff --git a/cockatrice/src/interface/widgets/general/display/banner_widget.cpp b/cockatrice/src/interface/widgets/general/display/banner_widget.cpp index 31f384cac..5de5457ea 100644 --- a/cockatrice/src/interface/widgets/general/display/banner_widget.cpp +++ b/cockatrice/src/interface/widgets/general/display/banner_widget.cpp @@ -32,7 +32,6 @@ BannerWidget::BannerWidget(QWidget *parent, const QString &text, Qt::Orientation // Set minimum height for the widget setMinimumHeight(50); - setMaximumHeight(100); connect(this, &BannerWidget::buddyVisibilityChanged, this, &BannerWidget::toggleBuddyVisibility); updateDropdownIconState(); diff --git a/cockatrice/src/interface/widgets/general/home_tab_button_color.h b/cockatrice/src/interface/widgets/general/home_tab_button_color.h deleted file mode 100644 index 1550b57e7..000000000 --- a/cockatrice/src/interface/widgets/general/home_tab_button_color.h +++ /dev/null @@ -1,49 +0,0 @@ -#ifndef COCKATRICE_HOME_TAB_BUTTON_COLOR_H -#define COCKATRICE_HOME_TAB_BUTTON_COLOR_H - -#include - -namespace HomeTabButtonColor -{ - -/** - * @brief Where to get the colors for the home tab buttons from - */ -enum Source -{ - Automatic, ///< Extract color from background, or use theme color if no background - FromBackground, ///< Always extract color from background -}; - -struct Entry -{ - Source source; - const char *trKey; ///< key for translation -}; - -inline QList all() -{ - static QList entries = {{Automatic, QT_TR_NOOP("Automatic")}, - {FromBackground, QT_TR_NOOP("Extract from background")}}; - - return entries; -} - -/** - * 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 - */ -inline Source intToSource(int value) -{ - if (value > FromBackground) { - return Automatic; // default - } - - return static_cast(value); -} - -} // namespace HomeTabButtonColor - -#endif // COCKATRICE_HOME_TAB_BUTTON_COLOR_H diff --git a/cockatrice/src/interface/widgets/general/home_widget.cpp b/cockatrice/src/interface/widgets/general/home_widget.cpp index 10fcdcb43..91f0d12b5 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.cpp +++ b/cockatrice/src/interface/widgets/general/home_widget.cpp @@ -7,7 +7,6 @@ #include "../cards/art_crop_attribution.h" #include "background_sources.h" #include "home_styled_button.h" -#include "home_tab_button_color.h" #include #include @@ -26,7 +25,7 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor) backgroundSourceCard = new CardInfoPictureArtCropWidget(this); - gradientColors = determineButtonColor(); + gradientColors = extractDominantColors(background); layout->addWidget(createButtons(), 1, 1, Qt::AlignVCenter | Qt::AlignHCenter); @@ -56,8 +55,6 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor) &HomeWidget::initializeBackgroundFromSource); connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, &HomeWidget::updateButtonsToBackgroundColor); - connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabButtonColorChanged, this, - &HomeWidget::updateButtonsToBackgroundColor); } void HomeWidget::initializeBackgroundFromSource() @@ -100,34 +97,6 @@ void HomeWidget::loadBackgroundSourceDeck() backgroundSourceDeck = deckOpt.has_value() ? deckOpt.value().deckList : DeckList(); } -static bool isDefaultBackgroundAndTheme() -{ - QString sourceId = SettingsCache::instance().appearance().getHomeTabBackgroundSource(); - return themeManager->isBuiltInTheme() && BackgroundSources::fromId(sourceId) == BackgroundSources::Theme; -} - -QPair 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::FromBackground: - return extractDominantColors(background); - } - - return defaultColor; -} - void HomeWidget::setRandomCard(ExactCard &newCard) { static constexpr int ATTEMPTS = 10; @@ -202,7 +171,7 @@ void HomeWidget::updateBackgroundProperties() void HomeWidget::updateButtonsToBackgroundColor() { - gradientColors = determineButtonColor(); + gradientColors = extractDominantColors(background); for (HomeStyledButton *button : findChildren()) { button->updateStylesheet(gradientColors); button->update(); @@ -297,6 +266,11 @@ void HomeWidget::updateConnectButton(const ClientStatus status) QPair HomeWidget::extractDominantColors(const QPixmap &pixmap) { + QString sourceId = SettingsCache::instance().appearance().getHomeTabBackgroundSource(); + if (themeManager->isBuiltInTheme() && BackgroundSources::fromId(sourceId) == BackgroundSources::Theme) { + return QPair(QColor::fromRgb(20, 140, 60), QColor::fromRgb(120, 200, 80)); + } + // Step 1: Downscale image for performance QImage image = pixmap.toImage() .scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation) diff --git a/cockatrice/src/interface/widgets/general/home_widget.h b/cockatrice/src/interface/widgets/general/home_widget.h index 9df0d7b6a..90d003aa7 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.h +++ b/cockatrice/src/interface/widgets/general/home_widget.h @@ -23,7 +23,7 @@ class HomeWidget : public QWidget public: HomeWidget(QWidget *parent, TabSupervisor *tabSupervisor); void updateRandomCard(); - static QPair extractDominantColors(const QPixmap &pixmap); + QPair extractDominantColors(const QPixmap &pixmap); public slots: void paintEvent(QPaintEvent *event) override; @@ -47,7 +47,6 @@ private: void setRandomCard(ExactCard &newCard); void loadBackgroundSourceDeck(); - QPair determineButtonColor() const; }; #endif // HOME_WIDGET_H diff --git a/cockatrice/src/interface/widgets/onboarding/banner_shader_config.h b/cockatrice/src/interface/widgets/onboarding/banner_shader_config.h deleted file mode 100644 index 32f3e89c0..000000000 --- a/cockatrice/src/interface/widgets/onboarding/banner_shader_config.h +++ /dev/null @@ -1,250 +0,0 @@ -#ifndef BANNER_SHADER_CONFIG_H -#define BANNER_SHADER_CONFIG_H - -#include -#include - -/** - * Uniform values fed to brand_banner.frag, exposed to QML as the - * "bannerConfig" context property. - * - * Two independent "banks" (A/B) each carry their own mode/speed/seed so - * BrandBanner.qml can render both simultaneously and crossfade between - * them via opacity -- see frontIsA. The shared palette (colorA/colorB/ - * accent) and clock (time/aspect) apply to both banks identically, since - * only the foreground motif changes between onboarding pages, never the - * brand palette. - * - * Deliberately plain `property` (not `required property`) on the QML side - * -- a required-property shadowing bug bit the home-screen particle - * background before, and there's no reason to reintroduce that risk here. - */ -class BannerShaderConfig : public QObject -{ - Q_OBJECT - Q_PROPERTY(qreal time READ time WRITE setTime NOTIFY timeChanged) - Q_PROPERTY(qreal aspect READ aspect WRITE setAspect NOTIFY aspectChanged) - - Q_PROPERTY(qreal modeA READ modeA WRITE setModeA NOTIFY modeAChanged) - Q_PROPERTY(qreal speedA READ speedA WRITE setSpeedA NOTIFY speedAChanged) - Q_PROPERTY(qreal seedA READ seedA WRITE setSeedA NOTIFY seedAChanged) - - Q_PROPERTY(qreal modeB READ modeB WRITE setModeB NOTIFY modeBChanged) - Q_PROPERTY(qreal speedB READ speedB WRITE setSpeedB NOTIFY speedBChanged) - Q_PROPERTY(qreal seedB READ seedB WRITE setSeedB NOTIFY seedBChanged) - - Q_PROPERTY(bool frontIsA READ frontIsA WRITE setFrontIsA NOTIFY frontIsAChanged) - - 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(bool logoVisible READ logoVisible WRITE setLogoVisible NOTIFY logoVisibleChanged) - Q_PROPERTY(qreal logoGlow READ logoGlow WRITE setLogoGlow NOTIFY logoGlowChanged) - -public: - explicit BannerShaderConfig(QObject *parent = nullptr) : QObject(parent) - { - } - - qreal time() const - { - return m_time; - } - void setTime(qreal v) - { - if (v != m_time) { - m_time = v; - emit timeChanged(); - } - } - - qreal aspect() const - { - return m_aspect; - } - void setAspect(qreal v) - { - if (v != m_aspect) { - m_aspect = v; - emit aspectChanged(); - } - } - - qreal modeA() const - { - return m_modeA; - } - void setModeA(qreal v) - { - if (v != m_modeA) { - m_modeA = v; - emit modeAChanged(); - } - } - qreal speedA() const - { - return m_speedA; - } - void setSpeedA(qreal v) - { - if (v != m_speedA) { - m_speedA = v; - emit speedAChanged(); - } - } - qreal seedA() const - { - return m_seedA; - } - void setSeedA(qreal v) - { - if (v != m_seedA) { - m_seedA = v; - emit seedAChanged(); - } - } - - qreal modeB() const - { - return m_modeB; - } - void setModeB(qreal v) - { - if (v != m_modeB) { - m_modeB = v; - emit modeBChanged(); - } - } - qreal speedB() const - { - return m_speedB; - } - void setSpeedB(qreal v) - { - if (v != m_speedB) { - m_speedB = v; - emit speedBChanged(); - } - } - qreal seedB() const - { - return m_seedB; - } - void setSeedB(qreal v) - { - if (v != m_seedB) { - m_seedB = v; - emit seedBChanged(); - } - } - - bool frontIsA() const - { - return m_frontIsA; - } - void setFrontIsA(bool v) - { - if (v != m_frontIsA) { - m_frontIsA = v; - emit frontIsAChanged(); - } - } - - QColor colorA() const - { - return m_colorA; - } - void setColorA(const QColor &c) - { - if (c != m_colorA) { - m_colorA = c; - emit colorAChanged(); - } - } - QColor colorB() const - { - return m_colorB; - } - void setColorB(const QColor &c) - { - if (c != m_colorB) { - m_colorB = c; - emit colorBChanged(); - } - } - QColor accent() const - { - return m_accent; - } - void setAccent(const QColor &c) - { - if (c != m_accent) { - m_accent = c; - emit accentChanged(); - } - } - - bool logoVisible() const - { - return m_logoVisible; - } - void setLogoVisible(bool v) - { - if (v != m_logoVisible) { - m_logoVisible = v; - emit logoVisibleChanged(); - } - } - - qreal logoGlow() const - { - return m_logoGlow; - } - void setLogoGlow(qreal v) - { - if (v != m_logoGlow) { - m_logoGlow = v; - emit logoGlowChanged(); - } - } - -signals: - void timeChanged(); - void aspectChanged(); - void modeAChanged(); - void speedAChanged(); - void seedAChanged(); - void modeBChanged(); - void speedBChanged(); - void seedBChanged(); - void frontIsAChanged(); - void colorAChanged(); - void colorBChanged(); - void accentChanged(); - void logoVisibleChanged(); - void logoGlowChanged(); - -private: - qreal m_time = 0.0; - qreal m_aspect = 16.0 / 9.0; - - qreal m_modeA = 0.0; - qreal m_speedA = 1.0; - qreal m_seedA = 0.0; - - qreal m_modeB = 0.0; - qreal m_speedB = 1.0; - qreal m_seedB = 0.0; - - bool m_frontIsA = true; - - QColor m_colorA{0x1A, 0x1A, 0x20}; - QColor m_colorB{0x0E, 0x0E, 0x12}; - QColor m_accent{0x8B, 0xDD, 0x6B}; - - bool m_logoVisible = false; - qreal m_logoGlow = 1.0; -}; - -#endif // BANNER_SHADER_CONFIG_H diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp deleted file mode 100644 index 618ac6f26..000000000 --- a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp +++ /dev/null @@ -1,218 +0,0 @@ -#include "first_run_wizard.h" - -#include "first_run_wizard_page.h" -#include "pages/account_setup_page.h" -#include "pages/card_database_setup_page.h" -#include "pages/finish_page.h" -#include "pages/preferences_setup_page.h" -#include "pages/theme_setup_page.h" -#include "pages/welcome_page.h" -#include "shader_banner_widget.h" -#include "step_indicator_widget.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -FirstRunWizard::FirstRunWizard(QWidget *parent) : QDialog(parent) -{ - setWindowFlag(Qt::WindowContextHelpButtonHint, false); - setMinimumSize(640, 490); - resize(720, 550); - - bannerHost = new BannerHost(this); - - titleLabel = new QLabel(this); - QFont titleFont = titleLabel->font(); - titleFont.setPointSizeF(titleFont.pointSizeF() * 1.4); - titleFont.setBold(true); - titleLabel->setFont(titleFont); - - subtitleLabel = new QLabel(this); - subtitleLabel->setWordWrap(true); - - stack = new QStackedWidget(this); - stepIndicator = new StepIndicatorWidget(this); - - backButton = new QPushButton(this); - skipButton = new QPushButton(this); - nextButton = new QPushButton(this); - nextButton->setDefault(true); - - connect(backButton, &QPushButton::clicked, this, &FirstRunWizard::goBack); - connect(skipButton, &QPushButton::clicked, this, &FirstRunWizard::skip); - connect(nextButton, &QPushButton::clicked, this, &FirstRunWizard::goNext); - - auto *headerLayout = new QVBoxLayout; - headerLayout->setContentsMargins(0, 0, 0, 0); - headerLayout->addWidget(bannerHost); - headerLayout->addSpacing(12); - headerLayout->addWidget(titleLabel); - headerLayout->addWidget(subtitleLabel); - - auto *navLayout = new QHBoxLayout; - navLayout->addWidget(backButton); - navLayout->addWidget(skipButton); - navLayout->addStretch(); - navLayout->addWidget(stepIndicator); - navLayout->addStretch(); - navLayout->addWidget(nextButton); - - auto *root = new QVBoxLayout(this); - root->addLayout(headerLayout); - root->addSpacing(8); - root->addWidget(stack, 1); - root->addSpacing(8); - root->addLayout(navLayout); - - auto *welcome = new WelcomePage(this); - auto *cardDb = new CardDatabaseSetupPage(this); - auto *theme = new ThemeSetupPage(this); - auto *account = new AccountSetupPage(this); - auto *prefs = new PreferencesSetupPage(this); - auto *finishPg = new FinishPage(this); - - cardDatabasePage = cardDb; - - connect(cardDb, &CardDatabaseSetupPage::updateRequested, this, &FirstRunWizard::cardDatabaseUpdateRequested); - connect(cardDb, &CardDatabaseSetupPage::manualSetupRequested, this, - &FirstRunWizard::manualCardDatabaseSetupRequested); - connect(account, &AccountSetupPage::registerRequested, this, &FirstRunWizard::registerRequested); - connect(account, &AccountSetupPage::connectRequested, this, &FirstRunWizard::connectRequested); - - connect(cardDb, &CardDatabaseSetupPage::advanceRequested, this, [this] { - if (stack->currentWidget() == cardDatabasePage) { - showPage(currentIndex + 1); - } - }); - - addPage(welcome); - addPage(cardDb); - addPage(theme); - addPage(account); - addPage(prefs); - addPage(finishPg); - - stepIndicator->setStepCount(pages.count()); - retranslateUi(); - showPage(0); -} - -void FirstRunWizard::addPage(FirstRunWizardPage *page) -{ - pages.append(page); - stack->addWidget(page); - connect(page, &FirstRunWizardPage::completeChanged, this, &FirstRunWizard::updateChrome); -} - -void FirstRunWizard::showPage(int index) -{ - if (index < 0 || index >= pages.count()) { - return; - } - currentIndex = index; - stack->setCurrentIndex(index); - pages[index]->initializePage(); - stepIndicator->setCurrentStep(index); - static const QList motifs = { - BannerHost::Motif::Welcome, BannerHost::Motif::CardDatabase, BannerHost::Motif::Theming, - BannerHost::Motif::Account, BannerHost::Motif::Preferences, BannerHost::Motif::Finish, - }; - if (index < motifs.size()) { - bannerHost->setMotif(motifs[index]); - } - titleLabel->setText(pages[index]->stepTitle()); - subtitleLabel->setText(pages[index]->stepSubtitle()); - subtitleLabel->setVisible(!pages[index]->stepSubtitle().isEmpty()); - updateChrome(); -} - -void FirstRunWizard::updateChrome() -{ - if (currentIndex < 0) { - return; - } - FirstRunWizardPage *page = pages[currentIndex]; - const bool isLast = (currentIndex == pages.count() - 1); - - backButton->setVisible(currentIndex > 0); - skipButton->setVisible(page->isSkippable()); - nextButton->setEnabled(page->isComplete()); - - QString customText = page->nextButtonText(); - if (!customText.isEmpty()) { - nextButton->setText(customText); - } else { - nextButton->setText(isLast ? tr("Finish") : tr("Next")); - } -} - -void FirstRunWizard::goNext() -{ - FirstRunWizardPage *page = pages[currentIndex]; - if (!page->validatePage() || !page->handleNextClick()) { - return; - } - if (currentIndex == pages.count() - 1) { - finish(); - return; - } - showPage(currentIndex + 1); -} - -void FirstRunWizard::goBack() -{ - showPage(currentIndex - 1); -} - -void FirstRunWizard::skip() -{ - showPage(currentIndex + 1); -} - -void FirstRunWizard::onCardDatabaseUpdateFinished(bool success) -{ - if (cardDatabasePage) { - cardDatabasePage->onUpdateFinished(success); - } -} - -void FirstRunWizard::finish() -{ - accept(); -} - -void FirstRunWizard::closeEvent(QCloseEvent *event) -{ - // Every step persists its own choice as it's made, so closing early - // isn't destructive -- treat it exactly like reaching the end. - QDialog::closeEvent(event); -} - -void FirstRunWizard::changeEvent(QEvent *event) -{ - if (event->type() == QEvent::LanguageChange) { - retranslateUi(); - } - QDialog::changeEvent(event); -} - -void FirstRunWizard::retranslateUi() -{ - setWindowTitle(tr("Welcome to Cockatrice")); - backButton->setText(tr("Back")); - skipButton->setText(tr("Skip")); - for (FirstRunWizardPage *page : std::as_const(pages)) { - page->retranslateUi(); - } - if (currentIndex >= 0) { - titleLabel->setText(pages[currentIndex]->stepTitle()); - subtitleLabel->setText(pages[currentIndex]->stepSubtitle()); - } - updateChrome(); -} diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h deleted file mode 100644 index 2c186ef95..000000000 --- a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h +++ /dev/null @@ -1,71 +0,0 @@ -#ifndef FIRST_RUN_WIZARD_H -#define FIRST_RUN_WIZARD_H - -#include -#include - -class BannerHost; -class FirstRunWizardPage; -class StepIndicatorWidget; -class CardDatabaseSetupPage; -class QLabel; -class QPushButton; -class QStackedWidget; - -/** @brief Polished first-run onboarding flow: card database setup, theme - * selection, server account setup, and a handful of key preferences. - * - * Deliberately ignorant of network/registration/download internals -- - * pages that need them emit request signals for MainWindow to fulfill. - * Every choice is written to SettingsCache as it's made (via the pages - * themselves, same as AppearanceSettingsPage does), so "Skip" or closing - * the window never discards anything already confirmed. */ -class FirstRunWizard : public QDialog -{ - Q_OBJECT - -public: - explicit FirstRunWizard(QWidget *parent = nullptr); - -signals: - void registerRequested(); - void connectRequested(); - void cardDatabaseUpdateRequested(); - void manualCardDatabaseSetupRequested(); - -public slots: - /** @brief Forwarded from MainWindow once the background card database update process exits. */ - void onCardDatabaseUpdateFinished(bool success); - -protected: - void closeEvent(QCloseEvent *event) override; - void changeEvent(QEvent *event) override; - -private slots: - void goNext(); - void goBack(); - void skip(); - void updateChrome(); - -private: - void addPage(FirstRunWizardPage *page); - void showPage(int index); - void retranslateUi(); - void finish(); - - QStackedWidget *stack; - StepIndicatorWidget *stepIndicator; - BannerHost *bannerHost; - QLabel *titleLabel; - QLabel *subtitleLabel; - QPushButton *backButton; - QPushButton *skipButton; - QPushButton *nextButton; - - CardDatabaseSetupPage *cardDatabasePage = nullptr; - - QList pages; - int currentIndex = -1; -}; - -#endif // FIRST_RUN_WIZARD_H diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard_page.cpp b/cockatrice/src/interface/widgets/onboarding/first_run_wizard_page.cpp deleted file mode 100644 index 6da8958f2..000000000 --- a/cockatrice/src/interface/widgets/onboarding/first_run_wizard_page.cpp +++ /dev/null @@ -1 +0,0 @@ -#include "first_run_wizard_page.h" diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard_page.h b/cockatrice/src/interface/widgets/onboarding/first_run_wizard_page.h deleted file mode 100644 index bdcd123bd..000000000 --- a/cockatrice/src/interface/widgets/onboarding/first_run_wizard_page.h +++ /dev/null @@ -1,75 +0,0 @@ -#ifndef FIRST_RUN_WIZARD_PAGE_H -#define FIRST_RUN_WIZARD_PAGE_H - -#include - -/** @brief Base class for a single step of FirstRunWizard. - * - * QWidget-based rather than QWizardPage-based: FirstRunWizard is a - * QDialog + QStackedWidget shell (not a QWizard) so it can own the - * banner/step-dot chrome that QWizard's native styles don't give us - * consistent control over. Naming mirrors OracleWizardPage for - * familiarity only -- the two hierarchies are unrelated. */ -class FirstRunWizardPage : public QWidget -{ - Q_OBJECT - -public: - explicit FirstRunWizardPage(QWidget *parent = nullptr) : QWidget(parent) - { - } - - /** @brief Called every time the page becomes visible, including navigating back to it. */ - virtual void initializePage() - { - } - - /** @brief Called before advancing past this page. Return false to block navigation; - the page itself is responsible for telling the user why. */ - virtual bool validatePage() - { - return true; - } - - /** @brief Whether Next/Finish should currently be enabled. Pages doing async work - can flip this mid-step; emit completeChanged() when they do. */ - virtual bool isComplete() const - { - return true; - } - - /** @brief Whether the wizard's "Skip" button should be offered on this page. */ - virtual bool isSkippable() const - { - return false; - } - - virtual QString stepTitle() const = 0; - virtual QString stepSubtitle() const - { - return {}; - } - - /** @brief Override to replace the "Next"/"Finish" button text on this page. - Return an empty string to use the default label. */ - virtual QString nextButtonText() const - { - return {}; - } - - /** @brief Called when the user presses the Next button. Return true to allow - advancing to the next page, false to stay on this page (e.g. to - trigger an async action first). */ - virtual bool handleNextClick() - { - return true; - } - - virtual void retranslateUi() = 0; - -signals: - void completeChanged(); - void advanceRequested(); -}; - -#endif // FIRST_RUN_WIZARD_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.cpp deleted file mode 100644 index 2107ea8bf..000000000 --- a/cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.cpp +++ /dev/null @@ -1,56 +0,0 @@ -#include "account_setup_page.h" - -#include -#include -#include - -AccountSetupPage::AccountSetupPage(QWidget *parent) : FirstRunWizardPage(parent) -{ - bodyLabel = new QLabel(this); - bodyLabel->setWordWrap(true); - bodyLabel->setAlignment(Qt::AlignCenter); - - registerButton = new QPushButton(this); - connectButton = new QPushButton(this); - skipHintLabel = new QLabel(this); - skipHintLabel->setWordWrap(true); - skipHintLabel->setAlignment(Qt::AlignCenter); - - connect(registerButton, &QPushButton::clicked, this, &AccountSetupPage::registerRequested); - connect(connectButton, &QPushButton::clicked, this, &AccountSetupPage::connectRequested); - - auto *layout = new QVBoxLayout(this); - layout->addStretch(); - layout->addWidget(bodyLabel); - layout->addSpacing(16); - layout->addWidget(registerButton, 0, Qt::AlignHCenter); - layout->addWidget(connectButton, 0, Qt::AlignHCenter); - layout->addSpacing(16); - layout->addWidget(skipHintLabel); - layout->addStretch(); - - retranslateUi(); -} - -bool AccountSetupPage::isSkippable() const -{ - return true; -} - -QString AccountSetupPage::stepTitle() const -{ - return tr("Join a Server"); -} - -QString AccountSetupPage::stepSubtitle() const -{ - return tr("Optional — you can always do this later from the menu."); -} - -void AccountSetupPage::retranslateUi() -{ - bodyLabel->setText(tr("Playing online needs a server account.")); - registerButton->setText(tr("Register a new account…")); - connectButton->setText(tr("I already have one — Connect…")); - skipHintLabel->setText(tr("Just want to play locally? Skip this and connect whenever you're ready.")); -} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.h b/cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.h deleted file mode 100644 index 0d9b76699..000000000 --- a/cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.h +++ /dev/null @@ -1,38 +0,0 @@ -#ifndef ACCOUNT_SETUP_PAGE_H -#define ACCOUNT_SETUP_PAGE_H - -#include "../first_run_wizard_page.h" - -class QLabel; -class QPushButton; - -/** @brief First-run account step. Does NOT embed DlgRegister's fields: they exist - * to be handed to ConnectionController's network registration flow, which - * this wizard has no visibility into. Reimplementing the fields here - * without that wiring would look functional and silently do nothing -- - * worse than reuse. So: a friendly landing spot that opens the *existing* - * DlgRegister / connect flow via signals FirstRunWizard forwards. */ -class AccountSetupPage : public FirstRunWizardPage -{ - Q_OBJECT - -public: - explicit AccountSetupPage(QWidget *parent = nullptr); - - bool isSkippable() const override; - QString stepTitle() const override; - QString stepSubtitle() const override; - void retranslateUi() override; - -signals: - void registerRequested(); - void connectRequested(); - -private: - QLabel *bodyLabel; - QPushButton *registerButton; - QPushButton *connectButton; - QLabel *skipHintLabel; -}; - -#endif // ACCOUNT_SETUP_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.cpp deleted file mode 100644 index 12116de7a..000000000 --- a/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.cpp +++ /dev/null @@ -1,314 +0,0 @@ -#include "card_database_setup_page.h" - -#include "../../client/settings/cache_settings.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -CardDatabaseSetupPage::CardDatabaseSetupPage(QWidget *parent) : FirstRunWizardPage(parent) -{ - statusLabel = new QLabel(this); - statusLabel->setWordWrap(true); - statusLabel->setAlignment(Qt::AlignCenter); - - progressBar = new QProgressBar(this); - progressBar->setRange(0, 0); - progressBar->setTextVisible(false); - progressBar->setFixedWidth(280); - - retryButton = new QPushButton(this); - manualButton = new QPushButton(this); - - connect(retryButton, &QPushButton::clicked, this, [this] { - setState(State::Running); - emit updateRequested(); - }); - connect(manualButton, &QPushButton::clicked, this, &CardDatabaseSetupPage::manualSetupRequested); - - // ── Advanced: custom download source ─────────────────────────────── - advancedToggleButton = new QPushButton(this); - advancedToggleButton->setCheckable(true); - advancedToggleButton->setChecked(false); - advancedToggleButton->setFlat(true); - advancedToggleButton->setStyleSheet("QPushButton { text-align: left; padding: 5px 12px; font-weight: bold; }" - "QPushButton:checked { }"); - - advancedPanel = new QWidget(this); - advancedPanel->setVisible(false); - - urlLineEdit = new QLineEdit(advancedPanel); - urlHintLabel = new QLabel(advancedPanel); - urlHintLabel->setWordWrap(true); - - restoreDefaultUrlButton = new QPushButton(advancedPanel); - applyAndRetryButton = new QPushButton(advancedPanel); - - connect(advancedToggleButton, &QPushButton::toggled, this, &CardDatabaseSetupPage::onToggleAdvanced); - connect(restoreDefaultUrlButton, &QPushButton::clicked, this, &CardDatabaseSetupPage::onRestoreDefaultUrl); - connect(applyAndRetryButton, &QPushButton::clicked, this, &CardDatabaseSetupPage::onApplyCustomUrl); - - auto *advancedButtonRow = new QHBoxLayout; - advancedButtonRow->addWidget(restoreDefaultUrlButton); - advancedButtonRow->addStretch(); - advancedButtonRow->addWidget(applyAndRetryButton); - - auto *advancedLayout = new QVBoxLayout(advancedPanel); - advancedLayout->setContentsMargins(12, 4, 12, 4); - advancedLayout->addWidget(urlLineEdit); - advancedLayout->addWidget(urlHintLabel); - advancedLayout->addLayout(advancedButtonRow); - - // ── Startup card update check ─────────────────────────────────────── - auto &upd = SettingsCache::instance().updates(); - - const auto updateBehavior = [this] { - auto &u = SettingsCache::instance().updates(); - int idx = startupBehaviorCombo->currentIndex(); - u.setStartupCardUpdateCheckPromptForUpdate(idx == 1); - u.setStartupCardUpdateCheckAlwaysUpdate(idx == 2); - }; - - startupBehaviorLabel = new QLabel(this); - startupBehaviorCombo = new QComboBox(this); - startupBehaviorCombo->addItem(QString()); // placeholder, filled in retranslateUi - startupBehaviorCombo->addItem(QString()); - startupBehaviorCombo->addItem(QString()); - if (upd.getStartupCardUpdateCheckPromptForUpdate()) { - startupBehaviorCombo->setCurrentIndex(1); - } else if (upd.getStartupCardUpdateCheckAlwaysUpdate()) { - startupBehaviorCombo->setCurrentIndex(2); - } else { - startupBehaviorCombo->setCurrentIndex(0); - } - connect(startupBehaviorCombo, QOverload::of(&QComboBox::currentIndexChanged), this, updateBehavior); - - checkIntervalLabel = new QLabel(this); - checkIntervalSpinBox = new QSpinBox(this); - checkIntervalSpinBox->setMinimum(1); - checkIntervalSpinBox->setMaximum(30); - checkIntervalSpinBox->setValue(upd.getCardUpdateCheckInterval()); - connect(checkIntervalSpinBox, QOverload::of(&QSpinBox::valueChanged), &upd, - &UpdatesSettings::setCardUpdateCheckInterval); - - auto *checkGrid = new QGridLayout; - checkGrid->addWidget(startupBehaviorLabel, 0, 0); - checkGrid->addWidget(startupBehaviorCombo, 0, 1); - checkGrid->addWidget(checkIntervalLabel, 1, 0); - checkGrid->addWidget(checkIntervalSpinBox, 1, 1); - - auto *layout = new QVBoxLayout(this); - layout->addStretch(); - layout->addWidget(statusLabel); - layout->addSpacing(12); - layout->addWidget(progressBar, 0, Qt::AlignHCenter); - layout->addSpacing(12); - layout->addWidget(retryButton, 0, Qt::AlignHCenter); - layout->addWidget(manualButton, 0, Qt::AlignHCenter); - layout->addSpacing(16); - layout->addWidget(advancedToggleButton); - layout->addWidget(advancedPanel); - layout->addSpacing(8); - layout->addLayout(checkGrid); - layout->addStretch(); - - retranslateUi(); -} - -bool CardDatabaseSetupPage::alreadyHaveDatabase() const -{ - return CardDatabaseManager::getInstance()->getCardList().count() > 0; -} - -QString CardDatabaseSetupPage::oracleSettingsFilePath() const -{ - return SettingsCache::instance().getSettingsPath() + "oracle.ini"; -} - -QString CardDatabaseSetupPage::readCustomUrl() const -{ - QSettings oracleSettings(oracleSettingsFilePath(), QSettings::IniFormat); - return oracleSettings.value("allsetsurl").toString(); -} - -void CardDatabaseSetupPage::writeCustomUrl(const QString &url) -{ - QSettings oracleSettings(oracleSettingsFilePath(), QSettings::IniFormat); - if (url.isEmpty()) { - oracleSettings.remove("allsetsurl"); - } else { - oracleSettings.setValue("allsetsurl", url); - } -} - -void CardDatabaseSetupPage::initializePage() -{ - urlLineEdit->setText(readCustomUrl()); - - if (state != State::NotStarted) { - return; - } - - if (alreadyHaveDatabase()) { - setState(State::Succeeded); - return; - } - - // Don't auto-download — wait for the user to press "Download". - setState(State::NotStarted); - statusLabel->setText(tr("Press Download to fetch the card database, or Skip to do it later.")); -} - -void CardDatabaseSetupPage::onUpdateFinished(bool success) -{ - setState(success ? State::Succeeded : State::Failed); - if (success) { - emit advanceRequested(); - } -} - -QString CardDatabaseSetupPage::nextButtonText() const -{ - return state == State::NotStarted ? tr("Download") : QString(); -} - -bool CardDatabaseSetupPage::handleNextClick() -{ - if (state == State::NotStarted) { - setState(State::Running); - emit updateRequested(); - return false; - } - return true; -} - -void CardDatabaseSetupPage::onToggleAdvanced(bool open) -{ - advancedToggleButton->setText(open ? tr("▼ Advanced: custom download source") - : tr("▶ Advanced: custom download source")); - advancedPanel->setVisible(open); - - QWidget *wizardWindow = window(); - if (!wizardWindow) { - return; - } - - if (open) { - windowSizeBeforeExpansion = wizardWindow->size(); - QTimer::singleShot(0, this, [wizardWindow] { - wizardWindow->resize(wizardWindow->size().expandedTo(wizardWindow->sizeHint())); - }); - } else { - QTimer::singleShot(0, this, [this, wizardWindow] { - wizardWindow->resize(wizardWindow->size().boundedTo(windowSizeBeforeExpansion)); - }); - } -} - -void CardDatabaseSetupPage::onApplyCustomUrl() -{ - const QString text = urlLineEdit->text().trimmed(); - - if (!text.isEmpty()) { - const QUrl url = QUrl::fromUserInput(text); - if (!url.isValid()) { - QMessageBox::warning(this, tr("Invalid URL"), - tr("That doesn't look like a valid URL. Double-check it and try again, " - "or clear the field to use the default source.")); - return; - } - } - - writeCustomUrl(text); - setState(State::Running); - emit updateRequested(); -} - -void CardDatabaseSetupPage::onRestoreDefaultUrl() -{ - urlLineEdit->clear(); - writeCustomUrl(QString()); -} - -void CardDatabaseSetupPage::setState(State newState) -{ - state = newState; - - progressBar->setVisible(state == State::Running); - retryButton->setVisible(state == State::Failed); - manualButton->setVisible(state == State::Failed); - applyAndRetryButton->setEnabled(state != State::Running); - - switch (state) { - case State::NotStarted: - statusLabel->setText(tr("Press Download to fetch the card database, or Skip to do it later.")); - break; - case State::Running: - statusLabel->setText(tr("Downloading the latest card database…")); - break; - case State::Succeeded: - statusLabel->setText(tr("Card database ready ✓")); - break; - case State::Failed: - statusLabel->setText( - tr("Couldn't download the card database automatically. Check your connection and retry, " - "set it up manually, or skip this for now — you can do it later from the Card Database menu.")); - break; - } - - emit completeChanged(); -} - -bool CardDatabaseSetupPage::isComplete() const -{ - return state != State::Running; -} - -bool CardDatabaseSetupPage::isSkippable() const -{ - return state != State::Succeeded; -} - -QString CardDatabaseSetupPage::stepTitle() const -{ - return tr("Card Database"); -} - -QString CardDatabaseSetupPage::stepSubtitle() const -{ - return tr("Cockatrice needs card data to know what you're playing with."); -} - -void CardDatabaseSetupPage::retranslateUi() -{ - retryButton->setText(tr("Retry")); - manualButton->setText(tr("Set up manually…")); - - onToggleAdvanced(advancedToggleButton->isChecked()); - urlLineEdit->setPlaceholderText(tr("Leave blank to use the default source")); - urlHintLabel->setText(tr("Only change this if you know you need a mirror or a custom card data source.")); - restoreDefaultUrlButton->setText(tr("Restore default")); - applyAndRetryButton->setText(tr("Apply && retry")); - - startupBehaviorLabel->setText(tr("Check for card database updates on startup")); - startupBehaviorCombo->setItemText(0, tr("Don't check")); - startupBehaviorCombo->setItemText(1, tr("Prompt for update")); - startupBehaviorCombo->setItemText(2, tr("Always update in the background")); - - checkIntervalLabel->setText(tr("Check for card database updates every")); - checkIntervalSpinBox->setSuffix(tr(" days")); - - setState(state); -} diff --git a/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.h b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.h deleted file mode 100644 index 0461d11d5..000000000 --- a/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.h +++ /dev/null @@ -1,79 +0,0 @@ -#ifndef CARD_DATABASE_SETUP_PAGE_H -#define CARD_DATABASE_SETUP_PAGE_H - -#include "../first_run_wizard_page.h" - -#include - -class QComboBox; -class QLabel; -class QLineEdit; -class QProgressBar; -class QPushButton; -class QSpinBox; -class QWidget; - -class CardDatabaseSetupPage : public FirstRunWizardPage -{ - Q_OBJECT - -public: - explicit CardDatabaseSetupPage(QWidget *parent = nullptr); - - void initializePage() override; - bool isComplete() const override; - bool isSkippable() const override; - QString stepTitle() const override; - QString stepSubtitle() const override; - QString nextButtonText() const override; - bool handleNextClick() override; - void retranslateUi() override; - - void onUpdateFinished(bool success); - -signals: - void updateRequested(); - void manualSetupRequested(); - -private: - enum class State - { - NotStarted, - Running, - Succeeded, - Failed, - }; - - void setState(State newState); - bool alreadyHaveDatabase() const; - - QString oracleSettingsFilePath() const; - QString readCustomUrl() const; - void writeCustomUrl(const QString &url); - - void onToggleAdvanced(bool open); - void onApplyCustomUrl(); - void onRestoreDefaultUrl(); - - QLabel *statusLabel; - QProgressBar *progressBar; - QPushButton *retryButton; - QPushButton *manualButton; - - QPushButton *advancedToggleButton; - QWidget *advancedPanel; - QLineEdit *urlLineEdit; - QLabel *urlHintLabel; - QPushButton *restoreDefaultUrlButton; - QPushButton *applyAndRetryButton; - - QLabel *startupBehaviorLabel; - QComboBox *startupBehaviorCombo; - QLabel *checkIntervalLabel; - QSpinBox *checkIntervalSpinBox; - - State state = State::NotStarted; - QSize windowSizeBeforeExpansion; -}; - -#endif // CARD_DATABASE_SETUP_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/pages/finish_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/finish_page.cpp deleted file mode 100644 index 4205fa532..000000000 --- a/cockatrice/src/interface/widgets/onboarding/pages/finish_page.cpp +++ /dev/null @@ -1,30 +0,0 @@ -#include "finish_page.h" - -#include -#include - -FinishPage::FinishPage(QWidget *parent) : FirstRunWizardPage(parent) -{ - bodyLabel = new QLabel(this); - bodyLabel->setWordWrap(true); - bodyLabel->setAlignment(Qt::AlignCenter); - - auto *layout = new QVBoxLayout(this); - layout->addStretch(); - layout->addWidget(bodyLabel); - layout->addStretch(); - - retranslateUi(); -} - -QString FinishPage::stepTitle() const -{ - return tr("You're All Set"); -} - -void FinishPage::retranslateUi() -{ - bodyLabel->setText( - tr("That's everything for now. Jump into Settings any time to change your mind about any of this.\n\n" - "Have fun!")); -} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/onboarding/pages/finish_page.h b/cockatrice/src/interface/widgets/onboarding/pages/finish_page.h deleted file mode 100644 index 40ebc6ed0..000000000 --- a/cockatrice/src/interface/widgets/onboarding/pages/finish_page.h +++ /dev/null @@ -1,22 +0,0 @@ -#ifndef FINISH_PAGE_H -#define FINISH_PAGE_H - -#include "../first_run_wizard_page.h" - -class QLabel; - -class FinishPage : public FirstRunWizardPage -{ - Q_OBJECT - -public: - explicit FinishPage(QWidget *parent = nullptr); - - QString stepTitle() const override; - void retranslateUi() override; - -private: - QLabel *bodyLabel; -}; - -#endif // FINISH_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.cpp deleted file mode 100644 index fceb34deb..000000000 --- a/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.cpp +++ /dev/null @@ -1,173 +0,0 @@ -#include "preferences_setup_page.h" - -#include "../../client/settings/cache_settings.h" -#include "../../client/sound_engine.h" -#include "libcockatrice/settings/interface_settings.h" -#include "libcockatrice/settings/sound_settings.h" -#include "libcockatrice/settings/tabs_settings.h" - -#include -#include -#include -#include -#include -#include -#include - -namespace -{ -// The server destinations are omitted: during first run their tabs are not -// open yet, and the wizard offers no way to fill in the server/room details. -QList wizardStartupTabOrder() -{ - return {StartupTabHome, StartupTabVisualDeckStorage, StartupTabDeckStorage, - StartupTabReplays, StartupTabDeckEditor, StartupTabVisualDeckEditor}; -} -} // namespace - -PreferencesSetupPage::PreferencesSetupPage(QWidget *parent) : FirstRunWizardPage(parent) -{ - auto *content = new QWidget; - auto *contentLayout = new QVBoxLayout(content); - - gameplayGroup = new QGroupBox(content); - auto *gameplayLayout = new QVBoxLayout(gameplayGroup); - contentLayout->addWidget(gameplayGroup); - doubleClickToPlayCheckBox = new QCheckBox(gameplayGroup); - horizontalHandCheckBox = new QCheckBox(gameplayGroup); - playToStackCheckBox = new QCheckBox(gameplayGroup); - gameplayLayout->addWidget(doubleClickToPlayCheckBox); - gameplayLayout->addWidget(horizontalHandCheckBox); - gameplayLayout->addWidget(playToStackCheckBox); - - notificationsGroup = new QGroupBox(content); - auto *notificationsLayout = new QVBoxLayout(notificationsGroup); - contentLayout->addWidget(notificationsGroup); - notificationsEnabledCheckBox = new QCheckBox(notificationsGroup); - soundEnabledCheckBox = new QCheckBox(notificationsGroup); - notificationsLayout->addWidget(notificationsEnabledCheckBox); - notificationsLayout->addWidget(soundEnabledCheckBox); - - startupGroup = new QGroupBox(content); - auto *startupForm = new QFormLayout(startupGroup); - contentLayout->addWidget(startupGroup); - startupTabLabel = new QLabel(startupGroup); - startupTabSelector = new QComboBox(startupGroup); - startupTabSelector->setSizeAdjustPolicy(QComboBox::AdjustToContents); - for (StartupTab tab : wizardStartupTabOrder()) { - startupTabSelector->addItem(QString(), tab); // texts set in retranslateUi - } - startupForm->addRow(startupTabLabel, startupTabSelector); - - contentLayout->addStretch(); - - auto *scrollArea = new QScrollArea(this); - scrollArea->setWidget(content); - scrollArea->setWidgetResizable(true); - scrollArea->setFrameShape(QFrame::NoFrame); - - auto *layout = new QVBoxLayout(this); - layout->addWidget(scrollArea); - - SettingsCache &settings = SettingsCache::instance(); - - connect(doubleClickToPlayCheckBox, &QCheckBox::toggled, &settings.userInterface(), - &InterfaceSettings::setDoubleClickToPlay); - connect(horizontalHandCheckBox, &QCheckBox::toggled, &settings.userInterface(), - &InterfaceSettings::setHorizontalHand); - connect(playToStackCheckBox, &QCheckBox::toggled, &settings.userInterface(), &InterfaceSettings::setPlayToStack); - - connect(notificationsEnabledCheckBox, &QCheckBox::toggled, &settings.userInterface(), - &InterfaceSettings::setNotificationsEnabled); - connect(soundEnabledCheckBox, &QCheckBox::toggled, &settings.sound(), &SoundSettings::setSoundEnabled); - connect(soundEnabledCheckBox, &QCheckBox::toggled, soundEngine, &SoundEngine::testSound); - - connect(startupTabSelector, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int index) { - if (index < 0) { - return; - } - SettingsCache::instance().tabs().setStartupTabIndex(startupTabSelector->itemData(index).toInt()); - }); - - retranslateUi(); -} - -void PreferencesSetupPage::initializePage() -{ - SettingsCache &settings = SettingsCache::instance(); - - doubleClickToPlayCheckBox->setChecked(settings.userInterface().getDoubleClickToPlay()); - horizontalHandCheckBox->setChecked(settings.userInterface().getHorizontalHand()); - playToStackCheckBox->setChecked(settings.userInterface().getPlayToStack()); - - notificationsEnabledCheckBox->setChecked(settings.userInterface().getNotificationsEnabled()); - soundEnabledCheckBox->setChecked(settings.sound().getSoundEnabled()); - - startupTabSelector->setCurrentIndex(startupTabSelector->findData(settings.tabs().getStartupTabIndex())); -} - -bool PreferencesSetupPage::isSkippable() const -{ - return true; -} - -QString PreferencesSetupPage::stepTitle() const -{ - return tr("A Few Preferences"); -} - -QString PreferencesSetupPage::stepSubtitle() const -{ - return tr("Defaults are fine — tweak these now or from Settings anytime."); -} - -void PreferencesSetupPage::retranslateUi() -{ - gameplayGroup->setTitle(tr("Gameplay")); - doubleClickToPlayCheckBox->setText(tr("Double-click cards to play them")); - doubleClickToPlayCheckBox->setToolTip(tr("When disabled, a single click plays the selected card onto the table.")); - horizontalHandCheckBox->setText(tr("Display hand horizontally")); - horizontalHandCheckBox->setToolTip( - tr("Shows your hand as a row along the bottom of the table instead of a column beside it.")); - playToStackCheckBox->setText(tr("Play all nonlands onto the stack by default")); - playToStackCheckBox->setToolTip( - tr("Cards you play appear on the stack so other players can respond to them, as in a tabletop game.")); - - notificationsGroup->setTitle(tr("Notifications && Sound")); - notificationsEnabledCheckBox->setText(tr("Show desktop notifications")); - soundEnabledCheckBox->setText(tr("Play sound effects")); - - startupGroup->setTitle(tr("Startup")); - startupTabLabel->setText(tr("Startup tab:")); - const QList tabs = wizardStartupTabOrder(); - for (int i = 0; i < tabs.size(); ++i) { - QString name; - switch (tabs[i]) { - case StartupTabHome: - name = tr("Home"); - break; - case StartupTabVisualDeckStorage: - name = tr("Visual Deck Storage"); - break; - case StartupTabDeckStorage: - name = tr("Deck Storage"); - break; - case StartupTabReplays: - name = tr("Game Replays"); - break; - case StartupTabDeckEditor: - name = tr("Deck Editor"); - break; - case StartupTabVisualDeckEditor: - name = tr("Visual Deck Editor"); - break; - case StartupTabServer: - name = tr("Server"); - break; - case StartupTabServerRoom: - name = tr("Server Room"); - break; - } - startupTabSelector->setItemText(i, name); - } -} diff --git a/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.h b/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.h deleted file mode 100644 index eed1b3aed..000000000 --- a/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.h +++ /dev/null @@ -1,41 +0,0 @@ -#ifndef PREFERENCES_SETUP_PAGE_H -#define PREFERENCES_SETUP_PAGE_H - -#include "../first_run_wizard_page.h" - -class QCheckBox; -class QComboBox; -class QGroupBox; -class QLabel; - -/** @brief A curated subset of settings for the user to adjust. - **/ -class PreferencesSetupPage : public FirstRunWizardPage -{ - Q_OBJECT - -public: - explicit PreferencesSetupPage(QWidget *parent = nullptr); - - void initializePage() override; - bool isSkippable() const override; - QString stepTitle() const override; - QString stepSubtitle() const override; - void retranslateUi() override; - -private: - QGroupBox *gameplayGroup; - QCheckBox *doubleClickToPlayCheckBox; - QCheckBox *horizontalHandCheckBox; - QCheckBox *playToStackCheckBox; - - QGroupBox *notificationsGroup; - QCheckBox *notificationsEnabledCheckBox; - QCheckBox *soundEnabledCheckBox; - - QGroupBox *startupGroup; - QLabel *startupTabLabel; - QComboBox *startupTabSelector; -}; - -#endif // PREFERENCES_SETUP_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.cpp deleted file mode 100644 index 3293b19ac..000000000 --- a/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.cpp +++ /dev/null @@ -1,231 +0,0 @@ -#include "theme_setup_page.h" - -#include "../../client/settings/cache_settings.h" -#include "../../interface/palette_editor/palette_generator.h" -#include "../../interface/palette_editor/quick_setup_panel.h" -#include "../../interface/theme_manager.h" -#include "../../interface/widgets/general/background_sources.h" -#include "libcockatrice/settings/appearance_settings.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -ThemeSetupPage::ThemeSetupPage(QWidget *parent) : FirstRunWizardPage(parent) -{ - themeCombo = new QComboBox(this); - schemeCombo = new QComboBox(this); - schemeCombo->addItem(tr("Light"), QStringLiteral("Light")); - schemeCombo->addItem(tr("Dark"), QStringLiteral("Dark")); -#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) - schemeCombo->addItem(tr("Match system"), QStringLiteral("System")); -#endif - - quickSetupPanel = new QuickSetupPanel(this); - - connect(themeCombo, QOverload::of(&QComboBox::currentIndexChanged), this, &ThemeSetupPage::onThemeChanged); - connect(schemeCombo, QOverload::of(&QComboBox::currentIndexChanged), this, &ThemeSetupPage::onSchemeChanged); - connect(quickSetupPanel, &QuickSetupPanel::valueChanged, this, &ThemeSetupPage::onGenerateFromAccent); - - homeTabBackgroundCombo = new QComboBox(this); - for (const auto &entry : BackgroundSources::all()) { - homeTabBackgroundCombo->addItem(QObject::tr(entry.trKey), QVariant::fromValue(entry.type)); - } - connect(homeTabBackgroundCombo, QOverload::of(&QComboBox::currentIndexChanged), this, - &ThemeSetupPage::onHomeTabBackgroundChanged); - - // Keep the scheme combo honest when the *theme* changes underneath it - // (switching theme reloads that theme's own stored colorScheme), and - // opportunistically seed a palette for themes that ship none at all. - // 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 ThemeConfig cfg = ThemeConfig::fromThemeDir(newDir); - const QString current = cfg.colorScheme; - - schemeCombo->blockSignals(true); - const int idx = schemeCombo->findData(current); - schemeCombo->setCurrentIndex(idx >= 0 ? idx : 0); - schemeCombo->blockSignals(false); - - maybeAutoGeneratePalette(); - }); - - auto *form = new QFormLayout; - form->addRow(tr("Theme:"), themeCombo); - form->addRow(tr("Appearance:"), schemeCombo); - form->addRow(tr("Home screen background:"), homeTabBackgroundCombo); - - accentGroup = new QGroupBox(this); - auto *accentLayout = new QVBoxLayout(accentGroup); - accentLayout->addWidget(quickSetupPanel); - - auto *layout = new QVBoxLayout(this); - layout->addLayout(form); - layout->addWidget(accentGroup); - layout->addStretch(); - - retranslateUi(); -} - -void ThemeSetupPage::initializePage() -{ - themeCombo->blockSignals(true); - themeCombo->clear(); - const QString currentTheme = SettingsCache::instance().getThemeName(); - for (const QString &name : themeManager->getAvailableThemes().keys()) { - themeCombo->addItem(name); - } - const int idx = themeCombo->findText(currentTheme); - themeCombo->setCurrentIndex(idx >= 0 ? idx : 0); - themeCombo->blockSignals(false); - - const QString dirPath = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName()); - const ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath); - schemeCombo->blockSignals(true); - const int schemeIdx = schemeCombo->findData(cfg.colorScheme); - schemeCombo->setCurrentIndex(schemeIdx >= 0 ? schemeIdx : 0); - schemeCombo->blockSignals(false); - - homeTabBackgroundCombo->blockSignals(true); - QString homeTabSource = SettingsCache::instance().appearance().getHomeTabBackgroundSource(); - int homeTabIdx = homeTabBackgroundCombo->findData(BackgroundSources::fromId(homeTabSource)); - homeTabBackgroundCombo->setCurrentIndex(homeTabIdx >= 0 ? homeTabIdx : 0); - homeTabBackgroundCombo->blockSignals(false); - - // Opening the page must not touch the running application's palette: - // previews and auto-generation only happen in response to the user - // actually changing a control, never on mere page visibility. - paletteDirty = false; -} - -QString ThemeSetupPage::currentScheme() const -{ - return schemeCombo->currentData().toString(); -} - -QString ThemeSetupPage::resolvedScheme() const -{ - const QString scheme = currentScheme(); - if (scheme.isEmpty() || scheme == QStringLiteral("System")) { - return themeManager->isDarkMode(themeManager->getCurrentThemePath()) ? "Dark" : "Light"; - } - return scheme; -} - -void ThemeSetupPage::onThemeChanged(int index) -{ - if (index < 0) { - return; - } - paletteDirty = false; - SettingsCache::instance().setThemeName(themeCombo->itemText(index)); - // Scheme-combo sync and auto-generation both happen via the - // ThemeManager::themeChanged listener above, triggered by setThemeName. -} - -void ThemeSetupPage::onSchemeChanged() -{ - themeManager->setColorScheme(currentScheme()); -} - -void ThemeSetupPage::onHomeTabBackgroundChanged(int index) -{ - if (index < 0) { - return; - } - auto type = homeTabBackgroundCombo->currentData().value(); - SettingsCache::instance().appearance().setHomeTabBackgroundSource(BackgroundSources::toId(type)); -} - -void ThemeSetupPage::onGenerateFromAccent(const QColor &accent, int intensity) -{ - PaletteConfig cfg = PaletteGenerator::fromAccent(accent, intensity, resolvedScheme()); - themeManager->previewPalette(cfg, resolvedScheme()); - paletteDirty = true; -} - -void ThemeSetupPage::maybeAutoGeneratePalette() -{ - const QString dirPath = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName()); - const QString scheme = resolvedScheme(); - - if (PaletteConfig::fromScheme(dirPath, scheme).hasPalette() || - PaletteConfig::fromDefault(dirPath, scheme).hasPalette()) { - return; // theme already has something real to show -- leave it alone - } - - // The theme+scheme combination has nothing saved and nothing shipped, and - // the user just switched to it. Rather than leaving a flat, unstyled look, - // seed one from whatever accent QuickSetupPanel currently holds and mark - // it dirty so it's written to disk if the user moves on. Only ever reached - // through user interaction (theme/scheme change, accent drag) -- never on - // page open. - PaletteConfig generated = - PaletteGenerator::fromAccent(quickSetupPanel->accentColor(), quickSetupPanel->intensity(), scheme); - themeManager->previewPalette(generated, scheme); - paletteDirty = true; -} - -bool ThemeSetupPage::validatePage() -{ - if (paletteDirty) { - const QString scheme = resolvedScheme(); - PaletteConfig cfg = - PaletteGenerator::fromAccent(quickSetupPanel->accentColor(), quickSetupPanel->intensity(), scheme); - if (!ThemeManager::commitPalette(writableThemeDir(), scheme, cfg)) { - QMessageBox::warning(this, tr("Save failed"), - tr("Could not write the theme palette to:\n%1").arg(writableThemeDir())); - return false; - } - themeManager->reloadCurrentTheme(); - } - return true; -} - -QString ThemeSetupPage::writableThemeDir() const -{ - // Built-in themes resolve to the read-only system themes directory; - // palette edits must go to the user themes directory instead, exactly - // as PaletteEditorDialog does. - const QString dirPath = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName()); - if (!dirPath.isEmpty()) { - const QString probe = QDir(dirPath).absoluteFilePath(".cockatrice_write_test"); - QFile f(probe); - if (f.open(QIODevice::WriteOnly)) { - f.close(); - f.remove(); - return dirPath; - } - } - return QDir(SettingsCache::instance().paths().getThemesPath()) - .absoluteFilePath(SettingsCache::instance().getThemeName()); -} - -bool ThemeSetupPage::isSkippable() const -{ - return true; -} - -QString ThemeSetupPage::stepTitle() const -{ - return tr("Pick a Look"); -} - -QString ThemeSetupPage::stepSubtitle() const -{ - return tr("You can fine-tune every colour later from Settings → Appearance."); -} - -void ThemeSetupPage::retranslateUi() -{ - accentGroup->setTitle(tr("Accent colour (optional)")); -} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.h b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.h deleted file mode 100644 index d1f84c1b9..000000000 --- a/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.h +++ /dev/null @@ -1,58 +0,0 @@ -#ifndef THEME_SETUP_PAGE_H -#define THEME_SETUP_PAGE_H - -#include "../first_run_wizard_page.h" - -class QComboBox; -class QGroupBox; -class QuickSetupPanel; - -/** @brief First-run theme step. Reuses the same building blocks as Appearance - * settings and the Palette Editor (ThemeManager, PaletteConfig, - * PaletteGenerator, and the QuickSetupPanel widget itself) rather than - * reimplementing palette generation or preview here. - * - * Behavior specific to this page (deliberately not pushed down into - * ThemeManager, to avoid changing app-wide behaviour for existing installs): - * - Opening the page never changes the running palette; previews and - * auto-generation only happen when the user actually changes a control. - * - If a theme+scheme the user selects has no saved palette and no shipped - * default, one is generated from the QuickSetupPanel's current accent so - * the preview doesn't fall back to a flat, unstyled look. */ -class ThemeSetupPage : public FirstRunWizardPage -{ - Q_OBJECT - -public: - explicit ThemeSetupPage(QWidget *parent = nullptr); - - void initializePage() override; - bool validatePage() override; - bool isSkippable() const override; - QString stepTitle() const override; - QString stepSubtitle() const override; - void retranslateUi() override; - -private slots: - void onThemeChanged(int index); - void onSchemeChanged(); - void onGenerateFromAccent(const QColor &accent, int intensity); - void onHomeTabBackgroundChanged(int index); - -private: - QString currentScheme() const; - QString resolvedScheme() const; // "System" -> actual Light/Dark - void maybeAutoGeneratePalette(); - QString writableThemeDir() const; - - QComboBox *themeCombo; - QComboBox *schemeCombo; - QGroupBox *accentGroup; - QuickSetupPanel *quickSetupPanel; - - QComboBox *homeTabBackgroundCombo; - - bool paletteDirty = false; -}; - -#endif // THEME_SETUP_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.cpp deleted file mode 100644 index 16e0719d2..000000000 --- a/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.cpp +++ /dev/null @@ -1,79 +0,0 @@ -#include "welcome_page.h" - -#include "../../../../main.h" -#include "../../client/settings/cache_settings.h" -#include "../../settings_page/general_settings_page.h" -#include "libcockatrice/settings/personal_settings.h" - -#include -#include -#include -#include -#include -#include -#include - -WelcomePage::WelcomePage(QWidget *parent) : FirstRunWizardPage(parent) -{ - bodyLabel = new QLabel(this); - bodyLabel->setWordWrap(true); - bodyLabel->setAlignment(Qt::AlignCenter); - - languageLabel = new QLabel(this); - langCombo = new QComboBox(this); - for (const QString &code : GeneralSettingsPage::findQmFiles()) { - langCombo->addItem(GeneralSettingsPage::languageName(code), code); - } - - QString current = SettingsCache::instance().personal().getLang(); - if (current.isEmpty()) { - current = QLocale::system().name(); - } - int index = langCombo->findData(current); - if (index < 0) { - index = langCombo->findData(current.section('_', 0, 0)); - } - if (index >= 0) { - langCombo->setCurrentIndex(index); - } - - connect(langCombo, QOverload::of(&QComboBox::currentIndexChanged), this, &WelcomePage::languageChanged); - - auto *languageRow = new QHBoxLayout; - languageRow->addStretch(); - languageRow->addWidget(languageLabel); - languageRow->addWidget(langCombo); - languageRow->addStretch(); - - auto *layout = new QVBoxLayout(this); - layout->addStretch(); - layout->addWidget(bodyLabel); - layout->addStretch(); - layout->addLayout(languageRow); - - retranslateUi(); -} - -void WelcomePage::languageChanged(int index) -{ - if (index < 0) { - return; - } - SettingsCache::instance().personal().setLang(langCombo->itemData(index).toString()); - qApp->removeTranslator(translator); // NOLINT(cppcoreguidelines-pro-type-static-cast-downcast) - installNewTranslator(); -} - -QString WelcomePage::stepTitle() const -{ - return tr("Welcome!"); -} - -void WelcomePage::retranslateUi() -{ - bodyLabel->setText(tr("Let's get you set up. This will only take a minute — " - "we'll grab the card database, pick a look you like, " - "and get you ready to connect to a server.\n\n" - "You can change any of this later from Settings.")); - languageLabel->setText(tr("Language:")); -} diff --git a/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.h b/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.h deleted file mode 100644 index 93b24d83d..000000000 --- a/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.h +++ /dev/null @@ -1,28 +0,0 @@ -#ifndef WELCOME_PAGE_H -#define WELCOME_PAGE_H - -#include "../first_run_wizard_page.h" - -class QComboBox; -class QLabel; - -class WelcomePage : public FirstRunWizardPage -{ - Q_OBJECT - -public: - explicit WelcomePage(QWidget *parent = nullptr); - - QString stepTitle() const override; - void retranslateUi() override; - -private slots: - void languageChanged(int index); - -private: - QLabel *bodyLabel; - QLabel *languageLabel; - QComboBox *langCombo; -}; - -#endif // WELCOME_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/qml/BrandBanner.qml b/cockatrice/src/interface/widgets/onboarding/qml/BrandBanner.qml deleted file mode 100644 index f1a385cad..000000000 --- a/cockatrice/src/interface/widgets/onboarding/qml/BrandBanner.qml +++ /dev/null @@ -1,62 +0,0 @@ -import QtQuick - -Item { - id: root - - ShaderEffect { - id: effectA - anchors.fill: parent - opacity: bannerConfig.frontIsA ? 1.0 : 0.0 - Behavior on opacity { NumberAnimation { duration: 450; easing.type: Easing.InOutCubic } } - property real iTime: bannerConfig.time - property real uAspect: bannerConfig.aspect - property real uMode: bannerConfig.modeA - property real uSpeed: bannerConfig.speedA - property real uSeed: bannerConfig.seedA - 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 real uLogoGlow: bannerConfig.logoGlow - fragmentShader: "qrc:/onboarding/shaders/brand_banner.frag.qsb" - } - - ShaderEffect { - id: effectB - anchors.fill: parent - opacity: bannerConfig.frontIsA ? 0.0 : 1.0 - Behavior on opacity { NumberAnimation { duration: 450; easing.type: Easing.InOutCubic } } - property real iTime: bannerConfig.time - property real uAspect: bannerConfig.aspect - property real uMode: bannerConfig.modeB - property real uSpeed: bannerConfig.speedB - property real uSeed: bannerConfig.seedB - 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 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 - visible: bannerConfig.logoVisible - source: "qrc:/resources/cockatrice-logo-white.svg" - 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) - - Behavior on opacity { NumberAnimation { duration: 300; easing.type: Easing.InOutSine } } - - 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 - } - } -} diff --git a/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp deleted file mode 100644 index fd1fb2a98..000000000 --- a/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp +++ /dev/null @@ -1,195 +0,0 @@ -#include "shader_banner_widget.h" - -#include "banner_shader_config.h" - -#include -#include -#include -#include -#include -#include - -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; -} // namespace - -class GradientFallbackWidget : public QWidget -{ -public: - using QWidget::QWidget; - -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)); - painter.fillRect(rect(), gradient); - } -}; - -BannerHost::BannerHost(QWidget *parent) : QWidget(parent) -{ - setFixedHeight(150); - - stack = new QStackedLayout(this); - stack->setContentsMargins(0, 0, 0, 0); - - fallback = new GradientFallbackWidget(this); - stack->addWidget(fallback); - - quickWidget = new QQuickWidget(this); - quickWidget->setResizeMode(QQuickWidget::SizeRootObjectToView); - - config = new BannerShaderConfig(quickWidget->engine()); - quickWidget->rootContext()->setContextProperty("bannerConfig", config); - quickWidget->setSource(QUrl("qrc:/onboarding/qml/BrandBanner.qml")); - - if (quickWidget->status() == QQuickWidget::Error) { - activateFallback(); - } else { - connect(quickWidget, &QQuickWidget::sceneGraphError, this, &BannerHost::onSceneGraphFailed); - stack->addWidget(quickWidget); - stack->setCurrentWidget(quickWidget); - } - - connect(&clock, &QTimer::timeout, this, &BannerHost::tick); - clock.setInterval(16); // ~60fps; the shader itself is cheap, this is just a wall clock - - applyMotifPreset(currentMotif); - updateAspect(); -} - -void BannerHost::activateFallback() -{ - if (usingFallback) { - return; - } - usingFallback = true; - clock.stop(); - stack->setCurrentWidget(fallback); - - if (quickWidget) { - quickWidget->deleteLater(); // takes BannerShaderConfig (parented to its engine) with it - quickWidget = nullptr; - config = nullptr; - } -} - -void BannerHost::onSceneGraphFailed() -{ - activateFallback(); -} - -void BannerHost::setMotif(Motif motif) -{ - currentMotif = motif; - applyMotifPreset(motif); -} - -BannerHost::Preset BannerHost::presetFor(Motif motif) -{ - // speed/seed tuned per motif so e.g. the network "pulse" (Account) reads - // at a deliberately calmer cadence than the data "scan" lines - // (Preferences), even though both come from the same shader. - switch (motif) { - case Motif::Welcome: - return {0.0, 0.6, 0.15}; - case Motif::CardDatabase: - return {1.0, 1.3, 0.42}; - case Motif::Theming: - return {2.0, 1.2, 0.73}; - case Motif::Account: - return {3.0, 0.8, 0.28}; - case Motif::Preferences: - return {4.0, 1.0, 0.61}; - case Motif::Finish: - return {5.0, 1.0, 0.91}; - } - return {0.0, 0.6, 0.15}; -} - -void BannerHost::applyMotifPreset(Motif motif) -{ - if (usingFallback || !config) { - return; - } - - const Preset p = presetFor(motif); - - config->setColorA(QColor(kColorA)); - config->setColorB(QColor(kColorB)); - config->setAccent(QColor(kAccent)); - config->setLogoVisible(motif == Motif::Welcome); - - if (isFirstApply) { - // Nothing on screen yet -- write straight into the front bank, no - // crossfade needed for the very first paint. - config->setModeA(p.mode); - config->setSpeedA(p.speed); - config->setSeedA(p.seed); - config->setFrontIsA(true); - isFirstApply = false; - return; - } - - // Write the new preset into whichever bank is currently hidden, then - // flip which one is front. QML's opacity Behavior does the actual - // crossfade -- BannerHost never animates anything itself. - if (config->frontIsA()) { - config->setModeB(p.mode); - config->setSpeedB(p.speed); - config->setSeedB(p.seed); - config->setFrontIsA(false); - } else { - config->setModeA(p.mode); - config->setSpeedA(p.speed); - config->setSeedA(p.seed); - config->setFrontIsA(true); - } -} - -void BannerHost::updateAspect() -{ - if (config && height() > 0) { - config->setAspect(qreal(width()) / qreal(height())); - } -} - -void BannerHost::resizeEvent(QResizeEvent *event) -{ - QWidget::resizeEvent(event); - updateAspect(); -} - -void BannerHost::showEvent(QShowEvent *event) -{ - QWidget::showEvent(event); - if (!usingFallback) { - elapsed.restart(); - clock.start(); - } -} - -void BannerHost::hideEvent(QHideEvent *event) -{ - QWidget::hideEvent(event); - clock.stop(); -} - -void BannerHost::tick() -{ - if (config) { - qreal t = elapsed.elapsed() / 1000.0; - config->setTime(t); - // Visible breathing for the logo: oscillates between 0.0 and 1.0 - qreal glow = 0.5 + 0.5 * qSin(t * 0.4); - config->setLogoGlow(glow); - } -} diff --git a/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.h b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.h deleted file mode 100644 index 2e230ad7f..000000000 --- a/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.h +++ /dev/null @@ -1,83 +0,0 @@ -#ifndef SHADER_BANNER_WIDGET_H -#define SHADER_BANNER_WIDGET_H - -#include -#include -#include - -class BannerShaderConfig; -class QQuickWidget; -class GradientFallbackWidget; -class QStackedLayout; - -/** @brief Onboarding banner: a subtle, looping brand-shader animation, one of six - * per-page "motifs" driving the same prebaked fragment shader - * (onboarding/shaders/brand_banner.frag) with different uniform values, so - * every page feels distinct but unmistakably part of the same family. - * - * Motif switches crossfade smoothly (see BrandBanner.qml's two stacked - * ShaderEffect layers + Behavior on opacity) rather than cutting instantly - * -- BannerHost just writes the new preset into whichever layer is - * currently hidden and flips BannerShaderConfig::frontIsA; QML handles the - * actual animation declaratively. - * - * Falls back to a static two-stop gradient (no shader, no QQuickWidget) if - * the platform's Qt Quick scenegraph can't initialize -- e.g. software - * rendering only, or a CI/VM environment with no GPU -- so onboarding - * never blocks or blanks out over a graphics driver problem. The fallback - * is permanent for the lifetime of this widget once triggered. */ -class BannerHost : public QWidget -{ - Q_OBJECT - -public: - enum class Motif - { - Welcome, - CardDatabase, - Theming, - Account, - Preferences, - Finish, - }; - - explicit BannerHost(QWidget *parent = nullptr); - - void setMotif(Motif motif); - -protected: - void showEvent(QShowEvent *event) override; - void hideEvent(QHideEvent *event) override; - void resizeEvent(QResizeEvent *event) override; - -private slots: - void tick(); - void onSceneGraphFailed(); - -private: - struct Preset - { - qreal mode; - qreal speed; - qreal seed; - }; - - static Preset presetFor(Motif motif); - - void applyMotifPreset(Motif motif); - void updateAspect(); - void activateFallback(); - - QStackedLayout *stack; - QQuickWidget *quickWidget = nullptr; - BannerShaderConfig *config = nullptr; - GradientFallbackWidget *fallback = nullptr; - - QTimer clock; - QElapsedTimer elapsed; - Motif currentMotif = Motif::Welcome; - bool usingFallback = false; - bool isFirstApply = true; -}; - -#endif // SHADER_BANNER_WIDGET_H diff --git a/cockatrice/src/interface/widgets/onboarding/shaders/brand_banner.frag b/cockatrice/src/interface/widgets/onboarding/shaders/brand_banner.frag deleted file mode 100644 index 508bd4bc4..000000000 --- a/cockatrice/src/interface/widgets/onboarding/shaders/brand_banner.frag +++ /dev/null @@ -1,461 +0,0 @@ -#version 440 - -// ════════════════════════════════════════════════════════════════════════ -// brand_banner.frag -// -// One shader, six motifs (uMode 0..5). All motifs composite over a shared -// backgroundField() whose colour is flow-noise-modulated blend of uColorA -// and uColorB. SDFs operate in aspect-corrected space (ac.x = uv.x * -// uAspect) to preserve shape proportions on the wide banner. -// -// IMPORTANT: the uniform block below must list custom uniforms in EXACTLY -// the order they're declared as properties on each ShaderEffect instance in -// BrandBanner.qml (after the two Qt-supplied members, qt_Matrix/qt_Opacity). -// ════════════════════════════════════════════════════════════════════════ - -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; - float iTime; - float uAspect; - float uMode; - float uSpeed; - float uSeed; - vec4 uColorA; - vec4 uColorB; - vec4 uAccent; - float uLogoGlow; -}; - -// ── Primitives ────────────────────────────────────────────────────────── - -float hash21(vec2 p) -{ - p = fract(p * vec2(123.34, 456.21)); - p += dot(p, p + 45.32); - return fract(p.x * p.y); -} - -float valueNoise(vec2 p) -{ - vec2 i = floor(p); - vec2 f = fract(p); - float a = hash21(i); - float b = hash21(i + vec2(1.0, 0.0)); - float c = hash21(i + vec2(0.0, 1.0)); - float d = hash21(i + vec2(1.0, 1.0)); - vec2 u = f * f * (3.0 - 2.0 * f); - return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); -} - -float fbm(vec2 p) -{ - float v = 0.0; - float amp = 0.5; - for (int i = 0; i < 3; i++) { - v += amp * valueNoise(p); - p *= 2.03; - amp *= 0.5; - } - return v; -} - -float flowNoise(vec2 p, float t) -{ - vec2 warp1 = vec2(fbm(p + vec2(0.0, 0.0)), fbm(p + vec2(5.2, 1.3))); - vec2 warp2 = vec2(fbm(p + 4.0 * warp1 + vec2(1.7, 9.2) + t * 0.6), - fbm(p + 4.0 * warp1 + vec2(8.3, 2.8) - t * 0.5)); - return fbm(p + 4.0 * warp2 + t * 0.15); -} - -float bloom(float d, float coreRadius, float haloRadius) -{ - float core = exp(-(d * d) / (coreRadius * coreRadius)); - float halo = exp(-d / haloRadius) * 0.35; - return core + halo; -} - -float roundedBoxSDF(vec2 p, vec2 halfSize, float radius) -{ - vec2 d = abs(p) - halfSize + radius; - return length(max(d, 0.0)) - radius + min(max(d.x, d.y), 0.0); -} - -// Rotated box SDF -- applies 2D rotation to p before evaluating roundedBoxSDF. -float rotatedBoxSDF(vec2 p, vec2 halfSize, float radius, float angle) -{ - float c = cos(angle); - float s = sin(angle); - vec2 rp = vec2(p.x * c - p.y * s, p.x * s + p.y * c); - return roundedBoxSDF(rp, halfSize, radius); -} - -float vignette(vec2 uv) -{ - vec2 c = uv - 0.5; - c.x *= max(uAspect, 0.0001); - return smoothstep(1.0, 0.25, length(c)); -} - -// ── Shared background ─────────────────────────────────────────────────── - -vec3 backgroundField(vec2 uv, float time) -{ - // Diagonal luminance gradient from (0,0) to (1,1) used as blend factor - // between uColorA and uColorB; modulated by flowNoise. - float baseD = smoothstep(0.0, 1.0, uv.y * 0.5 + uv.x * 0.2); - float painted = flowNoise(uv * 1.5, time * 0.04) - 0.5; - baseD = clamp(baseD + painted * 0.12, 0.0, 1.0); - - vec3 col = mix(uColorA.rgb, uColorB.rgb, baseD); - - // Low-frequency fBM noise pushes local colour toward uColorB for depth - float deep = fbm(uv * 1.0 + vec2(37.1, 12.4) + time * 0.015); - col = mix(col, uColorB.rgb, (deep - 0.5) * 0.08); - - // 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; - - return col; -} - -// ── Motifs ────────────────────────────────────────────────────────────── - -// Centre bloom, flow-noise shimmer gated to centre, and 48 orbiting ember -// particles that deflect into a tight ring near the centre. -vec3 motifWelcome(vec2 uv, vec3 bg, float t) -{ - vec3 col = bg; - float asp = max(uAspect, 0.001); - vec2 ac = vec2(uv.x * asp, uv.y); - vec2 center = vec2(asp * 0.5, 0.5); - float cDist = length(ac - center); - - // Centre bloom at logo position; intensity scales with uLogoGlow - float centreLight = bloom(cDist, 0.08 * asp, 0.40 * asp); - col += 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; - - // 48 ember particles: hash-seeded position, speed, size, brightness. - // Embers within a distance threshold of centre are deflected into an - // orbital ring via tangent displacement perpendicular to the centre vector. - const int EMBERS = 48; - for (int i = 0; i < EMBERS; i++) { - float fi = float(i); - - float baseX = hash21(vec2(fi * 7.31 + uSeed, fi * 3.17)); - float baseY = hash21(vec2(fi * 11.9 + uSeed * 1.4, fi * 5.53)); - - float riseSpeed = 0.025 + hash21(vec2(fi * 1.7, uSeed * 2.1)) * 0.035; - float driftAmp = 0.04 + hash21(vec2(fi * 9.3, uSeed)) * 0.06; - float driftFreq = 0.3 + hash21(vec2(fi * 4.1, uSeed * 3.3)) * 0.5; - - 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; - - // Fade out near top/bottom edges - float edgeFade = smoothstep(0.0, 0.12, pY) * smoothstep(1.0, 0.88, pY); - float twinkle = 0.6 + 0.4 * sin(t * (1.2 + fi * 0.37) + fi * 2.9); - - vec2 ePos = vec2(pX, pY); - - // Embers near centre: deflect into orbital ring via tangent displacement - vec2 toCenter = ePos - center; - float distToCenter = length(toCenter); - float ringWeight = smoothstep(0.38 * asp, 0.06 * asp, distToCenter); - - float orbitPhase = t * (0.15 + fi * 0.020) + fi * 2.3; - float orbitAmount = 0.020 + hash21(vec2(fi * 12.3, uSeed * 2.7)) * 0.020; - vec2 tangent = vec2(-toCenter.y, toCenter.x); - vec2 deflected = ePos + tangent * ringWeight * orbitAmount * asp * sin(orbitPhase); - - float pushOut = ringWeight * (0.008 + hash21(vec2(fi * 6.7, uSeed * 1.1)) * 0.012) * asp; - deflected += normalize(toCenter + 0.001) * pushOut; - - float dist = length(ac - deflected); - float intensity = bright * edgeFade * twinkle; - col += uAccent.rgb * bloom(dist, size, size * 4.0) * intensity; - } - - return col; -} - -// 25 card-shaped box SDFs at parallax depths drifting horizontally across -// the banner; each card has a semi-transparent fill, accent outline, and -// card-back diamond pattern. -vec3 motifCardDatabase(vec2 uv, vec3 bg, float t) -{ - vec3 col = bg; - float asp = max(uAspect, 0.001); - vec2 ac = vec2(uv.x * asp, uv.y); - - const int CARDS = 25; - for (int i = 0; i < CARDS; i++) { - float fi = float(i); - - // Parallax depth via hash; used to scale size, speed, brightness - float depth = hash21(vec2(fi * 1.37 + uSeed, fi * 0.91)); - - // Card dimensions in corrected space (portrait: height > width) - float cardH = mix(0.055, 0.15, depth); - cardH *= 0.85 + 0.30 * hash21(vec2(fi * 3.14, uSeed * 2.71)); - float cardW = cardH * 0.71; // 5:7 ratio - - // Horizontal drift; nearer cards (higher depth) move faster - float speed = mix(0.06, 0.18, depth); - float xPhase = hash21(vec2(fi * 7.13, uSeed * 4.37)); - xPhase = fract(xPhase + t * speed); - float x = mix(-1.5, asp + 1.5, xPhase); - - // Vertical position: hash distribution with sinusoidal oscillation - float yBase = hash21(vec2(fi * 2.91, uSeed * 1.63)); - float y = yBase + sin(t * 0.6 + fi * 1.9) * 0.035; - y = clamp(y, cardH + 0.02, 1.0 - cardH - 0.02); - - // Random rotation angle ±4 degrees - float tilt = (hash21(vec2(fi * 5.71, uSeed * 8.29)) - 0.5) * 0.14; - - vec2 p = ac - vec2(x, y); - float d = rotatedBoxSDF(p, vec2(cardW, cardH), cardW * 0.14, tilt); - - // Semi-transparent dark fill - float fill = smoothstep(0.015, -0.005, d); - col = mix(col, uColorB.rgb * 0.55, fill * 0.50); - - // Accent outline - float edge = smoothstep(0.035, 0.0, abs(d)); - col += uAccent.rgb * edge * mix(0.18, 0.50, 1.0 - depth); - - // Card-back diamond: smaller rotated box inset from card edges - float innerD = rotatedBoxSDF(p, vec2(cardW * 0.45, cardH * 0.55), cardW * 0.08, tilt); - float innerEdge = smoothstep(0.012, 0.0, abs(innerD)); - col += uAccent.rgb * innerEdge * fill * 0.12 * (1.0 - depth); - - // Centre dot - float dotDist = length(p); - col += uAccent.rgb * bloom(dotDist, 0.008, 0.02) * fill * 0.15 * (1.0 - depth); - } - return col; -} - -// 4 horizontal bands with multi-frequency sinusoidal warp and pulsing width. -vec3 motifTheming(vec2 uv, vec3 bg, float t) -{ - vec3 col = bg; - - const int BANDS = 4; - for (int i = 0; i < BANDS; i++) { - float fi = float(i); - float yCenter = 0.18 + fi * 0.22; - - // Three summed sinusoids for horizontal undulation - float wave = sin(uv.x * 3.2 + t * 0.5 + fi * 2.1) * 0.08; - wave += sin(uv.x * 7.0 - t * 0.3 + fi * 1.3) * 0.035; - wave += sin(uv.x * 1.6 + t * 0.18 + fi * 3.7) * 0.05; - - float bandDist = abs(uv.y - yCenter - wave); - float bandWidth = 0.04 + sin(t * 0.2 + fi * 0.8) * 0.012; - float band = smoothstep(bandWidth, 0.0, bandDist); - - // Upper bands have higher intensity - float intensity = mix(0.15, 0.38, 1.0 - fi / float(BANDS)); - col += uAccent.rgb * band * intensity; - } - - return col; -} - -// 14 nodes at pseudo-random positions with sinusoidal pulse; edges drawn -// between nodes within a threshold distance; central glow + periodic ring. -vec3 motifAccount(vec2 uv, vec3 bg, float t) -{ - vec3 col = bg; - float asp = max(uAspect, 0.001); - vec2 ac = vec2(uv.x * asp, uv.y); - vec2 center = vec2(asp * 0.5, 0.5); - - const int NODES = 14; - vec2 nodePos[14]; - float nodePulse[14]; - - for (int i = 0; i < NODES; i++) { - float fi = float(i); - - // Hash-seeded position with gentle sinusoidal drift - float nx = hash21(vec2(fi * 3.17 + uSeed, fi * 1.93)) * asp; - float ny = hash21(vec2(fi * 5.41 + uSeed * 1.7, fi * 2.79)); - - float dx = sin(t * 0.12 + fi * 1.7) * 0.08; - float dy = cos(t * 0.09 + fi * 2.3) * 0.04; - vec2 pos = vec2(nx + dx, ny + dy); - nodePos[i] = pos; - - // Per-node pulse phase, normalised to [0, 1] - float pulsePhase = hash21(vec2(fi * 4.31, uSeed * 6.17)); - float pulse = sin(t * 0.8 + pulsePhase * 6.283) * 0.5 + 0.5; - nodePulse[i] = pulse; - - // 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); - } - - // Edges: connect nodes within a radius threshold - float connectDist = asp * 0.22; - for (int i = 0; i < NODES; i++) { - for (int j = i + 1; j < NODES; j++) { - float pairDist = length(nodePos[i] - nodePos[j]); - if (pairDist < connectDist) { - float strength = 1.0 - pairDist / connectDist; - vec2 pa = ac - nodePos[i]; - 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; - } - } - } - - // Central bloom at banner centre - float cDist = length(ac - center); - col += uAccent.rgb * bloom(cDist, 0.04, 0.25) * 0.12; - - // 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; - - return col; -} - -// 18x5 toggle-grid of rounded boxes with hash-driven on/off per cell; -// a scanning highlight sweeps L-to-R, brightening cells near the scan line. -vec3 motifPreferences(vec2 uv, vec3 bg, float t) -{ - vec3 col = bg; - - float cols = 18.0; - float rows = 5.0; - vec2 gridUV = uv * vec2(cols, rows); - vec2 cell = fract(gridUV) - 0.5; - vec2 cellId = floor(gridUV); - - // On/off state per cell, hash-seeded for pseudo-randomness - float on = step(0.55, hash21(cellId + uSeed * 10.0)); - - float d = roundedBoxSDF(cell, vec2(0.28, 0.32), 0.06); - - // Filled "on" cells - float cellFill = smoothstep(0.04, -0.02, d); - col += uAccent.rgb * cellFill * on * 0.18; - - // Cell borders (drawn on all cells) - float border = smoothstep(0.025, 0.0, abs(d)); - col += uAccent.rgb * border * 0.06; - - // Scanning highlight: thin line + soft glow sweeping L-to-R - float scanX = fract(t * 0.15); - float scanDist = abs(uv.x - scanX); - float scanLine = smoothstep(0.015, 0.0, scanDist); - col += uAccent.rgb * scanLine * 0.40; - - float scanGlow = smoothstep(0.08, 0.0, scanDist); - col += uAccent.rgb * scanGlow * 0.08; - - // "On" cells near the scan line get extra brightness - float scanProximity = smoothstep(0.12, 0.0, scanDist); - col += uAccent.rgb * cellFill * on * scanProximity * 0.15; - - return col; -} - -// Centre radial bloom with sinusoidal pulse, 4 expanding ring halos with -// outer glow falloff, and 35 rising particles. -vec3 motifFinish(vec2 uv, vec3 bg, float t) -{ - vec3 col = bg; - float asp = max(uAspect, 0.001); - vec2 ac = vec2(uv.x * asp, uv.y); - vec2 center = vec2(asp * 0.5, 0.5); - float cDist = length(ac - center); - - // Centre bloom with sinusoidal pulse modulation - float pulse = 0.65 + 0.35 * sin(t * 0.4); - col += uAccent.rgb * bloom(cDist, 0.12, 0.55) * 0.10 * pulse; - - // 4 expanding rings: radius increases via phase; ring width grows with - // expansion; combined with exponential outer glow falloff - for (int i = 0; i < 4; i++) { - float fi = float(i); - float phase = fract(t * 0.06 + fi * 0.25); - float ringRadius = phase * asp * 0.7; - float ringDist = abs(cDist - ringRadius); - float ringWidth = 0.025 + phase * 0.025; - float ring = smoothstep(ringWidth, 0.0, ringDist); - float outerGlow = exp(-ringDist / (0.03 + phase * 0.02)) * 0.3; - float combined = ring + outerGlow; - float fade = 1.0 - phase * 0.5; - col += uAccent.rgb * combined * fade * 0.15; - } - - // 35 particles rising vertically with sinusoidal horizontal drift; - // each particle uses bloom with edge fade and twinkle animation - const int PARTICLES = 35; - for (int i = 0; i < PARTICLES; i++) { - float fi = float(i); - float baseX = hash21(vec2(fi * 13.7 + uSeed, fi * 7.31)); - float baseY = hash21(vec2(fi * 23.1 + uSeed * 1.9, fi * 11.3)); - - float riseSpeed = 0.04 + hash21(vec2(fi * 3.1, uSeed * 2.7)) * 0.06; - float driftAmp = 0.03 + hash21(vec2(fi * 8.9, uSeed)) * 0.05; - float driftFreq = 0.4 + hash21(vec2(fi * 5.3, uSeed * 4.1)) * 0.6; - - float pX = baseX * asp + sin(t * driftFreq + fi * 2.3) * driftAmp * asp; - float pY = fract(baseY + t * riseSpeed); - - float size = 0.005 + hash21(vec2(fi * 4.7, uSeed * 3.9)) * 0.010; - float bright = 0.12 + hash21(vec2(fi * 7.1, uSeed * 1.3)) * 0.25; - - float edgeFade = smoothstep(0.0, 0.1, pY) * smoothstep(1.0, 0.9, pY); - float twinkle = 0.5 + 0.5 * sin(t * (1.8 + fi * 0.43) + fi * 3.1); - - vec2 pPos = vec2(pX, pY); - float dist = length(ac - pPos); - col += uAccent.rgb * bloom(dist, size, size * 3.5) * bright * edgeFade * twinkle; - } - - return col; -} - -// ── Main ──────────────────────────────────────────────────────────────── - -void main() -{ - vec2 uv = qt_TexCoord0; - float t = iTime * uSpeed; - - vec3 bg = backgroundField(uv, iTime); - - vec3 col; - if (uMode < 0.5) col = motifWelcome(uv, bg, t); - else if (uMode < 1.5) col = motifCardDatabase(uv, bg, t); - else if (uMode < 2.5) col = motifTheming(uv, bg, t); - else if (uMode < 3.5) col = motifAccount(uv, bg, t); - else if (uMode < 4.5) col = motifPreferences(uv, bg, t); - else col = motifFinish(uv, bg, t); - - col *= mix(0.62, 1.0, vignette(uv)); - fragColor = vec4(col, 1.0) * qt_Opacity; -} diff --git a/cockatrice/src/interface/widgets/onboarding/step_indicator_widget.cpp b/cockatrice/src/interface/widgets/onboarding/step_indicator_widget.cpp deleted file mode 100644 index d25e8544b..000000000 --- a/cockatrice/src/interface/widgets/onboarding/step_indicator_widget.cpp +++ /dev/null @@ -1,84 +0,0 @@ -#include "step_indicator_widget.h" - -#include -#include - -StepIndicatorWidget::StepIndicatorWidget(QWidget *parent) : QWidget(parent) -{ - setFixedHeight(kDotDiameter + 2 * kVerticalMargin); -} - -void StepIndicatorWidget::setStepCount(int count) -{ - stepCount = qMax(0, count); - currentStep = qBound(0, currentStep, qMax(0, stepCount - 1)); - updateGeometry(); - update(); -} - -void StepIndicatorWidget::setCurrentStep(int index) -{ - if (stepCount == 0) { - return; - } - currentStep = qBound(0, index, stepCount - 1); - update(); -} - -QSize StepIndicatorWidget::sizeHint() const -{ - return minimumSizeHint(); -} - -QSize StepIndicatorWidget::minimumSizeHint() const -{ - if (stepCount == 0) { - return QSize(0, height()); - } - int width = kActiveDotWidth + (stepCount - 1) * kDotDiameter + (stepCount - 1) * kDotSpacing; - return QSize(width, height()); -} - -void StepIndicatorWidget::paintEvent(QPaintEvent * /*event*/) -{ - if (stepCount == 0) { - return; - } - - QPainter painter(this); - painter.setRenderHint(QPainter::Antialiasing); - - const QColor activeColor = palette().color(QPalette::Highlight); - - // QPalette::Mid alpha-blended against a dark Window background reads as - // near-invisible (Mid is itself a dark grey in dark palettes -- see - // PaletteGenerator's satShadeLo/Dark roles). WindowText is guaranteed to - // contrast against Window in any theme by definition, so alpha-blending - // *that* instead keeps the dots visibly dim-but-present in both light and - // dark schemes. Same trick PaletteGenerator uses for placeholder text. - QColor inactiveColor = palette().color(QPalette::WindowText); - inactiveColor.setAlpha(100); - - int totalWidth = 0; - for (int i = 0; i < stepCount; ++i) { - totalWidth += (i == currentStep) ? kActiveDotWidth : kDotDiameter; - if (i > 0) { - totalWidth += kDotSpacing; - } - } - - int x = (width() - totalWidth) / 2; - const int y = height() / 2; - - for (int i = 0; i < stepCount; ++i) { - const bool active = (i == currentStep); - const int dotWidth = active ? kActiveDotWidth : kDotDiameter; - - QPainterPath path; - QRectF rect(x, y - kDotDiameter / 2.0, dotWidth, kDotDiameter); - path.addRoundedRect(rect, kDotDiameter / 2.0, kDotDiameter / 2.0); - painter.fillPath(path, active ? activeColor : inactiveColor); - - x += dotWidth + kDotSpacing; - } -} diff --git a/cockatrice/src/interface/widgets/onboarding/step_indicator_widget.h b/cockatrice/src/interface/widgets/onboarding/step_indicator_widget.h deleted file mode 100644 index 1b85be04f..000000000 --- a/cockatrice/src/interface/widgets/onboarding/step_indicator_widget.h +++ /dev/null @@ -1,34 +0,0 @@ -#ifndef STEP_INDICATOR_WIDGET_H -#define STEP_INDICATOR_WIDGET_H - -#include - -/** @brief Row of dots showing progress through a fixed-length sequence of steps, - * in the style of a mobile/OS setup flow. Purely presentational. */ -class StepIndicatorWidget : public QWidget -{ - Q_OBJECT - -public: - explicit StepIndicatorWidget(QWidget *parent = nullptr); - - void setStepCount(int count); - void setCurrentStep(int index); - - QSize sizeHint() const override; - QSize minimumSizeHint() const override; - -protected: - void paintEvent(QPaintEvent *event) override; - -private: - int stepCount = 0; - int currentStep = 0; - - static constexpr int kDotDiameter = 8; - static constexpr int kActiveDotWidth = 22; - static constexpr int kDotSpacing = 10; - static constexpr int kVerticalMargin = 6; -}; - -#endif // STEP_INDICATOR_WIDGET_H diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp index 0b77ca185..2b9201e6c 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp @@ -1,7 +1,6 @@ #include "printing_selector_card_overlay_widget.h" #include "../../../client/settings/cache_settings.h" -#include "../cards/card_info_picture_widget.h" #include "printing_selector_card_display_widget.h" #include diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp index 8d5d423f6..646f2ee33 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp @@ -7,9 +7,9 @@ #include "../chat_view/chat_view.h" #include "../game_selector.h" #include "user_info_box.h" -#include "user_list_dialog.h" #include "user_list_manager.h" #include "user_list_proxy.h" +#include "user_list_widget.h" #include #include diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp index fb610e814..f6f34a6a5 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp @@ -525,13 +525,6 @@ void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool o connect(games, &QPushButton::clicked, this, [this, name] { emit showGamesRequested(name); }); add(games); - // ── Invite (only while the inviter has a joinable game for this user) ──── - if (!isSelf && online && gameInviteAvailable && gameInviteAvailable(name)) { - auto *invite = makeBtn(tr("Invite"), tr("Invite to your game"), actionArea, theme); - connect(invite, &QPushButton::clicked, this, [this, name] { emit inviteRequested(name); }); - add(invite); - } - // ── Buddy / ignore (registered users only) ──────────────────────────────── if (!isSelf && isReg) { if (isBuddy) { diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.h b/cockatrice/src/interface/widgets/server/user/user_info_popup.h index ed7320fba..02cc2b44e 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_popup.h +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.h @@ -9,7 +9,6 @@ #include #include #include -#include #include #include #include @@ -150,17 +149,6 @@ public: /** Re-pulls the avatar/card art for the currently shown user (e.g. after it loads). */ void refreshHeader(); - /** - * Sets a predicate evaluated on every action-button rebuild. It receives - * the name of the user the popup currently shows; when it returns true an - * "Invite" button is shown. The popup itself never resolves the invite - * link, it just forwards the request. - */ - void setGameInviteAvailable(std::function available) - { - gameInviteAvailable = std::move(available); - } - signals: void mouseEnteredPopup(); void mouseLeftPopup(); @@ -171,7 +159,6 @@ signals: // ── Action signals — connect to UserContextMenu::exec*() ────────────────── void chatRequested(const QString &userName); - void inviteRequested(const QString &userName); void detailsRequested(const QString &userName); void showGamesRequested(const QString &userName); void addBuddyRequested(const QString &userName); @@ -213,7 +200,6 @@ private: QString currentUser; ServerInfo_User currentUserInfo; bool currentOnline = false; - std::function gameInviteAvailable; UserInfoHeaderWidget *header; QWidget *actionArea; ///< rebuilt per user diff --git a/cockatrice/src/interface/widgets/server/user/user_list_dialog.cpp b/cockatrice/src/interface/widgets/server/user/user_list_dialog.cpp deleted file mode 100644 index fc5b520ad..000000000 --- a/cockatrice/src/interface/widgets/server/user/user_list_dialog.cpp +++ /dev/null @@ -1,302 +0,0 @@ -#include "user_list_dialog.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -BanDialog::BanDialog(const ServerInfo_User &info, QWidget *parent) : QDialog(parent) -{ - setAttribute(Qt::WA_DeleteOnClose); - - nameBanCheckBox = new QCheckBox(tr("ban &user name")); - nameBanCheckBox->setChecked(true); - nameBanEdit = new QLineEdit(QString::fromStdString(info.name())); - nameBanEdit->setMaxLength(MAX_NAME_LENGTH); - ipBanCheckBox = new QCheckBox(tr("ban &IP address")); - ipBanCheckBox->setChecked(true); - ipBanEdit = new QLineEdit(QString::fromStdString(info.address())); - ipBanEdit->setMaxLength(MAX_NAME_LENGTH); - idBanCheckBox = new QCheckBox(tr("ban client I&D")); - idBanCheckBox->setChecked(true); - idBanEdit = new QLineEdit(QString::fromStdString(info.clientid())); - idBanEdit->setMaxLength(MAX_NAME_LENGTH); - if (QString::fromStdString(info.clientid()).isEmpty()) { - idBanCheckBox->setChecked(false); - } - - QGridLayout *banTypeGrid = new QGridLayout; - banTypeGrid->addWidget(nameBanCheckBox, 0, 0); - banTypeGrid->addWidget(nameBanEdit, 0, 1); - banTypeGrid->addWidget(ipBanCheckBox, 1, 0); - banTypeGrid->addWidget(ipBanEdit, 1, 1); - banTypeGrid->addWidget(idBanCheckBox, 2, 0); - banTypeGrid->addWidget(idBanEdit, 2, 1); - QGroupBox *banTypeGroupBox = new QGroupBox(tr("Ban type")); - banTypeGroupBox->setLayout(banTypeGrid); - - permanentRadio = new QRadioButton(tr("&permanent ban")); - temporaryRadio = new QRadioButton(tr("&temporary ban")); - temporaryRadio->setChecked(true); - connect(temporaryRadio, &QRadioButton::toggled, this, &BanDialog::enableTemporaryEdits); - daysLabel = new QLabel(tr("&Days:")); - daysEdit = new QSpinBox; - daysEdit->setMinimum(0); - daysEdit->setValue(0); - daysEdit->setMaximum(10000); - daysLabel->setBuddy(daysEdit); - hoursLabel = new QLabel(tr("&Hours:")); - hoursEdit = new QSpinBox; - hoursEdit->setMinimum(0); - hoursEdit->setValue(0); - hoursEdit->setMaximum(24); - hoursLabel->setBuddy(hoursEdit); - minutesLabel = new QLabel(tr("&Minutes:")); - minutesEdit = new QSpinBox; - minutesEdit->setMinimum(0); - minutesEdit->setValue(5); - minutesEdit->setMaximum(60); - minutesLabel->setBuddy(minutesEdit); - QGridLayout *durationLayout = new QGridLayout; - durationLayout->addWidget(permanentRadio, 0, 0, 1, 6); - durationLayout->addWidget(temporaryRadio, 1, 0, 1, 6); - durationLayout->addWidget(daysLabel, 2, 0); - durationLayout->addWidget(daysEdit, 2, 1); - durationLayout->addWidget(hoursLabel, 2, 2); - durationLayout->addWidget(hoursEdit, 2, 3); - durationLayout->addWidget(minutesLabel, 2, 4); - durationLayout->addWidget(minutesEdit, 2, 5); - QGroupBox *durationGroupBox = new QGroupBox(tr("Duration of the ban")); - durationGroupBox->setLayout(durationLayout); - - QLabel *reasonLabel = new QLabel(tr("Please enter the reason for the ban.\n" - "This is only saved for moderators and cannot be seen by the banned person.")); - reasonEdit = new QPlainTextEdit; - - QLabel *visibleReasonLabel = - new QLabel(tr("Please enter the reason for the ban that will be visible to the banned person.")); - visibleReasonEdit = new QPlainTextEdit; - - deleteMessages = new QCheckBox(tr("Redact all messages from this user in all rooms")); - - QPushButton *okButton = new QPushButton(tr("&OK")); - okButton->setAutoDefault(true); - connect(okButton, &QPushButton::clicked, this, &BanDialog::okClicked); - QPushButton *cancelButton = new QPushButton(tr("&Cancel")); - connect(cancelButton, &QPushButton::clicked, this, &BanDialog::reject); - - QHBoxLayout *buttonLayout = new QHBoxLayout; - buttonLayout->addStretch(); - buttonLayout->addWidget(okButton); - buttonLayout->addWidget(cancelButton); - - QVBoxLayout *vbox = new QVBoxLayout; - vbox->addWidget(banTypeGroupBox); - vbox->addWidget(durationGroupBox); - vbox->addWidget(reasonLabel); - vbox->addWidget(reasonEdit); - vbox->addWidget(visibleReasonLabel); - vbox->addWidget(visibleReasonEdit); - vbox->addWidget(deleteMessages); - vbox->addLayout(buttonLayout); - - setLayout(vbox); - setWindowTitle(tr("Ban user from server")); -} - -WarningDialog::WarningDialog(const QString &userName, const QString &clientID, QWidget *parent) : QDialog(parent) -{ - setAttribute(Qt::WA_DeleteOnClose); - descriptionLabel = new QLabel(tr("Which warning would you like to send?")); - nameWarning = new QLineEdit(userName); - nameWarning->setMaxLength(MAX_NAME_LENGTH); - warnClientID = new QLineEdit(clientID); - warnClientID->setMaxLength(MAX_NAME_LENGTH); - warningOption = new QComboBox(); - warningOption->addItem("", ""); - - deleteMessages = new QCheckBox(tr("Redact all messages from this user in all rooms")); - - QPushButton *okButton = new QPushButton(tr("&OK")); - okButton->setAutoDefault(true); - connect(okButton, &QPushButton::clicked, this, &WarningDialog::okClicked); - QPushButton *cancelButton = new QPushButton(tr("&Cancel")); - connect(cancelButton, &QPushButton::clicked, this, &WarningDialog::reject); - - QHBoxLayout *buttonLayout = new QHBoxLayout; - buttonLayout->addStretch(); - buttonLayout->addWidget(okButton); - buttonLayout->addWidget(cancelButton); - - QVBoxLayout *vbox = new QVBoxLayout; - vbox->addWidget(descriptionLabel); - vbox->addWidget(nameWarning); - vbox->addWidget(warningOption); - vbox->addWidget(deleteMessages); - vbox->addLayout(buttonLayout); - setLayout(vbox); - setWindowTitle(tr("Warn user for misconduct")); -} - -void WarningDialog::okClicked() -{ - if (nameWarning->text().simplified().isEmpty()) { - QMessageBox::critical(this, tr("Error"), - tr("User name to send a warning to can not be blank, please specify a user to warn.")); - return; - } - - if (warningOption->currentData().toString().simplified().isEmpty()) { - QMessageBox::critical(this, tr("Error"), - tr("Warning to use can not be blank, please select a valid warning to send.")); - return; - } - - accept(); -} - -QString WarningDialog::getName() const -{ - return nameWarning->text().simplified(); -} - -QString WarningDialog::getWarnID() const -{ - return warnClientID->text().simplified(); -} - -QString WarningDialog::getReason() const -{ - return warningOption->currentData().toString().simplified(); -} - -int WarningDialog::getDeleteMessages() const -{ - return deleteMessages->isChecked() ? -1 : 0; -} - -void WarningDialog::addWarningOption(const QString &warning, int startingIl) -{ - if (startingIl > 1) { - warningOption->addItem(tr("%1 (IL %2)").arg(warning).arg(startingIl), warning); - } else { - warningOption->addItem(warning, warning); - } -} - -void BanDialog::okClicked() -{ - if (!nameBanCheckBox->isChecked() && !ipBanCheckBox->isChecked() && !idBanCheckBox->isChecked()) { - QMessageBox::critical(this, tr("Error"), - tr("You have to select a name-based, IP-based, clientId based, or some combination of " - "the three to place a ban.")); - return; - } - - if (nameBanCheckBox->isChecked()) { - if (nameBanEdit->text().simplified() == "") { - QMessageBox::critical(this, tr("Error"), - tr("You must have a value in the name ban when selecting the name ban checkbox.")); - return; - } - } - - if (ipBanCheckBox->isChecked()) { - if (ipBanEdit->text().simplified() == "") { - QMessageBox::critical(this, tr("Error"), - tr("You must have a value in the ip ban when selecting the ip ban checkbox.")); - return; - } - } - - if (idBanCheckBox->isChecked()) { - if (idBanEdit->text().simplified() == "") { - QMessageBox::critical( - this, tr("Error"), - tr("You must have a value in the clientid ban when selecting the clientid ban checkbox.")); - return; - } - } - - accept(); -} - -void BanDialog::enableTemporaryEdits(bool enabled) -{ - daysLabel->setEnabled(enabled); - daysEdit->setEnabled(enabled); - hoursLabel->setEnabled(enabled); - hoursEdit->setEnabled(enabled); - minutesLabel->setEnabled(enabled); - minutesEdit->setEnabled(enabled); -} - -QString BanDialog::getBanId() const -{ - return idBanCheckBox->isChecked() ? idBanEdit->text() : QString(); -} - -QString BanDialog::getBanName() const -{ - return nameBanCheckBox->isChecked() ? nameBanEdit->text() : QString(); -} - -QString BanDialog::getBanIP() const -{ - return ipBanCheckBox->isChecked() ? ipBanEdit->text() : QString(); -} - -int BanDialog::getMinutes() const -{ - return permanentRadio->isChecked() ? 0 - : (daysEdit->value() * 24 * 60 + hoursEdit->value() * 60 + minutesEdit->value()); -} - -QString BanDialog::getReason() const -{ - return reasonEdit->toPlainText(); -} - -QString BanDialog::getVisibleReason() const -{ - return visibleReasonEdit->toPlainText(); -} - -int BanDialog::getDeleteMessages() const -{ - return deleteMessages->isChecked() ? -1 : 0; -} - -AdminNotesDialog::AdminNotesDialog(const QString &_userName, const QString &_notes, QWidget *_parent) - : QDialog(_parent), userName(_userName) -{ - setAttribute(Qt::WA_DeleteOnClose); - - auto *updateButton = new QPushButton(tr("Update Notes")); - updateButton->setEnabled(false); - connect(updateButton, &QPushButton::clicked, this, &AdminNotesDialog::accept); - - notes = new QPlainTextEdit(_notes); - notes->setMinimumWidth(500); - connect(notes, &QPlainTextEdit::textChanged, this, [=]() { updateButton->setEnabled(true); }); - - auto *vbox = new QVBoxLayout; - vbox->addWidget(notes); - vbox->addWidget(updateButton); - - setLayout(vbox); - setWindowTitle(tr("Admin Notes for %1").arg(_userName)); -} - -QString AdminNotesDialog::getNotes() const -{ - return notes->toPlainText(); -} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_list_dialog.h b/cockatrice/src/interface/widgets/server/user/user_list_dialog.h deleted file mode 100644 index fa7d5de92..000000000 --- a/cockatrice/src/interface/widgets/server/user/user_list_dialog.h +++ /dev/null @@ -1,79 +0,0 @@ -#ifndef COCKATRICE_USER_LIST_DIALOG_H -#define COCKATRICE_USER_LIST_DIALOG_H - -#include -#include - -class QComboBox; -class QLabel; -class QPlainTextEdit; -class QRadioButton; -class QSpinBox; -class QLineEdit; -class QCheckBox; - -class BanDialog : public QDialog -{ - Q_OBJECT - - QLabel *daysLabel, *hoursLabel, *minutesLabel; - QCheckBox *nameBanCheckBox, *ipBanCheckBox, *idBanCheckBox, *deleteMessages; - QLineEdit *nameBanEdit, *ipBanEdit, *idBanEdit; - QSpinBox *daysEdit, *hoursEdit, *minutesEdit; - QRadioButton *permanentRadio, *temporaryRadio; - QPlainTextEdit *reasonEdit, *visibleReasonEdit; - -private slots: - void okClicked(); - void enableTemporaryEdits(bool enabled); - -public: - explicit BanDialog(const ServerInfo_User &info, QWidget *parent = nullptr); - [[nodiscard]] QString getBanName() const; - [[nodiscard]] QString getBanIP() const; - [[nodiscard]] QString getBanId() const; - [[nodiscard]] int getMinutes() const; - [[nodiscard]] QString getReason() const; - [[nodiscard]] QString getVisibleReason() const; - [[nodiscard]] int getDeleteMessages() const; -}; - -class WarningDialog : public QDialog -{ - Q_OBJECT - - QLabel *descriptionLabel; - QLineEdit *nameWarning; - QComboBox *warningOption; - QLineEdit *warnClientID; - QCheckBox *deleteMessages; - -private slots: - void okClicked(); - -public: - WarningDialog(const QString &userName, const QString &clientID, QWidget *parent = nullptr); - [[nodiscard]] QString getName() const; - [[nodiscard]] QString getWarnID() const; - [[nodiscard]] QString getReason() const; - [[nodiscard]] int getDeleteMessages() const; - void addWarningOption(const QString &warning, int startingIl = 1); -}; - -class AdminNotesDialog : public QDialog -{ - Q_OBJECT - - QString userName; - QPlainTextEdit *notes; - -public: - explicit AdminNotesDialog(const QString &_userName, const QString &_notes, QWidget *_parent = nullptr); - [[nodiscard]] QString getName() const - { - return userName; - } - [[nodiscard]] QString getNotes() const; -}; - -#endif // COCKATRICE_USER_LIST_DIALOG_H diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp index a8c99c979..c4b5d6af6 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -1,21 +1,335 @@ #include "user_list_widget.h" #include "../../../../client/settings/cache_settings.h" +#include "../../../card_picture_loader/card_picture_loader.h" +#include "../../cards/art_crop_attribution.h" #include "../../interface/pixel_map_generator.h" #include "../../interface/theme_manager.h" +#include "../../interface/widgets/tabs/tab_account.h" #include "../../interface/widgets/tabs/tab_supervisor.h" +#include "../game_selector.h" #include "user_context_menu.h" #include "user_list_painter.h" -#include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include +#include +#include #include +#include #include #include -#include -#include -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include + +BanDialog::BanDialog(const ServerInfo_User &info, QWidget *parent) : QDialog(parent) +{ + setAttribute(Qt::WA_DeleteOnClose); + + nameBanCheckBox = new QCheckBox(tr("ban &user name")); + nameBanCheckBox->setChecked(true); + nameBanEdit = new QLineEdit(QString::fromStdString(info.name())); + nameBanEdit->setMaxLength(MAX_NAME_LENGTH); + ipBanCheckBox = new QCheckBox(tr("ban &IP address")); + ipBanCheckBox->setChecked(true); + ipBanEdit = new QLineEdit(QString::fromStdString(info.address())); + ipBanEdit->setMaxLength(MAX_NAME_LENGTH); + idBanCheckBox = new QCheckBox(tr("ban client I&D")); + idBanCheckBox->setChecked(true); + idBanEdit = new QLineEdit(QString::fromStdString(info.clientid())); + idBanEdit->setMaxLength(MAX_NAME_LENGTH); + if (QString::fromStdString(info.clientid()).isEmpty()) { + idBanCheckBox->setChecked(false); + } + + QGridLayout *banTypeGrid = new QGridLayout; + banTypeGrid->addWidget(nameBanCheckBox, 0, 0); + banTypeGrid->addWidget(nameBanEdit, 0, 1); + banTypeGrid->addWidget(ipBanCheckBox, 1, 0); + banTypeGrid->addWidget(ipBanEdit, 1, 1); + banTypeGrid->addWidget(idBanCheckBox, 2, 0); + banTypeGrid->addWidget(idBanEdit, 2, 1); + QGroupBox *banTypeGroupBox = new QGroupBox(tr("Ban type")); + banTypeGroupBox->setLayout(banTypeGrid); + + permanentRadio = new QRadioButton(tr("&permanent ban")); + temporaryRadio = new QRadioButton(tr("&temporary ban")); + temporaryRadio->setChecked(true); + connect(temporaryRadio, &QRadioButton::toggled, this, &BanDialog::enableTemporaryEdits); + daysLabel = new QLabel(tr("&Days:")); + daysEdit = new QSpinBox; + daysEdit->setMinimum(0); + daysEdit->setValue(0); + daysEdit->setMaximum(10000); + daysLabel->setBuddy(daysEdit); + hoursLabel = new QLabel(tr("&Hours:")); + hoursEdit = new QSpinBox; + hoursEdit->setMinimum(0); + hoursEdit->setValue(0); + hoursEdit->setMaximum(24); + hoursLabel->setBuddy(hoursEdit); + minutesLabel = new QLabel(tr("&Minutes:")); + minutesEdit = new QSpinBox; + minutesEdit->setMinimum(0); + minutesEdit->setValue(5); + minutesEdit->setMaximum(60); + minutesLabel->setBuddy(minutesEdit); + QGridLayout *durationLayout = new QGridLayout; + durationLayout->addWidget(permanentRadio, 0, 0, 1, 6); + durationLayout->addWidget(temporaryRadio, 1, 0, 1, 6); + durationLayout->addWidget(daysLabel, 2, 0); + durationLayout->addWidget(daysEdit, 2, 1); + durationLayout->addWidget(hoursLabel, 2, 2); + durationLayout->addWidget(hoursEdit, 2, 3); + durationLayout->addWidget(minutesLabel, 2, 4); + durationLayout->addWidget(minutesEdit, 2, 5); + QGroupBox *durationGroupBox = new QGroupBox(tr("Duration of the ban")); + durationGroupBox->setLayout(durationLayout); + + QLabel *reasonLabel = new QLabel(tr("Please enter the reason for the ban.\nThis is only saved for moderators and " + "cannot be seen by the banned person.")); + reasonEdit = new QPlainTextEdit; + + QLabel *visibleReasonLabel = + new QLabel(tr("Please enter the reason for the ban that will be visible to the banned person.")); + visibleReasonEdit = new QPlainTextEdit; + + deleteMessages = new QCheckBox(tr("Redact all messages from this user in all rooms")); + + QPushButton *okButton = new QPushButton(tr("&OK")); + okButton->setAutoDefault(true); + connect(okButton, &QPushButton::clicked, this, &BanDialog::okClicked); + QPushButton *cancelButton = new QPushButton(tr("&Cancel")); + connect(cancelButton, &QPushButton::clicked, this, &BanDialog::reject); + + QHBoxLayout *buttonLayout = new QHBoxLayout; + buttonLayout->addStretch(); + buttonLayout->addWidget(okButton); + buttonLayout->addWidget(cancelButton); + + QVBoxLayout *vbox = new QVBoxLayout; + vbox->addWidget(banTypeGroupBox); + vbox->addWidget(durationGroupBox); + vbox->addWidget(reasonLabel); + vbox->addWidget(reasonEdit); + vbox->addWidget(visibleReasonLabel); + vbox->addWidget(visibleReasonEdit); + vbox->addWidget(deleteMessages); + vbox->addLayout(buttonLayout); + + setLayout(vbox); + setWindowTitle(tr("Ban user from server")); +} + +WarningDialog::WarningDialog(const QString userName, const QString clientID, QWidget *parent) : QDialog(parent) +{ + setAttribute(Qt::WA_DeleteOnClose); + descriptionLabel = new QLabel(tr("Which warning would you like to send?")); + nameWarning = new QLineEdit(userName); + nameWarning->setMaxLength(MAX_NAME_LENGTH); + warnClientID = new QLineEdit(clientID); + warnClientID->setMaxLength(MAX_NAME_LENGTH); + warningOption = new QComboBox(); + warningOption->addItem("", ""); + + deleteMessages = new QCheckBox(tr("Redact all messages from this user in all rooms")); + + QPushButton *okButton = new QPushButton(tr("&OK")); + okButton->setAutoDefault(true); + connect(okButton, &QPushButton::clicked, this, &WarningDialog::okClicked); + QPushButton *cancelButton = new QPushButton(tr("&Cancel")); + connect(cancelButton, &QPushButton::clicked, this, &WarningDialog::reject); + + QHBoxLayout *buttonLayout = new QHBoxLayout; + buttonLayout->addStretch(); + buttonLayout->addWidget(okButton); + buttonLayout->addWidget(cancelButton); + + QVBoxLayout *vbox = new QVBoxLayout; + vbox->addWidget(descriptionLabel); + vbox->addWidget(nameWarning); + vbox->addWidget(warningOption); + vbox->addWidget(deleteMessages); + vbox->addLayout(buttonLayout); + setLayout(vbox); + setWindowTitle(tr("Warn user for misconduct")); +} + +void WarningDialog::okClicked() +{ + if (nameWarning->text().simplified().isEmpty()) { + QMessageBox::critical(this, tr("Error"), + tr("User name to send a warning to can not be blank, please specify a user to warn.")); + return; + } + + if (warningOption->currentData().toString().simplified().isEmpty()) { + QMessageBox::critical(this, tr("Error"), + tr("Warning to use can not be blank, please select a valid warning to send.")); + return; + } + + accept(); +} + +QString WarningDialog::getName() const +{ + return nameWarning->text().simplified(); +} + +QString WarningDialog::getWarnID() const +{ + return warnClientID->text().simplified(); +} + +QString WarningDialog::getReason() const +{ + return warningOption->currentData().toString().simplified(); +} + +int WarningDialog::getDeleteMessages() const +{ + return deleteMessages->isChecked() ? -1 : 0; +} + +void WarningDialog::addWarningOption(const QString warning, int startingIl) +{ + if (startingIl > 1) { + warningOption->addItem(tr("%1 (IL %2)").arg(warning).arg(startingIl), warning); + } else { + warningOption->addItem(warning, warning); + } +} + +void BanDialog::okClicked() +{ + if (!nameBanCheckBox->isChecked() && !ipBanCheckBox->isChecked() && !idBanCheckBox->isChecked()) { + QMessageBox::critical(this, tr("Error"), + tr("You have to select a name-based, IP-based, clientId based, or some combination of " + "the three to place a ban.")); + return; + } + + if (nameBanCheckBox->isChecked()) { + if (nameBanEdit->text().simplified() == "") { + QMessageBox::critical(this, tr("Error"), + tr("You must have a value in the name ban when selecting the name ban checkbox.")); + return; + } + } + + if (ipBanCheckBox->isChecked()) { + if (ipBanEdit->text().simplified() == "") { + QMessageBox::critical(this, tr("Error"), + tr("You must have a value in the ip ban when selecting the ip ban checkbox.")); + return; + } + } + + if (idBanCheckBox->isChecked()) { + if (idBanEdit->text().simplified() == "") { + QMessageBox::critical( + this, tr("Error"), + tr("You must have a value in the clientid ban when selecting the clientid ban checkbox.")); + return; + } + } + + accept(); +} + +void BanDialog::enableTemporaryEdits(bool enabled) +{ + daysLabel->setEnabled(enabled); + daysEdit->setEnabled(enabled); + hoursLabel->setEnabled(enabled); + hoursEdit->setEnabled(enabled); + minutesLabel->setEnabled(enabled); + minutesEdit->setEnabled(enabled); +} + +QString BanDialog::getBanId() const +{ + return idBanCheckBox->isChecked() ? idBanEdit->text() : QString(); +} + +QString BanDialog::getBanName() const +{ + return nameBanCheckBox->isChecked() ? nameBanEdit->text() : QString(); +} + +QString BanDialog::getBanIP() const +{ + return ipBanCheckBox->isChecked() ? ipBanEdit->text() : QString(); +} + +int BanDialog::getMinutes() const +{ + return permanentRadio->isChecked() ? 0 + : (daysEdit->value() * 24 * 60 + hoursEdit->value() * 60 + minutesEdit->value()); +} + +QString BanDialog::getReason() const +{ + return reasonEdit->toPlainText(); +} + +QString BanDialog::getVisibleReason() const +{ + return visibleReasonEdit->toPlainText(); +} + +int BanDialog::getDeleteMessages() const +{ + return deleteMessages->isChecked() ? -1 : 0; +} + +AdminNotesDialog::AdminNotesDialog(const QString &_userName, const QString &_notes, QWidget *_parent) + : QDialog(_parent), userName(_userName) +{ + setAttribute(Qt::WA_DeleteOnClose); + + auto *updateButton = new QPushButton(tr("Update Notes")); + updateButton->setEnabled(false); + connect(updateButton, &QPushButton::clicked, this, &AdminNotesDialog::accept); + + notes = new QPlainTextEdit(_notes); + notes->setMinimumWidth(500); + connect(notes, &QPlainTextEdit::textChanged, this, [=]() { updateButton->setEnabled(true); }); + + auto *vbox = new QVBoxLayout; + vbox->addWidget(notes); + vbox->addWidget(updateButton); + + setLayout(vbox); + setWindowTitle(tr("Admin Notes for %1").arg(_userName)); +} + +QString AdminNotesDialog::getNotes() const +{ + return notes->toPlainText(); +} namespace UserListRoles { @@ -299,12 +613,7 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, userTree->hideColumn(3); connect(userTree, &QTreeWidget::itemActivated, this, &UserListWidget::userClicked); userTree->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - // QTreeWidget enables stretchLastSection by default. Left on, the hidden - // last section absorbs viewport resizes, the Stretch sections never - // redistribute, and the header keeps a stale length past the viewport — - // an invisible horizontal pan range under ScrollBarAlwaysOff. Disable it - // so the explicit resize modes in applyDisplayMode() own the geometry. - userTree->header()->setStretchLastSection(false); + userTree->header()->setStretchLastSection(true); // Always create timers so callers never segfault on a null deref; // showPopupForUser / hidePopup already guard against a null userInfoPopup. @@ -345,11 +654,6 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, &cardArtProvider->cache(), &cardArtParamsMap, window()); // parented to main window so it floats above siblings - // The invite availability is scoped to the room this list belongs to, - // and gated on the room's buddy-only setting for the hovered user. - userInfoPopup->setGameInviteAvailable( - [this](const QString &userName) { return userContextMenu->hasGameInviteLink(userName); }); - userInfoPopup->hide(); userInfoPopup->setWindowOpacity(0.0); userInfoPopup->installEventFilter(this); @@ -626,24 +930,13 @@ void UserListWidget::applyDisplayMode() { const bool styled = SettingsCache::instance().appearance().getStyleUserList(); - // Both modes must keep the header length at the viewport width: with - // ScrollBarAlwaysOff a nonzero horizontal range is invisible but still - // pans via trackpad gestures, which reads as janky random drift. if (styled) { userTree->header()->setSectionResizeMode(0, QHeaderView::Stretch); userTree->hideColumn(1); userTree->hideColumn(2); userTree->hideColumn(3); } else { - // Bounded widths instead of ResizeToContents: content sizing measures - // the FULL text width while the delegate elides afterwards, so long - // names widened the header past the viewport. Fixed icon columns plus - // a stretched name column keep the range at zero, eliding trims. - userTree->header()->setSectionResizeMode(0, QHeaderView::Fixed); - userTree->header()->resizeSection(0, 24); - userTree->header()->setSectionResizeMode(1, QHeaderView::Fixed); - userTree->header()->resizeSection(1, 22); - userTree->header()->setSectionResizeMode(2, QHeaderView::Stretch); + userTree->header()->setSectionResizeMode(QHeaderView::ResizeToContents); userTree->showColumn(1); userTree->showColumn(2); userTree->hideColumn(3); @@ -667,8 +960,6 @@ void UserListWidget::connectPopupSignals() // Wire all action signals to UserContextMenu::exec*() connect(userInfoPopup, &UserInfoPopup::chatRequested, userContextMenu, &UserContextMenu::execChat); - connect(userInfoPopup, &UserInfoPopup::inviteRequested, this, - [this](const QString &userName) { userContextMenu->execInvite(userName); }); connect(userInfoPopup, &UserInfoPopup::detailsRequested, userContextMenu, &UserContextMenu::execDetails); connect(userInfoPopup, &UserInfoPopup::showGamesRequested, userContextMenu, &UserContextMenu::execShowGames); connect(userInfoPopup, &UserInfoPopup::addBuddyRequested, userContextMenu, &UserContextMenu::execAddToBuddy); diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.h b/cockatrice/src/interface/widgets/server/user/user_list_widget.h index 412271160..5fed54573 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.h +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.h @@ -16,7 +16,9 @@ #include "user_list_painter.h" #include +#include #include +#include #include #include #include @@ -29,12 +31,80 @@ class QTreeWidget; class ServerInfo_User; class AbstractClient; class TabSupervisor; +class QLabel; +class QCheckBox; +class QSpinBox; +class QRadioButton; +class QPlainTextEdit; class Response; class CommandContainer; class UserContextMenu; class UserListWidget; class QShowEvent; +class BanDialog : public QDialog +{ + Q_OBJECT +private: + QLabel *daysLabel, *hoursLabel, *minutesLabel; + QCheckBox *nameBanCheckBox, *ipBanCheckBox, *idBanCheckBox, *deleteMessages; + QLineEdit *nameBanEdit, *ipBanEdit, *idBanEdit; + QSpinBox *daysEdit, *hoursEdit, *minutesEdit; + QRadioButton *permanentRadio, *temporaryRadio; + QPlainTextEdit *reasonEdit, *visibleReasonEdit; +private slots: + void okClicked(); + void enableTemporaryEdits(bool enabled); + +public: + explicit BanDialog(const ServerInfo_User &info, QWidget *parent = nullptr); + [[nodiscard]] QString getBanName() const; + [[nodiscard]] QString getBanIP() const; + [[nodiscard]] QString getBanId() const; + [[nodiscard]] int getMinutes() const; + [[nodiscard]] QString getReason() const; + [[nodiscard]] QString getVisibleReason() const; + [[nodiscard]] int getDeleteMessages() const; +}; + +class WarningDialog : public QDialog +{ + Q_OBJECT +private: + QLabel *descriptionLabel; + QLineEdit *nameWarning; + QComboBox *warningOption; + QLineEdit *warnClientID; + QCheckBox *deleteMessages; +private slots: + void okClicked(); + +public: + WarningDialog(const QString userName, const QString clientID, QWidget *parent = nullptr); + [[nodiscard]] QString getName() const; + [[nodiscard]] QString getWarnID() const; + [[nodiscard]] QString getReason() const; + [[nodiscard]] int getDeleteMessages() const; + void addWarningOption(const QString warning, int startingIl = 1); +}; + +class AdminNotesDialog : public QDialog +{ + Q_OBJECT + +private: + QString userName; + QPlainTextEdit *notes; + +public: + explicit AdminNotesDialog(const QString &_userName, const QString &_notes, QWidget *_parent = nullptr); + [[nodiscard]] QString getName() const + { + return userName; + } + [[nodiscard]] QString getNotes() const; +}; + class UserListItemDelegate : public QStyledItemDelegate { QTreeWidget *tree; diff --git a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp index c8494f095..9272c36d9 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp @@ -5,7 +5,6 @@ #include "../../client/settings/card_counter_settings.h" #include "../../palette_editor/palette_editor_dialog.h" #include "../dialogs/override_printing_warning.h" -#include "../general/home_tab_button_color.h" #include "../interface/theme_manager.h" #include "../interface/widgets/general/background_sources.h" #include "../playmat/playmat_collection_dialog.h" @@ -132,14 +131,6 @@ AppearanceSettingsPage::AppearanceSettingsPage() connect(&homeTabDisplayCardNameCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.appearance(), &AppearanceSettings::setHomeTabDisplayCardName); - for (const auto &entry : HomeTabButtonColor::all()) { - homeTabButtonColorSourceBox.addItem(QObject::tr(entry.trKey)); - } - - homeTabButtonColorSourceBox.setCurrentIndex(settings.appearance().getHomeTabButtonColorSourceIndex()); - connect(&homeTabButtonColorSourceBox, QOverload::of(&QComboBox::currentIndexChanged), &settings.appearance(), - &AppearanceSettings::setHomeTabButtonColorSourceIndex); - updateHomeTabSettingsVisibility(); auto *homeTabGrid = new QGridLayout; @@ -148,54 +139,10 @@ AppearanceSettingsPage::AppearanceSettingsPage() homeTabGrid->addWidget(&homeTabBackgroundShuffleFrequencyLabel, 1, 0); homeTabGrid->addWidget(&homeTabBackgroundShuffleFrequencySpinBox, 1, 1); homeTabGrid->addWidget(&homeTabDisplayCardNameCheckBox, 2, 0, 1, 2); - homeTabGrid->addWidget(&homeTabButtonColorSourceLabel, 3, 0); - homeTabGrid->addWidget(&homeTabButtonColorSourceBox, 3, 1); homeTabGroupBox = new QGroupBox; homeTabGroupBox->setLayout(homeTabGrid); - // Playmat settings - playmatVisibilityCombo.addItem(tr("Show all playmats"), PlaymatVisibilityAll); - playmatVisibilityCombo.addItem(tr("Show own playmat only"), PlaymatVisibilityOwnOnly); - playmatVisibilityCombo.addItem(tr("Don't use playmats"), PlaymatVisibilityNone); - int visIdx = playmatVisibilityCombo.findData(settings.userInterface().getPlaymatVisibility()); - if (visIdx >= 0) { - playmatVisibilityCombo.setCurrentIndex(visIdx); - } - connect(&playmatVisibilityCombo, qOverload(&QComboBox::currentIndexChanged), this, [this](int index) { - SettingsCache::instance().userInterface().setPlaymatVisibility(playmatVisibilityCombo.itemData(index).toInt()); - }); - playmatVisibilityLabel.setBuddy(&playmatVisibilityCombo); - - // Playmat mode: Override / Fallback / Deck-only - playmatModeCombo.addItem(tr("Override deck playmat"), PlaymatModeOverrideDeck); - playmatModeCombo.addItem(tr("Fallback if deck has none"), PlaymatModeFallback); - playmatModeCombo.addItem(tr("Deck only, ignore collection"), PlaymatModeDeckOnly); - int modeIdx = playmatModeCombo.findData(settings.userInterface().getPlaymatMode()); - if (modeIdx >= 0) { - playmatModeCombo.setCurrentIndex(modeIdx); - } - connect(&playmatModeCombo, qOverload(&QComboBox::currentIndexChanged), this, [this](int index) { - SettingsCache::instance().userInterface().setPlaymatMode(playmatModeCombo.itemData(index).toInt()); - }); - playmatModeLabel.setBuddy(&playmatModeCombo); - - // User-level playmat settings: fallback collection. - connect(&playmatDefaultEditButton, &QPushButton::clicked, this, - &AppearanceSettingsPage::openPlaymatCollectionDialog); - - auto *playmatGrid = new QGridLayout; - playmatGrid->addWidget(&playmatVisibilityLabel, 0, 0, 1, 1); - playmatGrid->addWidget(&playmatVisibilityCombo, 0, 1, 1, 1); - playmatGrid->addWidget(&playmatModeLabel, 1, 0, 1, 1); - playmatGrid->addWidget(&playmatModeCombo, 1, 1, 1, 1); - playmatGrid->addWidget(&playmatDefaultLabel, 2, 0, 1, 1); - playmatGrid->addWidget(&playmatDefaultEditButton, 2, 1, 1, 1); - - playmatGroupBox = new QGroupBox; - playmatGroupBox->setLayout(playmatGrid); - - // Styling settings styleUserListCheckBox.setChecked(settings.appearance().getStyleUserList()); connect(&styleUserListCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.appearance(), &AppearanceSettings::setStyleUserList); @@ -301,6 +248,7 @@ AppearanceSettingsPage::AppearanceSettingsPage() cardLayoutGroupBox->setLayout(cardLayoutGrid); // Card counter colors + auto *cardCounterColorsLayout = new QGridLayout; cardCounterColorsLayout->setColumnStretch(1, 1); cardCounterColorsLayout->setColumnStretch(3, 1); @@ -380,6 +328,47 @@ AppearanceSettingsPage::AppearanceSettingsPage() tableGroupBox = new QGroupBox; tableGroupBox->setLayout(tableGrid); + // Playmat settings + playmatVisibilityCombo.addItem(tr("Show all playmats"), PlaymatVisibilityAll); + playmatVisibilityCombo.addItem(tr("Show own playmat only"), PlaymatVisibilityOwnOnly); + playmatVisibilityCombo.addItem(tr("Don't use playmats"), PlaymatVisibilityNone); + int visIdx = playmatVisibilityCombo.findData(settings.userInterface().getPlaymatVisibility()); + if (visIdx >= 0) { + playmatVisibilityCombo.setCurrentIndex(visIdx); + } + connect(&playmatVisibilityCombo, qOverload(&QComboBox::currentIndexChanged), this, [this](int index) { + SettingsCache::instance().userInterface().setPlaymatVisibility(playmatVisibilityCombo.itemData(index).toInt()); + }); + playmatVisibilityLabel.setBuddy(&playmatVisibilityCombo); + + // Playmat mode: Override / Fallback / Deck-only + playmatModeCombo.addItem(tr("Override deck playmat"), PlaymatModeOverrideDeck); + playmatModeCombo.addItem(tr("Fallback if deck has none"), PlaymatModeFallback); + playmatModeCombo.addItem(tr("Deck only, ignore collection"), PlaymatModeDeckOnly); + int modeIdx = playmatModeCombo.findData(settings.userInterface().getPlaymatMode()); + if (modeIdx >= 0) { + playmatModeCombo.setCurrentIndex(modeIdx); + } + connect(&playmatModeCombo, qOverload(&QComboBox::currentIndexChanged), this, [this](int index) { + SettingsCache::instance().userInterface().setPlaymatMode(playmatModeCombo.itemData(index).toInt()); + }); + playmatModeLabel.setBuddy(&playmatModeCombo); + + // User-level playmat settings: fallback collection. + connect(&playmatDefaultEditButton, &QPushButton::clicked, this, + &AppearanceSettingsPage::openPlaymatCollectionDialog); + + auto *playmatGrid = new QGridLayout; + playmatGrid->addWidget(&playmatVisibilityLabel, 0, 0, 1, 1); + playmatGrid->addWidget(&playmatVisibilityCombo, 0, 1, 1, 1); + playmatGrid->addWidget(&playmatModeLabel, 1, 0, 1, 1); + playmatGrid->addWidget(&playmatModeCombo, 1, 1, 1, 1); + playmatGrid->addWidget(&playmatDefaultLabel, 2, 0, 1, 1); + playmatGrid->addWidget(&playmatDefaultEditButton, 2, 1, 1, 1); + + playmatGroupBox = new QGroupBox; + playmatGroupBox->setLayout(playmatGrid); + // putting it all together auto *mainLayout = new QVBoxLayout; mainLayout->addWidget(themeGroupBox); @@ -508,15 +497,6 @@ 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")); - homeTabButtonColorSourceLabel.setText(tr("Home tab button color:")); - homeTabButtonColorSourceBox.setToolTip( - tr("Automatic: extract from background if present, otherwise use theme default")); - - playmatGroupBox->setTitle(tr("Playmat settings")); - playmatVisibilityLabel.setText(tr("Playmat visibility:")); - playmatModeLabel.setText(tr("Default collection behavior:")); - playmatDefaultLabel.setText(tr("Default playmat collection:")); - playmatDefaultEditButton.setText(tr("Edit...")); stylingGroupBox->setTitle(tr("Styling settings")); styleUserListCheckBox.setText(tr("Style user list")); @@ -560,4 +540,9 @@ void AppearanceSettingsPage::retranslateUi() tableGroupBox->setTitle(tr("Table grid layout")); invertVerticalCoordinateCheckBox.setText(tr("Invert vertical coordinate")); minPlayersForMultiColumnLayoutLabel.setText(tr("Minimum player count for multi-column layout:")); + playmatGroupBox->setTitle(tr("Playmat settings")); + playmatVisibilityLabel.setText(tr("Playmat visibility:")); + playmatModeLabel.setText(tr("Default collection behavior:")); + playmatDefaultLabel.setText(tr("Default playmat collection:")); + playmatDefaultEditButton.setText(tr("Edit...")); } diff --git a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h index 8db71ff8f..28abbd537 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h @@ -35,64 +35,51 @@ private: QLabel styleComboLabel; QComboBox styleCombo; QPushButton editPaletteButton; - QLabel homeTabBackgroundSourceLabel; QComboBox homeTabBackgroundSourceBox; QLabel homeTabBackgroundShuffleFrequencyLabel; QSpinBox homeTabBackgroundShuffleFrequencySpinBox; QCheckBox homeTabDisplayCardNameCheckBox; - QLabel homeTabButtonColorSourceLabel; - QComboBox homeTabButtonColorSourceBox; - - QLabel playmatVisibilityLabel; - QComboBox playmatVisibilityCombo; - QLabel playmatModeLabel; - QComboBox playmatModeCombo; - QLabel playmatDefaultLabel; - QPushButton playmatDefaultEditButton; - QCheckBox styleUserListCheckBox; - QCheckBox showShortcutsCheckBox; QCheckBox showGameSelectorFilterToolbarCheckBox; - + QLabel minPlayersForMultiColumnLayoutLabel; + QLabel maxFontSizeForCardsLabel; QCheckBox overrideAllCardArtWithPersonalPreferenceCheckBox; QCheckBox bumpSetsWithCardsInDeckToTopCheckBox; - QCheckBox displayCardNamesCheckBox; QCheckBox autoRotateSidewaysLayoutCardsCheckBox; QCheckBox cardScalingCheckBox; QCheckBox roundCardCornersCheckBox; - QLabel maxFontSizeForCardsLabel; - QSpinBox maxFontSizeForCardsEdit; - QLabel verticalCardOverlapPercentLabel; QSpinBox verticalCardOverlapPercentBox; QLabel cardViewInitialRowsMaxLabel; QSpinBox cardViewInitialRowsMaxBox; QLabel cardViewExpandedRowsMaxLabel; QSpinBox cardViewExpandedRowsMaxBox; - - QList cardCounterNames; - QCheckBox horizontalHandCheckBox; QCheckBox leftJustifiedHandCheckBox; - QCheckBox invertVerticalCoordinateCheckBox; - QLabel minPlayersForMultiColumnLayoutLabel; - QSpinBox minPlayersForMultiColumnLayoutEdit; - + QLabel playmatVisibilityLabel; + QComboBox playmatVisibilityCombo; + QLabel playmatModeLabel; + QComboBox playmatModeCombo; + QLabel playmatDefaultLabel; + QPushButton playmatDefaultEditButton; QGroupBox *themeGroupBox; QGroupBox *homeTabGroupBox; - QGroupBox *playmatGroupBox; QGroupBox *stylingGroupBox; QGroupBox *menuGroupBox; QGroupBox *printingsGroupBox; QGroupBox *cardsGroupBox; QGroupBox *cardLayoutGroupBox; - QGroupBox *cardCountersGroupBox; QGroupBox *handGroupBox; + QGroupBox *playmatGroupBox; QGroupBox *tableGroupBox; + QGroupBox *cardCountersGroupBox; + QList cardCounterNames; + QSpinBox minPlayersForMultiColumnLayoutEdit; + QSpinBox maxFontSizeForCardsEdit; public: AppearanceSettingsPage(); diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp index 62b06fb60..a293660f9 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp @@ -425,28 +425,29 @@ void GeneralSettingsPage::updateStartupServerControlsVisibility() void GeneralSettingsPage::retranslateUi() { - const auto &settings = SettingsCache::instance(); - languageGroupBox->setTitle(tr("Language settings")); languageLabel.setText(tr("Language:")); - advertiseTranslationPageLabel.setText( - QString("%2").arg(WIKI_TRANSLATION_FAQ).arg(tr("How to help with translations"))); versionGroupBox->setTitle(tr("Version settings")); + cardDatabaseGroupBox->setTitle(tr("Card database")); + startupGroupBox->setTitle(tr("Startup settings")); + + if (SettingsCache::instance().getIsPortableBuild()) { + pathsGroupBox->setTitle(tr("Paths (editing disabled in portable mode)")); + } else { + pathsGroupBox->setTitle(tr("Paths")); + } + advertiseTranslationPageLabel.setText( + QString("%2").arg(WIKI_TRANSLATION_FAQ).arg(tr("How to help with translations"))); + deckPathLabel.setText(tr("Decks directory:")); + filtersPathLabel.setText(tr("Filters directory:")); + replaysPathLabel.setText(tr("Replays directory:")); + picsPathLabel.setText(tr("Pictures directory:")); + cardDatabasePathLabel.setText(tr("Card database:")); + customCardDatabasePathLabel.setText(tr("Custom database directory:")); + tokenDatabasePathLabel.setText(tr("Token database:")); updateReleaseChannelLabel.setText(tr("Update channel")); startupUpdateCheckCheckBox.setText(tr("Check for client updates on startup")); - updateNotificationCheckBox.setText(tr("Notify if a feature supported by the server is missing in my client")); - newVersionOracleCheckBox.setText(tr("Automatically run Oracle when running a new version of Cockatrice")); - - // We can't change the strings after they're put into the QComboBox, so this is our workaround - int oldIndex = updateReleaseChannelBox.currentIndex(); - updateReleaseChannelBox.clear(); - for (ReleaseChannel *chan : settings.getUpdateReleaseChannels()) { - updateReleaseChannelBox.addItem(tr(chan->getName().toUtf8())); - } - updateReleaseChannelBox.setCurrentIndex(oldIndex); - - cardDatabaseGroupBox->setTitle(tr("Card database")); startupCardUpdateCheckBehaviorLabel.setText(tr("Check for card database updates on startup")); startupCardUpdateCheckBehaviorSelector.setItemText(startupCardUpdateCheckBehaviorIndexNone, tr("Don't check")); startupCardUpdateCheckBehaviorSelector.setItemText(startupCardUpdateCheckBehaviorIndexPrompt, @@ -455,13 +456,8 @@ void GeneralSettingsPage::retranslateUi() tr("Always update in the background")); cardUpdateCheckIntervalLabel.setText(tr("Check for card database updates every")); cardUpdateCheckIntervalSpinBox.setSuffix(tr(" days")); - - QDate lastCheckDate = settings.updates().getLastCardUpdateCheck(); - int daysAgo = lastCheckDate.daysTo(QDate::currentDate()); - lastCardUpdateCheckDateLabel.setText( - tr("Last update check on %1 (%2 days ago)").arg(lastCheckDate.toString()).arg(daysAgo)); - - startupGroupBox->setTitle(tr("Startup settings")); + updateNotificationCheckBox.setText(tr("Notify if a feature supported by the server is missing in my client")); + newVersionOracleCheckBox.setText(tr("Automatically run Oracle when running a new version of Cockatrice")); showTipsOnStartup.setText(tr("Show tips on startup")); startupTabLabel.setText(tr("Startup tab:")); startupTabSelector.setItemText(StartupTab::StartupTabHome, tr("Home")); @@ -477,18 +473,21 @@ void GeneralSettingsPage::retranslateUi() startupServerLabel.setText(tr("Server:")); startupRoomLabel.setText(tr("Room:")); startupRoomNameEdit->setPlaceholderText(tr("Room name")); - - if (settings.getIsPortableBuild()) { - pathsGroupBox->setTitle(tr("Paths (editing disabled in portable mode)")); - } else { - pathsGroupBox->setTitle(tr("Paths")); - } - deckPathLabel.setText(tr("Decks directory:")); - filtersPathLabel.setText(tr("Filters directory:")); - replaysPathLabel.setText(tr("Replays directory:")); - picsPathLabel.setText(tr("Pictures directory:")); - cardDatabasePathLabel.setText(tr("Card database:")); - customCardDatabasePathLabel.setText(tr("Custom database directory:")); - tokenDatabasePathLabel.setText(tr("Token database:")); resetAllPathsButton->setText(tr("Reset all paths")); -} + + const auto &settings = SettingsCache::instance(); + + QDate lastCheckDate = settings.updates().getLastCardUpdateCheck(); + int daysAgo = lastCheckDate.daysTo(QDate::currentDate()); + + lastCardUpdateCheckDateLabel.setText( + tr("Last update check on %1 (%2 days ago)").arg(lastCheckDate.toString()).arg(daysAgo)); + + // We can't change the strings after they're put into the QComboBox, so this is our workaround + int oldIndex = updateReleaseChannelBox.currentIndex(); + updateReleaseChannelBox.clear(); + for (ReleaseChannel *chan : settings.getUpdateReleaseChannels()) { + updateReleaseChannelBox.addItem(tr(chan->getName().toUtf8())); + } + updateReleaseChannelBox.setCurrentIndex(oldIndex); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h index e0c1a47bf..fbe70a5a4 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h @@ -20,9 +20,6 @@ public: GeneralSettingsPage(); void retranslateUi() override; - static QStringList findQmFiles(); - static QString languageName(const QString &lang); - private slots: void deckPathButtonClicked(); void filtersPathButtonClicked(); @@ -36,43 +33,15 @@ private slots: void updateStartupServerControlsVisibility(); private: + QStringList findQmFiles(); + QString languageName(const QString &lang); + QGroupBox *languageGroupBox; QGroupBox *versionGroupBox; QGroupBox *cardDatabaseGroupBox; QGroupBox *startupGroupBox; QGroupBox *pathsGroupBox; - QLabel languageLabel; - QComboBox languageBox; - QLabel advertiseTranslationPageLabel; - - QLabel updateReleaseChannelLabel; - QComboBox updateReleaseChannelBox; - QCheckBox startupUpdateCheckCheckBox; - QCheckBox updateNotificationCheckBox; - QCheckBox newVersionOracleCheckBox; - - QLabel startupCardUpdateCheckBehaviorLabel; - QComboBox startupCardUpdateCheckBehaviorSelector; - QLabel cardUpdateCheckIntervalLabel; - QSpinBox cardUpdateCheckIntervalSpinBox; - QLabel lastCardUpdateCheckDateLabel; - - QCheckBox showTipsOnStartup; - QLabel startupTabLabel; - QComboBox startupTabSelector; - QLabel startupServerLabel; - QComboBox startupServerSelector; - QLabel startupRoomLabel; - QLineEdit *startupRoomNameEdit; - - QLabel deckPathLabel; - QLabel filtersPathLabel; - QLabel replaysPathLabel; - QLabel picsPathLabel; - QLabel cardDatabasePathLabel; - QLabel customCardDatabasePathLabel; - QLabel tokenDatabasePathLabel; QLineEdit *deckPathEdit; QLineEdit *filtersPathEdit; QLineEdit *replaysPathEdit; @@ -82,6 +51,33 @@ private: QLineEdit *tokenDatabasePathEdit; QPushButton *resetAllPathsButton; QLabel *allPathsResetLabel; + QComboBox languageBox; + QCheckBox startupUpdateCheckCheckBox; + QLabel startupCardUpdateCheckBehaviorLabel; + QComboBox startupCardUpdateCheckBehaviorSelector; + QLabel cardUpdateCheckIntervalLabel; + QSpinBox cardUpdateCheckIntervalSpinBox; + QLabel lastCardUpdateCheckDateLabel; + QCheckBox updateNotificationCheckBox; + QCheckBox newVersionOracleCheckBox; + QComboBox updateReleaseChannelBox; + QLabel languageLabel; + QLabel deckPathLabel; + QLabel filtersPathLabel; + QLabel replaysPathLabel; + QLabel picsPathLabel; + QLabel cardDatabasePathLabel; + QLabel customCardDatabasePathLabel; + QLabel tokenDatabasePathLabel; + QLabel updateReleaseChannelLabel; + QLabel advertiseTranslationPageLabel; + QCheckBox showTipsOnStartup; + QLabel startupTabLabel; + QComboBox startupTabSelector; + QLabel startupServerLabel; + QComboBox startupServerSelector; + QLabel startupRoomLabel; + QLineEdit *startupRoomNameEdit; }; #endif // COCKATRICE_GENERAL_SETTINGS_PAGE_H diff --git a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp index 2c6e062da..182e75aac 100644 --- a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp @@ -20,7 +20,26 @@ enum visualDeckStoragePromptForConversionIndex UserInterfaceSettingsPage::UserInterfaceSettingsPage() { - // general settings + // general settings and notification settings + notificationsEnabledCheckBox.setChecked(SettingsCache::instance().userInterface().getNotificationsEnabled()); + connect(¬ificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setNotificationsEnabled); + connect(¬ificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, this, + &UserInterfaceSettingsPage::setNotificationEnabled); + + specNotificationsEnabledCheckBox.setChecked( + SettingsCache::instance().userInterface().getSpectatorNotificationsEnabled()); + specNotificationsEnabledCheckBox.setEnabled(SettingsCache::instance().userInterface().getNotificationsEnabled()); + connect(&specNotificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setSpectatorNotificationsEnabled); + + buddyConnectNotificationsEnabledCheckBox.setChecked( + SettingsCache::instance().userInterface().getBuddyConnectNotificationsEnabled()); + buddyConnectNotificationsEnabledCheckBox.setEnabled( + SettingsCache::instance().userInterface().getNotificationsEnabled()); + connect(&buddyConnectNotificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, + &SettingsCache::instance().userInterface(), &InterfaceSettings::setBuddyConnectNotificationsEnabled); + doubleClickToPlayCheckBox.setChecked(SettingsCache::instance().userInterface().getDoubleClickToPlay()); connect(&doubleClickToPlayCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), &InterfaceSettings::setDoubleClickToPlay); @@ -84,26 +103,6 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() generalGroupBox = new QGroupBox; generalGroupBox->setLayout(generalGrid); - // notification settings - notificationsEnabledCheckBox.setChecked(SettingsCache::instance().userInterface().getNotificationsEnabled()); - connect(¬ificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), - &InterfaceSettings::setNotificationsEnabled); - connect(¬ificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, this, - &UserInterfaceSettingsPage::setNotificationEnabled); - - specNotificationsEnabledCheckBox.setChecked( - SettingsCache::instance().userInterface().getSpectatorNotificationsEnabled()); - specNotificationsEnabledCheckBox.setEnabled(SettingsCache::instance().userInterface().getNotificationsEnabled()); - connect(&specNotificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), - &InterfaceSettings::setSpectatorNotificationsEnabled); - - buddyConnectNotificationsEnabledCheckBox.setChecked( - SettingsCache::instance().userInterface().getBuddyConnectNotificationsEnabled()); - buddyConnectNotificationsEnabledCheckBox.setEnabled( - SettingsCache::instance().userInterface().getNotificationsEnabled()); - connect(&buddyConnectNotificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, - &SettingsCache::instance().userInterface(), &InterfaceSettings::setBuddyConnectNotificationsEnabled); - auto *notificationsGrid = new QGridLayout; notificationsGrid->addWidget(¬ificationsEnabledCheckBox, 0, 0); notificationsGrid->addWidget(&specNotificationsEnabledCheckBox, 1, 0); @@ -356,7 +355,6 @@ void UserInterfaceSettingsPage::retranslateUi() notificationsEnabledCheckBox.setText(tr("Enable notifications in taskbar")); specNotificationsEnabledCheckBox.setText(tr("Notify in the taskbar for game events while you are spectating")); buddyConnectNotificationsEnabledCheckBox.setText(tr("Notify in the taskbar when users in your buddy list connect")); - animationGroupBox->setTitle(tr("Animation settings")); enableAllAnimationsButton.setText(tr("&Enable all animations")); disableAllAnimationsButton.setText(tr("&Disable all animations")); @@ -364,7 +362,6 @@ void UserInterfaceSettingsPage::retranslateUi() arrowDrawAnimationCheckBox.setText(tr("&Arrow draw animation")); lifeCounterAnimationsCheckBox.setText(tr("Life counter flash")); battlefieldFlashCheckBox.setText(tr("Battlefield flash on damage")); - deckEditorGroupBox->setTitle(tr("Deck editor/storage settings")); openDeckInNewTabCheckBox.setText(tr("Open deck in new tab by default")); visualDeckStorageInGameCheckBox.setText(tr("Use visual deck storage in game lobby")); @@ -400,8 +397,8 @@ void UserInterfaceSettingsPage::retranslateUi() 0, CommanderBracketNames::CommanderSpellbookBracketNames); commanderSpellbookIntegrationBracketNamingSelector.setItemText( 1, CommanderBracketNames::OfficialCommanderBracketNames); - commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setToolTip(CommanderBracketNames::Explainer); + commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setToolTip(CommanderBracketNames::Explainer); replayGroupBox->setTitle(tr("Replay settings")); rewindBufferingMsLabel.setText(tr("Buffer time for backwards skip via shortcut:")); rewindBufferingMsBox.setSuffix(" ms"); diff --git a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h index e8a30fb1f..0dc4cf4e8 100644 --- a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h @@ -23,6 +23,9 @@ private slots: void updateCommanderSpellbookUiState(); private: + QCheckBox notificationsEnabledCheckBox; + QCheckBox specNotificationsEnabledCheckBox; + QCheckBox buddyConnectNotificationsEnabledCheckBox; QCheckBox doubleClickToPlayCheckBox; QCheckBox clickPlaysAllSelectedCheckBox; QCheckBox playToStackCheckBox; @@ -34,18 +37,12 @@ private: QCheckBox showTotalSelectionCountCheckBox; QCheckBox useTearOffMenusCheckBox; QCheckBox keepGameChatFocusCheckBox; - - QCheckBox notificationsEnabledCheckBox; - QCheckBox specNotificationsEnabledCheckBox; - QCheckBox buddyConnectNotificationsEnabledCheckBox; - QPushButton enableAllAnimationsButton; QPushButton disableAllAnimationsButton; QCheckBox tapAnimationCheckBox; QCheckBox arrowDrawAnimationCheckBox; QCheckBox lifeCounterAnimationsCheckBox; QCheckBox battlefieldFlashCheckBox; - QCheckBox openDeckInNewTabCheckBox; QLabel visualDeckStoragePromptForConversionLabel; QComboBox visualDeckStoragePromptForConversionSelector; @@ -60,10 +57,8 @@ private: QLabel commanderSpellbookIntegrationUseOfficialBracketNamesLabel; QToolButton commanderSpellbookIntegrationUseOfficialBracketNamesExplainer; QComboBox commanderSpellbookIntegrationBracketNamingSelector; - QLabel rewindBufferingMsLabel; QSpinBox rewindBufferingMsBox; - QGroupBox *generalGroupBox; QGroupBox *notificationsGroupBox; QGroupBox *animationGroupBox; diff --git a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.h b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.h index a3cda2bfc..e1f255199 100644 --- a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.h +++ b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.h @@ -8,7 +8,6 @@ #ifndef TAB_GENERIC_DECK_EDITOR_H #define TAB_GENERIC_DECK_EDITOR_H -#include "../../deck_loader/deck_loader.h" #include "../interface/widgets/deck_editor/deck_editor_card_database_dock_widget.h" #include "../interface/widgets/deck_editor/deck_editor_card_info_dock_widget.h" #include "../interface/widgets/deck_editor/deck_editor_database_display_widget.h" diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index ed0ddaf06..f96c139b3 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -116,10 +116,9 @@ void CloseButton::paintEvent(QPaintEvent * /*event*/) } TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget *parent) - : QTabWidget(parent), userInfo(nullptr), client(_client), tabsMenu(tabsMenu), tabHome(nullptr), - tabVisualDeckStorage(nullptr), tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(nullptr), - tabReplays(nullptr), tabAdmin(nullptr), tabCardArtRules(nullptr), tabLog(nullptr), tabReport(nullptr), - tabModeration(nullptr), isLocalGame(false) + : QTabWidget(parent), userInfo(nullptr), client(_client), tabsMenu(tabsMenu), tabVisualDeckStorage(nullptr), + tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(nullptr), tabReplays(nullptr), tabAdmin(nullptr), + tabLog(nullptr), tabReport(nullptr), tabModeration(nullptr), isLocalGame(false) { setElideMode(Qt::ElideRight); setMovable(true); @@ -246,7 +245,6 @@ void TabSupervisor::retranslateUi() aTabLog->setText(tr("Logs")); aTabReport->setText(tr("Report Queue")); aTabModeration->setText(tr("Moderation")); - aTabCardArtRules->setText(tr("Card Art Rules")); // tabs QList tabs; @@ -258,7 +256,6 @@ void TabSupervisor::retranslateUi() tabs.append(tabLog); tabs.append(tabReport); tabs.append(tabModeration); - tabs.append(tabCardArtRules); QMapIterator roomIterator(roomTabs); while (roomIterator.hasNext()) { tabs.append(roomIterator.next().value()); @@ -523,9 +520,7 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo) if (SettingsCache::instance().tabs().getTabModerationOpen()) { openTabModeration(); } - if (SettingsCache::instance().tabs().getTabCardArtRulesOpen()) { - openTabCardArtRules(); - } + openTabCardArtRules(); } retranslateUi(); @@ -587,9 +582,6 @@ void TabSupervisor::stop() if (tabModeration) { tabModeration->close(); } - if (tabCardArtRules) { - tabCardArtRules->close(); - } } QList tabsToDelete; @@ -783,7 +775,6 @@ void TabSupervisor::openTabAdmin() void TabSupervisor::actTabCardArtRules(bool checked) { - SettingsCache::instance().tabs().setTabCardArtRulesOpen(checked); if (checked && !tabCardArtRules) { openTabCardArtRules(); setCurrentWidget(tabCardArtRules); @@ -1100,8 +1091,7 @@ QList TabSupervisor::getGameInviteLinksForRoom(int roomId) con // The inviter may be in several games of the same room (hosting one and // spectating another, for example). Return every game so the caller can // let the user choose which one to invite to. - for (auto it = gameTabs.cbegin(); it != gameTabs.cend(); ++it) { - TabGame *tab = it.value(); + for (TabGame *tab : gameTabs) { GameMetaInfo *metaInfo = tab->getGame()->getGameMetaInfo(); if (metaInfo->proto().room_id() != roomId) { continue; diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp index 0f43893d3..209a30642 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp @@ -4,7 +4,6 @@ #include "../../../../client/settings/shortcuts_settings.h" #include "../../cards/card_info_display_widget.h" #include "../../deck_editor/deck_state_manager.h" -#include "../../deck_editor/deck_zone_dialog.h" #include "../../filters/filter_builder.h" #include "../../interface/pixel_map_generator.h" #include "../../interface/widgets/cards/card_info_frame_widget.h" @@ -85,7 +84,6 @@ void TabDeckEditorVisual::createCentralFrame() connect(tabContainer, &TabDeckEditorVisualTabWidget::printingSelectorRequested, this, &TabDeckEditorVisual::showPrintingSelector); connect(tabContainer, &TabDeckEditorVisualTabWidget::cardInfoRequested, this, &TabDeckEditorVisual::updateCardInfo); - tabContainer->visualDatabaseDisplay->setNewZoneCreator([this] { return createNewZone(); }); centralFrame->addWidget(tabContainer); setCentralWidget(centralWidget); @@ -271,19 +269,6 @@ bool TabDeckEditorVisual::actSaveDeckAs() return result; } -/** @brief Prompts for and creates a new custom deck zone. Returns the name of the created zone. */ -QString TabDeckEditorVisual::createNewZone() -{ - QString boardName; - const QString zoneName = DeckZoneDialog::promptForNewZone(this, {}, &boardName, [this](const QString &candidate) { - return deckStateManager->validateNewZoneName(candidate); - }); - if (!zoneName.isEmpty()) { - deckStateManager->createCustomZone(boardName, zoneName); - } - return zoneName; -} - /** @brief Refreshes keyboard shortcuts for this tab from settings. */ void TabDeckEditorVisual::refreshShortcuts() { diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h index fb09578c4..21335d2d0 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h @@ -165,12 +165,6 @@ public slots: */ bool actSaveDeckAs() override; - /** - * @brief Prompts for and creates a new custom deck zone. - * @return The name of the created zone, or an empty string if creation was cancelled. - */ - QString createNewZone(); - private: /** * @brief Sets the deck for this tab and selects the sub-tab to open on diff --git a/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp b/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp index 29bf2263e..6cf096cd4 100644 --- a/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp +++ b/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp @@ -1,6 +1,5 @@ #include "card_completer_delegate.h" -#include "../../pixel_map_generator.h" #include "../cards/additional_info/mana_cost_widget.h" #include @@ -85,6 +84,7 @@ QColor CardCompleterDelegate::accentForColors(const QString &colors) CardCompleterDelegate::CardCompleterDelegate(QObject *parent) : QStyledItemDelegate(parent) { + symbolCache.setMaxCost(64); setCodeCache.setMaxCost(64); } @@ -108,16 +108,36 @@ QSize CardCompleterDelegate::sizeHint(const QStyleOptionViewItem &option, const // Mana symbol painting // --------------------------------------------------------------------------- +const QPixmap *CardCompleterDelegate::cachedSymbolPixmap(const QString &symbol, int size) const +{ + const QString key = symbol + QString::number(size); + + if (symbolCache.contains(key)) { + return symbolCache[key]; + } + + QPixmap src(QString("theme:icons/mana/%1").arg(symbol)); + + if (!src.isNull()) { + auto *pm = new QPixmap(src.scaled(size, size, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + + symbolCache.insert(key, pm); + return pm; + } + + return nullptr; +} + // --------------------------------------------------------------------------- void CardCompleterDelegate::drawManaSymbol(QPainter *p, QPoint centre, const QString &symbol, int radius) const { const QRect pip(centre.x() - radius, centre.y() - radius, radius * 2, radius * 2); - const QPixmap px = ManaSymbolPixmapGenerator::generatePixmap(symbol, QSize(radius * 2, radius * 2)); + const QPixmap *px = cachedSymbolPixmap(symbol, radius * 2); - if (!px.isNull()) { - p->drawPixmap(pip, px); + if (px && !px->isNull()) { + p->drawPixmap(pip, *px); return; } diff --git a/cockatrice/src/interface/widgets/utility/card_completer_delegate.h b/cockatrice/src/interface/widgets/utility/card_completer_delegate.h index 78667f56b..18661a21b 100644 --- a/cockatrice/src/interface/widgets/utility/card_completer_delegate.h +++ b/cockatrice/src/interface/widgets/utility/card_completer_delegate.h @@ -31,6 +31,9 @@ public: QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; private: + // Mana symbol pixmaps, loaded once and cached + mutable QCache symbolCache; + // Set short codes, resolved once per card name and cached mutable QCache setCodeCache; @@ -44,6 +47,9 @@ private: // adventure costs ("1W // W") are drawn as separate groups. Returns the left-most x used int drawManaCost(QPainter *p, const QRect &row, const QString &manaCost, int radius) const; + // Load (or return cached) a mana icon pixmap; falls back to painted circle + const QPixmap *cachedSymbolPixmap(const QString &symbol, int size) const; + // Resolve the preferred printing's set short code for a card QString setCodeForCard(const QSharedPointer &card) const; diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp index 76bbf344b..0cdf60d5d 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp @@ -21,7 +21,6 @@ #include #include #include -#include #include #include @@ -90,19 +89,6 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, databaseView->setItemDelegate(nullptr); databaseView->setVisible(false); - // Without a deck model there is nothing to add cards to, so the zone menu stays hidden. - if (deckListModel) { - databaseView->setZoneMenuProvider( - [deckListModel]() -> QList> { - QList> result; - for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { - result.append({boardName, deckListModel->getCustomZoneNames(boardName)}); - } - return result; - }, - [this] { return newZoneCreator ? newZoneCreator() : QString(); }); - } - searchEdit->setTreeView(databaseView); searchEdit->installEventFilter(databaseView->getKeySignals()); @@ -209,11 +195,6 @@ void VisualDatabaseDisplayWidget::showEvent(QShowEvent *event) initializeFilters(); } -void VisualDatabaseDisplayWidget::setNewZoneCreator(const std::function &creator) -{ - newZoneCreator = creator; -} - void VisualDatabaseDisplayWidget::retranslateUi() { databaseLoadIndicator->setText(tr("Loading database ...")); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h index d161ce362..6e4d87876 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h @@ -22,7 +22,6 @@ #include #include #include -#include #include #include #include @@ -47,12 +46,6 @@ public: void sortCardList(const QStringList &properties, Qt::SortOrder order) const; void setDeckList(const DeckList &new_deck_list_model); - /** - * @brief Sets the callback used to create a custom zone from the add-to-zone menu. - * The callback returns the name of the created zone, or an empty string if creation was cancelled. - */ - void setNewZoneCreator(const std::function &creator); - CardDatabaseDisplayModel *getDatabaseDisplayModel() { return databaseDisplayModel; @@ -113,7 +106,6 @@ private: VisualDatabaseDisplayFilterToolbarWidget *filterContainer; CardDatabaseDisplayModel *databaseDisplayModel; CardDatabaseView *databaseView; - std::function newZoneCreator; QList *cards; QVBoxLayout *mainLayout; QScrollArea *scrollArea; diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.cpp index fd529ff69..f1dcf113f 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.cpp @@ -1,9 +1,9 @@ #include "deck_preview_color_identity_filter_widget.h" #include "../../cards/additional_info/mana_symbol_widget.h" -#include "../visual_deck_storage_widget.h" +#include "deck_preview_widget.h" -#include +#include DeckPreviewColorIdentityFilterWidget::DeckPreviewColorIdentityFilterWidget(VisualDeckStorageWidget *parent) : QWidget(parent), layout(new QHBoxLayout(this)) @@ -32,6 +32,10 @@ DeckPreviewColorIdentityFilterWidget::DeckPreviewColorIdentityFilterWidget(Visua // Connect the button's clicked signal connect(toggleButton, &QPushButton::clicked, this, &DeckPreviewColorIdentityFilterWidget::updateFilterMode); + connect(this, &DeckPreviewColorIdentityFilterWidget::activeColorsChanged, parent, + &VisualDeckStorageWidget::updateColorFilter); + connect(this, &DeckPreviewColorIdentityFilterWidget::filterModeChanged, parent, + &VisualDeckStorageWidget::updateColorFilter); // Call retranslateUi to set the initial text retranslateUi(); @@ -41,33 +45,19 @@ void DeckPreviewColorIdentityFilterWidget::retranslateUi() { // Set the toggle button text based on the current mode switch (filterMode) { - case VisualDeckStorageSortFilterProxyModel::FilterMode::ExactMatch: + case ExactMatch: toggleButton->setText(tr("Mode: Exact Match")); break; - case VisualDeckStorageSortFilterProxyModel::FilterMode::Includes: + case Includes: toggleButton->setText(tr("Mode: Includes")); break; - case VisualDeckStorageSortFilterProxyModel::FilterMode::Excludes: + case Excludes: toggleButton->setText(tr("Mode: Excludes")); break; } toggleButton->setToolTip(tr("Color identity filter mode (AND/OR/NOT conjunctions of filters)")); } -/** - * @brief The colors that are currently toggled on. - */ -QSet DeckPreviewColorIdentityFilterWidget::getActiveColors() const -{ - QSet activeColorSet; - for (auto it = activeColors.constBegin(); it != activeColors.constEnd(); ++it) { - if (it.value()) { - activeColorSet.insert(it.key()); - } - } - return activeColorSet; -} - void DeckPreviewColorIdentityFilterWidget::handleColorToggled(QChar color, bool active) { activeColors[color] = active; @@ -78,17 +68,88 @@ void DeckPreviewColorIdentityFilterWidget::updateFilterMode() { // Cycle through the modes switch (filterMode) { - case VisualDeckStorageSortFilterProxyModel::FilterMode::ExactMatch: - filterMode = VisualDeckStorageSortFilterProxyModel::FilterMode::Includes; + case ExactMatch: + filterMode = Includes; break; - case VisualDeckStorageSortFilterProxyModel::FilterMode::Includes: - filterMode = VisualDeckStorageSortFilterProxyModel::FilterMode::Excludes; + case Includes: + filterMode = Excludes; break; - case VisualDeckStorageSortFilterProxyModel::FilterMode::Excludes: - filterMode = VisualDeckStorageSortFilterProxyModel::FilterMode::ExactMatch; + case Excludes: + filterMode = ExactMatch; break; } retranslateUi(); // Update the button text emit filterModeChanged(filterMode); } + +void DeckPreviewColorIdentityFilterWidget::filterWidgets(QList widgets) +{ + // Check if no colors are active + bool noColorsActive = true; + for (auto it = activeColors.constBegin(); it != activeColors.constEnd(); ++it) { + if (it.value()) { + noColorsActive = false; + break; + } + } + + // If no colors are active, return the unfiltered list of widgets + if (noColorsActive) { + for (DeckPreviewWidget *previewWidget : widgets) { + previewWidget->filteredByColor = false; + } + return; + } + + for (const auto &widget : widgets) { + QString colorIdentity = widget->getColorIdentity(); + + bool matchesFilter = true; + switch (filterMode) { + case ExactMatch: { + // Exact match mode: active colors must exactly match colorIdentity + + // Create a set of active colors + QSet activeColorSet; + for (auto it = activeColors.constBegin(); it != activeColors.constEnd(); ++it) { + if (it.value()) { + activeColorSet.insert(it.key().toUpper()); // Use uppercase for uniformity + } + } + + // Create a set of colors from the color identity string + QSet colorIdentitySet; + for (const QChar &color : colorIdentity) { + colorIdentitySet.insert(color.toUpper()); // Ensure case uniformity + } + + // Compare the sets: the sets must match exactly + if (activeColorSet != colorIdentitySet) { + matchesFilter = false; + } + break; + } + case Includes: + // Includes mode: colorIdentity must contain all active colors + for (auto it = activeColors.constBegin(); it != activeColors.constEnd(); ++it) { + if (it.value() && !colorIdentity.contains(it.key())) { + matchesFilter = false; + break; + } + } + break; + case Excludes: + // Excludes mode: colorIdentity must contain none of the active colors + for (auto it = activeColors.constBegin(); it != activeColors.constEnd(); ++it) { + if (it.value() && colorIdentity.contains(it.key())) { + matchesFilter = false; + break; + } + } + break; + } + + widget->filteredByColor = !matchesFilter; + } +} diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h index def45de66..8e60b16fb 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h @@ -2,18 +2,18 @@ * @file deck_preview_color_identity_filter_widget.h * @ingroup VisualDeckPreviewWidgets */ +//! \todo Document this file. #ifndef DECK_PREVIEW_COLOR_IDENTITY_FILTER_WIDGET_H #define DECK_PREVIEW_COLOR_IDENTITY_FILTER_WIDGET_H -#include "../visual_deck_storage_sort_filter_proxy_model.h" +#include "../visual_deck_storage_widget.h" #include -#include #include -#include #include +class DeckPreviewWidget; class VisualDeckStorageWidget; class DeckPreviewColorIdentityFilterWidget : public QWidget @@ -21,34 +21,25 @@ class DeckPreviewColorIdentityFilterWidget : public QWidget Q_OBJECT public: + /** + * How the active colors are matched against a deck's color identity. + */ + enum FilterMode + { + ExactMatch, ///< The color identity consists of exactly the active colors. + Includes, ///< The color identity contains all of the active colors. + Excludes ///< The color identity contains none of the active colors. + }; + Q_ENUM(FilterMode) + explicit DeckPreviewColorIdentityFilterWidget(VisualDeckStorageWidget *parent); void retranslateUi(); - - /** - * @brief The currently active color identity filter mode. - */ - [[nodiscard]] VisualDeckStorageSortFilterProxyModel::FilterMode getFilterMode() const - { - return filterMode; - } - - /** - * @brief The colors that are currently toggled on. - */ - [[nodiscard]] QSet getActiveColors() const; + void filterWidgets(QList widgets); signals: - /** - * Emitted when the set of active colors changed due to user interaction. - */ + void filterModeChanged(FilterMode mode); void activeColorsChanged(); - /** - * Emitted when the user cycles the color identity filter mode. - * @param mode The new filter mode. - */ - void filterModeChanged(VisualDeckStorageSortFilterProxyModel::FilterMode mode); - private slots: void handleColorToggled(QChar color, bool active); void updateFilterMode(); @@ -57,7 +48,7 @@ private: QHBoxLayout *layout; QPushButton *toggleButton; QMap activeColors; - VisualDeckStorageSortFilterProxyModel::FilterMode filterMode = VisualDeckStorageSortFilterProxyModel::Includes; + FilterMode filterMode = Includes; // Default to "includes" mode }; #endif // DECK_PREVIEW_COLOR_IDENTITY_FILTER_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp index 41d87ccb1..b45f61be7 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp @@ -1,15 +1,19 @@ #include "deck_preview_deck_tags_display_widget.h" #include "../../../../client/settings/cache_settings.h" -#include "../../../deck_loader/deck_loader.h" +#include "../../../../interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h" +#include "../../../../interface/widgets/tabs/tab_deck_editor.h" #include "../../general/layout_containers/flow_widget.h" #include "deck_preview_tag_addition_widget.h" #include "deck_preview_tag_dialog.h" #include "deck_preview_tag_display_widget.h" +#include "deck_preview_widget.h" #include #include +#include #include +#include DeckPreviewDeckTagsDisplayWidget::DeckPreviewDeckTagsDisplayWidget(QWidget *_parent, const QStringList &_tags) : QWidget(_parent), currentTags(_tags) @@ -50,16 +54,6 @@ void DeckPreviewDeckTagsDisplayWidget::refreshTags() flowWidget->addWidget(tagAdditionWidget); } -void DeckPreviewDeckTagsDisplayWidget::setKnownTagsProvider(const std::function &provider) -{ - knownTagsProvider = provider; -} - -void DeckPreviewDeckTagsDisplayWidget::setConversionPromptHandler(const std::function &handler) -{ - conversionPromptHandler = handler; -} - /** * Gets the filepath of all files (no directories) in target directory and all subdirectories */ @@ -98,13 +92,93 @@ static QStringList findAllKnownTags() void DeckPreviewDeckTagsDisplayWidget::openTagEditDlg() { - // The deck editor path has no conversion prompt; the VDS path registers one. - if (conversionPromptHandler && !conversionPromptHandler()) { - return; + if (qobject_cast(parentWidget())) { + // If we're the child of a DeckPreviewWidget, then we need to handle conversion + auto *deckPreviewWidget = qobject_cast(parentWidget()); + + bool canAddTags = promptFileConversionIfRequired(deckPreviewWidget); + + if (canAddTags) { + QStringList knownTags = deckPreviewWidget->visualDeckStorageWidget->tagFilterWidget->getAllKnownTags(); + execTagDialog(knownTags); + } + } else { + // If we're the child of an AbstractTabDeckEditor, then we don't bother with conversion + QStringList knownTags = findAllKnownTags(); + execTagDialog(knownTags); + } +} + +static bool confirmOverwriteIfExists(QWidget *parent, const QString &filePath) +{ + QFileInfo fileInfo(filePath); + QString newFileName = QDir::toNativeSeparators(fileInfo.path() + "/" + fileInfo.completeBaseName() + ".cod"); + + if (QFile::exists(newFileName)) { + QMessageBox::StandardButton reply = + QMessageBox::question(parent, QObject::tr("Overwrite Existing File?"), + QObject::tr("A .cod version of this deck already exists. Overwrite it?"), + QMessageBox::Yes | QMessageBox::No); + return reply == QMessageBox::Yes; + } + return true; // Safe to proceed +} + +static void convertFileToCockatriceFormat(DeckPreviewWidget *deckPreviewWidget) +{ + DeckLoader::convertToCockatriceFormat(deckPreviewWidget->deckLoader->getDeck()); + deckPreviewWidget->filePath = deckPreviewWidget->deckLoader->getDeck().lastLoadInfo.fileName; + deckPreviewWidget->refreshBannerCardText(); +} + +/** + * Checks if the deck's file format supports tags. + * If not, then prompt the user for file conversion. + * @return whether the resulting file can support adding tags + */ +bool DeckPreviewDeckTagsDisplayWidget::promptFileConversionIfRequired(DeckPreviewWidget *deckPreviewWidget) +{ + if (DeckFileFormat::getFormatFromName(deckPreviewWidget->filePath) == DeckFileFormat::Cockatrice) { + return true; } - const QStringList knownTags = knownTagsProvider ? knownTagsProvider() : findAllKnownTags(); - execTagDialog(knownTags); + // Retrieve saved preference if the prompt is disabled + if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStoragePromptForConversion()) { + if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStorageAlwaysConvert()) { + return false; + } + + if (!confirmOverwriteIfExists(this, deckPreviewWidget->filePath)) { + return false; + } + + convertFileToCockatriceFormat(deckPreviewWidget); + return true; + } + + // Show the dialog to the user + DialogConvertDeckToCodFormat conversionDialog(parentWidget()); + if (conversionDialog.exec() != QDialog::Accepted) { + SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion( + !conversionDialog.dontAskAgain()); + SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(false); + + return false; + } + + // Try to convert file + if (!confirmOverwriteIfExists(this, deckPreviewWidget->filePath)) { + return false; + } + + convertFileToCockatriceFormat(deckPreviewWidget); + + if (conversionDialog.dontAskAgain()) { + SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion(false); + SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(true); + } + + return true; } void DeckPreviewDeckTagsDisplayWidget::execTagDialog(const QStringList &knownTags) @@ -117,4 +191,4 @@ void DeckPreviewDeckTagsDisplayWidget::execTagDialog(const QStringList &knownTag emit tagsChanged(updatedTags); } } -} +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.h index 64bd5aa1a..4bd7915cd 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.h @@ -2,53 +2,41 @@ * @file deck_preview_deck_tags_display_widget.h * @ingroup VisualDeckPreviewWidgets */ +//! \todo Document this file. #ifndef DECK_PREVIEW_DECK_TAGS_DISPLAY_WIDGET_H #define DECK_PREVIEW_DECK_TAGS_DISPLAY_WIDGET_H -#include +#include "../../../deck_loader/deck_loader.h" +#include "deck_preview_widget.h" + #include -#include - -class FlowWidget; +class DeckPreviewWidget; class DeckPreviewDeckTagsDisplayWidget : public QWidget { Q_OBJECT QStringList currentTags; FlowWidget *flowWidget; - std::function knownTagsProvider; - std::function conversionPromptHandler; public: explicit DeckPreviewDeckTagsDisplayWidget(QWidget *_parent, const QStringList &_tags = {}); void setTags(const QStringList &_tags); void refreshTags(); - /** - * @brief Sets a provider for the tags shown in the edit dialog. - * Defaults to scanning all deck files in the deck folder. - */ - void setKnownTagsProvider(const std::function &provider); - - /** - * @brief Sets a handler run before opening the tag dialog. Returning false - * cancels the dialog. Defaults to no handler (the deck editor path). - */ - void setConversionPromptHandler(const std::function &handler); - public slots: void openTagEditDlg(); +private: + bool promptFileConversionIfRequired(DeckPreviewWidget *deckPreviewWidget); + void execTagDialog(const QStringList &knownTags); + signals: /** * Emitted when the tags have changed due to user interaction. * @param tags The new list of tags. */ void tagsChanged(const QStringList &tags); - -private: - void execTagDialog(const QStringList &knownTags); }; #endif // DECK_PREVIEW_DECK_TAGS_DISPLAY_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp index 876fbf6ad..ffe954308 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp @@ -1,67 +1,47 @@ #include "deck_preview_widget.h" #include "../../../../client/settings/cache_settings.h" -#include "../../../../interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h" -#include "../../../deck_loader/deck_loader.h" #include "../../cards/additional_info/color_identity_widget.h" #include "../../cards/deck_preview_card_picture_widget.h" -#include "../visual_deck_storage_quick_settings_widget.h" -#include "../visual_deck_storage_tag_filter_widget.h" -#include "../visual_deck_storage_widget.h" #include "deck_preview_deck_tags_display_widget.h" +#include +#include #include #include -#include #include #include #include #include #include #include -#include #include #include DeckPreviewWidget::DeckPreviewWidget(QWidget *_parent, VisualDeckStorageWidget *_visualDeckStorageWidget, - VisualDeckStorageModel *_model, const QString &_filePath) - : QWidget(_parent), visualDeckStorageWidget(_visualDeckStorageWidget), model(_model), filePath(_filePath) + : QWidget(_parent), visualDeckStorageWidget(_visualDeckStorageWidget), filePath(_filePath), + colorIdentityWidget(nullptr), deckTagsDisplayWidget(nullptr) { layout = new QVBoxLayout(this); setLayout(layout); - auto *pictureWidget = + deckLoader = new DeckLoader(this); + connect(deckLoader, &DeckLoader::loadFinished, this, &DeckPreviewWidget::initializeUi); + //! \todo Batch tag refresh: count finished deck loads and refresh tags once all decks are loaded. + // Currently expensive: refreshes on each individual deck load instead of once at the end. + connect(deckLoader, &DeckLoader::loadFinished, visualDeckStorageWidget->tagFilterWidget, + &VisualDeckStorageTagFilterWidget::refreshTags); + deckLoader->loadFromFileAsync(filePath, DeckFileFormat::getFormatFromName(filePath), false); + + bannerCardDisplayWidget = new DeckPreviewCardPictureWidget(this, false, visualDeckStorageWidget->deckPreviewSelectionAnimationEnabled); - pictureWidget->setFontSize(24); - connect(pictureWidget, &DeckPreviewCardPictureWidget::imageClicked, this, &DeckPreviewWidget::imageClickedEvent); - connect(pictureWidget, &DeckPreviewCardPictureWidget::imageDoubleClicked, this, + + connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageClicked, this, + &DeckPreviewWidget::imageClickedEvent); + connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageDoubleClicked, this, &DeckPreviewWidget::imageDoubleClickedEvent); - bannerCardDisplayWidget = pictureWidget; - - colorIdentityWidget = new ColorIdentityWidget(this); - deckTagsDisplayWidget = new DeckPreviewDeckTagsDisplayWidget(this); - connect(deckTagsDisplayWidget, &DeckPreviewDeckTagsDisplayWidget::tagsChanged, this, &DeckPreviewWidget::setTags); - deckTagsDisplayWidget->setKnownTagsProvider( - [this] { return visualDeckStorageWidget->tagFilterWidget->getAllKnownTags(); }); - deckTagsDisplayWidget->setConversionPromptHandler([this] { return promptFileConversionIfRequired(); }); - - bannerCardLabel = new QLabel(this); - bannerCardLabel->setObjectName("bannerCardLabel"); - bannerCardComboBox = new QComboBox(this); - bannerCardComboBox->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); - bannerCardComboBox->setObjectName("bannerCardComboBox"); - bannerCardComboBox->installEventFilter(new NoScrollFilter(bannerCardComboBox)); - connect(bannerCardComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, - &DeckPreviewWidget::setBannerCard); - - // Apply the initial visibility settings and keep them in sync while they change. - updateColorIdentityVisibility( - SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowColorIdentity()); - updateBannerCardComboBoxVisibility( - SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowBannerCardComboBox()); - updateTagsVisibility(SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagsOnDeckPreviews()); connect(&SettingsCache::instance().visualDeckStorage(), &VisualDeckStorageSettings::visualDeckStorageShowColorIdentityChanged, this, @@ -76,29 +56,6 @@ DeckPreviewWidget::DeckPreviewWidget(QWidget *_parent, &DeckPreviewWidget::refreshBannerCardToolTip); layout->addWidget(bannerCardDisplayWidget); - layout->addWidget(colorIdentityWidget); - layout->addWidget(deckTagsDisplayWidget); - layout->addWidget(bannerCardLabel); - layout->addWidget(bannerCardComboBox); - - // Only re-sync when this widget's own row changed. Without the row check, every - // finished deck load would trigger a full resync (card db lookup + combo rebuild) - // in every preview widget. - connect(model, &QAbstractItemModel::dataChanged, this, - [this](const QModelIndex &topLeft, const QModelIndex &bottomRight) { - const int r = row(); - if (r >= topLeft.row() && r <= bottomRight.row()) { - syncFromModel(); - } - }); - - retranslateUi(); - syncFromModel(); - - // resizeEvent clamps every child to the picture's width, so collect them once here - // to keep the resize handler from searching the widget tree on every layout pass. - fixedWidthChildren = {bannerCardDisplayWidget, colorIdentityWidget, deckTagsDisplayWidget, bannerCardLabel, - bannerCardComboBox}; } void DeckPreviewWidget::retranslateUi() @@ -112,15 +69,9 @@ void DeckPreviewWidget::resizeEvent(QResizeEvent *event) if (bannerCardDisplayWidget == nullptr) { return; } - - const int width = bannerCardDisplayWidget->width(); - if (width == lastKnownBannerWidth) { - return; - } - lastKnownBannerWidth = width; - - for (QWidget *widget : fixedWidthChildren) { - widget->setMaximumWidth(width); + QList widgets = findChildren(); + for (QWidget *widget : widgets) { + widget->setMaximumWidth(bannerCardDisplayWidget->width()); } } @@ -128,28 +79,83 @@ void DeckPreviewWidget::enterEvent(QEnterEvent *event) { QWidget::enterEvent(event); - // Don't do reloads until the deck has actually been loaded once. - reloadIfModified(); -} - -/** - * @brief The row of this deck in the source model, or -1 if it no longer exists. - */ -int DeckPreviewWidget::row() const -{ - return model->rowForFilePath(filePath); -} - -/** - * @brief The display name is given by the deck name, or the filename if the deck name is not set. - */ -QString DeckPreviewWidget::getDisplayName() const -{ - const int r = row(); - if (r == -1) { - return {}; + // don't do reloads until widgets have been created + if (bannerCardComboBox != nullptr) { + reloadIfModified(); } - return model->dataForRow(r).displayName; +} + +/** + * @brief Sets the lastModifiedTime to the value given by the file. + */ +void DeckPreviewWidget::updateLastModifiedTime() +{ + QFileInfo fileInfo(filePath); + lastModifiedTime = fileInfo.lastModified(); +} + +/** + * @brief Writes the current contents of the deck to file. Updates the lastModifiedTime afterward. + */ +void DeckPreviewWidget::writeDeckToFile() +{ + DeckLoader::saveToFile(deckLoader->getDeck()); + updateLastModifiedTime(); +} + +void DeckPreviewWidget::initializeUi(const bool deckLoadSuccess) +{ + if (!deckLoadSuccess) { + return; + } + + QFileInfo fileInfo(filePath); + lastModifiedTime = fileInfo.lastModified(); + + bannerCardDisplayWidget->setFontSize(24); + setFilePath(deckLoader->getDeck().lastLoadInfo.fileName); + + colorIdentityWidget = new ColorIdentityWidget(this); + deckTagsDisplayWidget = new DeckPreviewDeckTagsDisplayWidget(this); + connect(deckTagsDisplayWidget, &DeckPreviewDeckTagsDisplayWidget::tagsChanged, this, &DeckPreviewWidget::setTags); + + bannerCardLabel = new QLabel(this); + bannerCardLabel->setObjectName("bannerCardLabel"); + bannerCardComboBox = new QComboBox(this); + bannerCardComboBox->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); + bannerCardComboBox->setObjectName("bannerCardComboBox"); + bannerCardComboBox->installEventFilter(new NoScrollFilter(bannerCardComboBox)); + connect(bannerCardComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, + &DeckPreviewWidget::setBannerCard); + + updateColorIdentityVisibility( + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowColorIdentity()); + updateBannerCardComboBoxVisibility( + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowBannerCardComboBox()); + updateTagsVisibility(SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagsOnDeckPreviews()); + + layout->addWidget(colorIdentityWidget); + layout->addWidget(deckTagsDisplayWidget); + layout->addWidget(bannerCardLabel); + layout->addWidget(bannerCardComboBox); + + retranslateUi(); + resyncWidgets(); +} + +/** + * @brief Syncs the contents of the child widgets with the current deck. + */ +void DeckPreviewWidget::resyncWidgets() +{ + auto bannerCardRef = deckLoader->getDeck().deckList.getBannerCard(); + auto bannerCard = bannerCardRef.name.isEmpty() ? ExactCard() : CardDatabaseManager::query()->getCard(bannerCardRef); + + bannerCardDisplayWidget->setCard(bannerCard); + refreshBannerCardText(); + updateBannerCardComboBox(bannerCardRef.name); + colorIdentityWidget->setColorIdentity(getColorIdentity()); + deckTagsDisplayWidget->setTags(deckLoader->getDeck().deckList.getTags()); } /** @@ -157,36 +163,33 @@ QString DeckPreviewWidget::getDisplayName() const */ void DeckPreviewWidget::reloadIfModified() { - const int r = row(); - if (r == -1 || !model->dataForRow(r).loadSucceeded) { + QFileInfo fileInfo(filePath); + QDateTime newLastModifiedTime = fileInfo.lastModified(); + + if (!newLastModifiedTime.isValid() || newLastModifiedTime <= lastModifiedTime) { return; } - model->reloadIfModified(r); + bool success = deckLoader->reload(); + + if (success) { + fileInfo.refresh(); + lastModifiedTime = fileInfo.lastModified(); + resyncWidgets(); + } } -/** - * @brief Syncs the contents of the child widgets with the current row's data. - */ -void DeckPreviewWidget::syncFromModel() +void DeckPreviewWidget::updateVisibility() { - const int r = row(); - if (r == -1) { - return; + setHidden(!checkVisibility()); +} + +bool DeckPreviewWidget::checkVisibility() const +{ + if (filteredBySearch || filteredByColor || filteredByTags) { + return false; } - - const DeckPreviewData &data = model->dataForRow(r); - filePath = data.filePath; - - const CardRef bannerCardRef = data.deck.deckList.getBannerCard(); - const ExactCard bannerCard = - bannerCardRef.name.isEmpty() ? ExactCard() : CardDatabaseManager::query()->getCard(bannerCardRef); - - bannerCardDisplayWidget->setCard(bannerCard); - refreshBannerCardText(); - updateBannerCardComboBox(bannerCardRef.name); - colorIdentityWidget->setColorIdentity(data.colorIdentity); - deckTagsDisplayWidget->setTags(data.tags); + return true; } void DeckPreviewWidget::updateColorIdentityVisibility(bool visible) @@ -226,6 +229,51 @@ void DeckPreviewWidget::updateTagsVisibility(bool visible) } } +QString DeckPreviewWidget::getColorIdentity() +{ + QStringList cardList = deckLoader->getDeck().deckList.getCardList({DECK_ZONE_MAIN, DECK_ZONE_SIDE}); + if (cardList.isEmpty()) { + return {}; + } + + QSet colorSet; // A set to collect unique color symbols (e.g., W, U, B, R, G) + + for (const QString &cardName : cardList) { + CardInfoPtr currentCard = CardDatabaseManager::query()->getCardInfo(cardName); + if (currentCard) { + QString colors = currentCard->getColors(); // Assuming this 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; +} + +/** + * The display name is given by the deck name, or the filename if the deck name is not set. + */ +QString DeckPreviewWidget::getDisplayName() const +{ + QString deckName = deckLoader->getDeck().deckList.getName(); + return !deckName.isEmpty() ? deckName : QFileInfo(deckLoader->getDeck().lastLoadInfo.fileName).fileName(); +} + +void DeckPreviewWidget::setFilePath(const QString &_filePath) +{ + filePath = _filePath; +} + /** * Refreshes the banner card text. * This also calls `refreshBannerCardToolTip`, since those two often need to be updated together. @@ -262,15 +310,11 @@ void DeckPreviewWidget::updateBannerCardComboBox(const QString ¤tText) // Prepare the new items with deduplication QSet> bannerCardSet; - const int r = row(); - if (r != -1) { - const DeckList &deckList = model->dataForRow(r).deck.deckList; - const QList cardsInDeck = deckList.getCardNodes(); + QList cardsInDeck = deckLoader->getDeck().deckList.getCardNodes(); - for (auto currentCard : cardsInDeck) { - for (int k = 0; k < currentCard->getNumber(); ++k) { - bannerCardSet.insert(QPair(currentCard->getName(), currentCard->getCardProviderId())); - } + for (auto currentCard : cardsInDeck) { + for (int k = 0; k < currentCard->getNumber(); ++k) { + bannerCardSet.insert(QPair(currentCard->getName(), currentCard->getCardProviderId())); } } @@ -283,16 +327,16 @@ void DeckPreviewWidget::updateBannerCardComboBox(const QString ¤tText) // This is *slightly* more performant than using addItem in a loop. - QStandardItemModel *comboModel = new QStandardItemModel(pairList.size(), 1, bannerCardComboBox); + QStandardItemModel *model = new QStandardItemModel(pairList.size(), 1, bannerCardComboBox); int row = 0; for (const auto &pair : pairList) { QStandardItem *item = new QStandardItem(pair.first); item->setData(QVariant::fromValue(pair), Qt::UserRole); - comboModel->setItem(row++, 0, item); + model->setItem(row++, 0, item); } - bannerCardComboBox->setModel(comboModel); + bannerCardComboBox->setModel(model); // Try to restore the previous selection by finding the currentText int restoredIndex = bannerCardComboBox->findText(currentText); @@ -300,9 +344,7 @@ void DeckPreviewWidget::updateBannerCardComboBox(const QString ¤tText) bannerCardComboBox->setCurrentIndex(restoredIndex); } else { // Add a placeholder "-" and set it as the current selection - const QString currentBannerCardName = - r == -1 ? QString() : model->dataForRow(r).deck.deckList.getBannerCard().name; - int bannerIndex = bannerCardComboBox->findText(currentBannerCardName); + int bannerIndex = bannerCardComboBox->findText(deckLoader->getDeck().deckList.getBannerCard().name); if (bannerIndex != -1) { bannerCardComboBox->setCurrentIndex(bannerIndex); } else { @@ -320,11 +362,8 @@ void DeckPreviewWidget::setBannerCard(int /* changedIndex */) { auto [name, id] = bannerCardComboBox->currentData().value>(); CardRef cardRef = {name, id}; - const int r = row(); - if (r == -1) { - return; - } - model->setBannerCard(r, cardRef); + deckLoader->getDeck().deckList.setBannerCard(cardRef); + writeDeckToFile(); bannerCardDisplayWidget->setCard(CardDatabaseManager::query()->getCard(cardRef)); } @@ -346,24 +385,17 @@ void DeckPreviewWidget::imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewC void DeckPreviewWidget::setTags(const QStringList &tags) { - const int r = row(); - if (r != -1) { - model->setTags(r, tags); - } + deckLoader->getDeck().deckList.setTags(tags); + writeDeckToFile(); } QMenu *DeckPreviewWidget::createRightClickMenu() { - const int r = row(); - auto *menu = new QMenu(this); menu->setAttribute(Qt::WA_DeleteOnClose); - connect(menu->addAction(tr("Open in deck editor")), &QAction::triggered, this, [this, r] { - if (r != -1) { - emit openDeckEditor(model->deckForRow(r)); - } - }); + connect(menu->addAction(tr("Open in deck editor")), &QAction::triggered, this, + [this] { emit openDeckEditor(deckLoader->getDeck()); }); connect(menu->addAction(tr("Edit Tags")), &QAction::triggered, deckTagsDisplayWidget, &DeckPreviewDeckTagsDisplayWidget::openTagEditDlg); @@ -376,26 +408,14 @@ QMenu *DeckPreviewWidget::createRightClickMenu() auto saveToClipboardMenu = menu->addMenu(tr("Save Deck to Clipboard")); - connect(saveToClipboardMenu->addAction(tr("Annotated")), &QAction::triggered, this, [this, r] { - if (r != -1) { - DeckLoader::saveToClipboard(model->dataForRow(r).deck.deckList, true, true); - } - }); - connect(saveToClipboardMenu->addAction(tr("Annotated (No set info)")), &QAction::triggered, this, [this, r] { - if (r != -1) { - DeckLoader::saveToClipboard(model->dataForRow(r).deck.deckList, true, false); - } - }); - connect(saveToClipboardMenu->addAction(tr("Not Annotated")), &QAction::triggered, this, [this, r] { - if (r != -1) { - DeckLoader::saveToClipboard(model->dataForRow(r).deck.deckList, false, true); - } - }); - connect(saveToClipboardMenu->addAction(tr("Not Annotated (No set info)")), &QAction::triggered, this, [this, r] { - if (r != -1) { - DeckLoader::saveToClipboard(model->dataForRow(r).deck.deckList, false, false); - } - }); + connect(saveToClipboardMenu->addAction(tr("Annotated")), &QAction::triggered, this, + [this] { DeckLoader::saveToClipboard(deckLoader->getDeck().deckList, true, true); }); + connect(saveToClipboardMenu->addAction(tr("Annotated (No set info)")), &QAction::triggered, this, + [this] { DeckLoader::saveToClipboard(deckLoader->getDeck().deckList, true, false); }); + connect(saveToClipboardMenu->addAction(tr("Not Annotated")), &QAction::triggered, this, + [this] { DeckLoader::saveToClipboard(deckLoader->getDeck().deckList, false, true); }); + connect(saveToClipboardMenu->addAction(tr("Not Annotated (No set info)")), &QAction::triggered, this, + [this] { DeckLoader::saveToClipboard(deckLoader->getDeck().deckList, false, false); }); menu->addSeparator(); @@ -430,58 +450,57 @@ void DeckPreviewWidget::addSetBannerCardMenu(QMenu *menu) void DeckPreviewWidget::actRenameDeck() { - const int r = row(); - if (r == -1) { - return; - } - // read input - const QString oldName = model->dataForRow(r).deckName; + const QString oldName = deckLoader->getDeck().deckList.getName(); bool ok; - QString newName = QInputDialog::getText(this, tr("Rename deck"), tr("New name:"), QLineEdit::Normal, oldName, &ok); + QString newName = QInputDialog::getText(this, "Rename deck", tr("New name:"), QLineEdit::Normal, oldName, &ok); if (!ok || oldName == newName) { return; } // write change - model->renameDeck(r, newName); + deckLoader->getDeck().deckList.setName(newName); + writeDeckToFile(); - // The banner card text updates via the model's dataChanged signal. + // update VDS + refreshBannerCardText(); } void DeckPreviewWidget::actRenameFile() { - const int r = row(); - if (r == -1) { - return; - } - // read input const auto info = QFileInfo(filePath); const QString oldName = info.baseName(); bool ok; - QString newName = QInputDialog::getText(this, tr("Rename file"), tr("New name:"), QLineEdit::Normal, oldName, &ok); + QString newName = QInputDialog::getText(this, "Rename file", tr("New name:"), QLineEdit::Normal, oldName, &ok); if (!ok || newName.isEmpty() || oldName == newName) { return; } - // write change - if (!model->renameFile(r, newName)) { - QMessageBox::critical(this, tr("Error"), tr("Rename failed")); + QString newFileName = newName; + if (!info.suffix().isEmpty()) { + newFileName += "." + info.suffix(); } - // The file path and banner card text update via the model's signals. + // write change + const QString newFilePath = QFileInfo(info.dir(), newFileName).filePath(); + if (!QFile::rename(info.filePath(), newFilePath)) { + QMessageBox::critical(this, tr("Error"), tr("Rename failed")); + return; + } + + deckLoader->getDeck().lastLoadInfo.fileName = newFilePath; + setFilePath(newFilePath); + + // update VDS + updateLastModifiedTime(); + refreshBannerCardText(); } void DeckPreviewWidget::actDeleteFile() { - const int r = row(); - if (r == -1) { - return; - } - // read input auto res = QMessageBox::warning(this, tr("Delete file"), tr("Are you sure you want to delete the selected file?"), QMessageBox::Yes | QMessageBox::No); @@ -490,22 +509,11 @@ void DeckPreviewWidget::actDeleteFile() } // write change - if (!model->deleteFile(r)) { + if (!QFile::remove(QFileInfo(filePath).filePath())) { QMessageBox::critical(this, tr("Error"), tr("Delete failed")); + return; } - // The folder widget removes this preview once the row is gone. -} - -/** - * Checks if the deck's file format supports tags. - * If not, then prompt the user for file conversion. - * @return whether the resulting file can support adding tags - */ -bool DeckPreviewWidget::promptFileConversionIfRequired() -{ - return DialogConvertDeckToCodFormat::promptIfRequired(this, filePath, [this] { - model->convertToCockatriceFormat(row()); - return true; - }); + // update VDS + this->deleteLater(); } diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h index 7bb69f9b9..de66c194b 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h @@ -2,12 +2,16 @@ * @file deck_preview_widget.h * @ingroup VisualDeckPreviewWidgets */ +//! \todo Document this file. #ifndef DECK_PREVIEW_WIDGET_H #define DECK_PREVIEW_WIDGET_H +#include "../../../deck_loader/deck_loader.h" +#include "../../cards/additional_info/color_identity_widget.h" #include "../../cards/deck_preview_card_picture_widget.h" -#include "../visual_deck_storage_model.h" +#include "../visual_deck_storage_widget.h" +#include "deck_preview_deck_tags_display_widget.h" #include #include @@ -16,82 +20,72 @@ #include #include -class QEnterEvent; -class QLabel; class QMenu; -class QMouseEvent; -class ColorIdentityWidget; -class DeckPreviewCardPictureWidget; -class DeckPreviewDeckTagsDisplayWidget; -class VisualDeckStorageModel; class VisualDeckStorageWidget; +class DeckPreviewDeckTagsDisplayWidget; class DeckPreviewWidget final : public QWidget { Q_OBJECT public: - explicit DeckPreviewWidget(QWidget *parent, + explicit DeckPreviewWidget(QWidget *_parent, VisualDeckStorageWidget *_visualDeckStorageWidget, - VisualDeckStorageModel *_model, const QString &_filePath); void retranslateUi(); + QString getColorIdentity(); + [[nodiscard]] QString getDisplayName() const; - /** - * @brief The banner card picture; the parent widget wires its size to the card size setting. - */ - DeckPreviewCardPictureWidget *bannerCardDisplayWidget; + VisualDeckStorageWidget *visualDeckStorageWidget; + QVBoxLayout *layout; + QString filePath; + QDateTime lastModifiedTime; + DeckLoader *deckLoader; + DeckPreviewCardPictureWidget *bannerCardDisplayWidget = nullptr; + ColorIdentityWidget *colorIdentityWidget = nullptr; + DeckPreviewDeckTagsDisplayWidget *deckTagsDisplayWidget = nullptr; + QLabel *bannerCardLabel = nullptr; + QComboBox *bannerCardComboBox = nullptr; + bool filteredBySearch = false; + bool filteredByColor = false; + bool filteredByTags = false; + [[nodiscard]] bool checkVisibility() const; signals: void deckLoadRequested(const QString &filePath); void openDeckEditor(const LoadedDeck &deck); public slots: - /** - * @brief Re-reads the row's data from the model and syncs every child widget. - * Connected to the model's dataChanged signal. - */ - void syncFromModel(); - - /** - * @brief Reloads the deck file if its modification time is newer than the stored one. - */ - void reloadIfModified(); + void setFilePath(const QString &filePath); + void refreshBannerCardText(); void refreshBannerCardToolTip(); + void updateBannerCardComboBox(const QString ¤tText); + void setBannerCard(int); + void imageClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); + void imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); + void initializeUi(bool deckLoadSuccess); + void resyncWidgets(); + void reloadIfModified(); + void updateVisibility(); void updateColorIdentityVisibility(bool visible); void updateBannerCardComboBoxVisibility(bool visible); void updateTagsVisibility(bool visible); - void setBannerCard(int); - void setTags(const QStringList &tags); + void resizeEvent(QResizeEvent *event) override; protected: void enterEvent(QEnterEvent *event) override; - void resizeEvent(QResizeEvent *event) override; private: - [[nodiscard]] int row() const; - [[nodiscard]] QString getDisplayName() const; - void refreshBannerCardText(); - void updateBannerCardComboBox(const QString ¤tText); - bool promptFileConversionIfRequired(); + void updateLastModifiedTime(); + void writeDeckToFile(); QMenu *createRightClickMenu(); void addSetBannerCardMenu(QMenu *menu); - void imageClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); - void imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); + +private slots: + void setTags(const QStringList &tags); void actRenameDeck(); void actRenameFile(); void actDeleteFile(); - - VisualDeckStorageWidget *visualDeckStorageWidget; - VisualDeckStorageModel *model; - QString filePath; - QVBoxLayout *layout; - ColorIdentityWidget *colorIdentityWidget; - DeckPreviewDeckTagsDisplayWidget *deckTagsDisplayWidget; - QLabel *bannerCardLabel; - QComboBox *bannerCardComboBox; - QList fixedWidthChildren; ///< Children clamped to the picture width on resize. - int lastKnownBannerWidth = -1; ///< The picture width last applied to the children. }; class NoScrollFilter : public QObject diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp index 22d73b604..cc7f07871 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp @@ -1,27 +1,20 @@ #include "visual_deck_storage_folder_display_widget.h" -#include "../cards/card_info_picture_widget.h" -#include "../general/display/banner_widget.h" -#include "../general/layout_containers/flow_widget.h" +#include "../../../client/settings/cache_settings.h" #include "deck_preview/deck_preview_widget.h" -#include "visual_deck_storage_model.h" -#include "visual_deck_storage_quick_settings_widget.h" -#include "visual_deck_storage_sort_filter_proxy_model.h" #include "visual_deck_storage_widget.h" -#include -#include -#include -#include +#include +#include +#include VisualDeckStorageFolderDisplayWidget::VisualDeckStorageFolderDisplayWidget( QWidget *parent, VisualDeckStorageWidget *_visualDeckStorageWidget, - const QString &_folderPath, + QString _filePath, bool canBeHidden, bool _showFolders) - : QWidget(parent), showFolders(_showFolders), folderPath(_folderPath), - visualDeckStorageWidget(_visualDeckStorageWidget) + : QWidget(parent), showFolders(_showFolders), visualDeckStorageWidget(_visualDeckStorageWidget), filePath(_filePath) { layout = new QVBoxLayout(this); setLayout(layout); @@ -29,9 +22,6 @@ VisualDeckStorageFolderDisplayWidget::VisualDeckStorageFolderDisplayWidget( header = new BannerWidget(this, ""); header->setClickable(canBeHidden); header->setHidden(!showFolders); - - const QString bannerText = folderPath.isEmpty() ? tr("Deck Storage") : folderPath; - header->setText(bannerText); layout->addWidget(header); container = new QWidget(this); @@ -45,283 +35,192 @@ VisualDeckStorageFolderDisplayWidget::VisualDeckStorageFolderDisplayWidget( flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAlwaysOff); containerLayout->addWidget(flowWidget); - auto *proxy = visualDeckStorageWidget->proxyModel(); - // A burst of proxy changes (one dataChanged per finished deck load, plus the filter - // invalidations) coalesces into a single reconcile, so a scan of many decks doesn't - // rebuild the flow layout once per deck. - connect(proxy, &QAbstractItemModel::modelReset, this, &VisualDeckStorageFolderDisplayWidget::scheduleReconcile); - connect(proxy, &QAbstractItemModel::rowsInserted, this, &VisualDeckStorageFolderDisplayWidget::scheduleReconcile); - connect(proxy, &QAbstractItemModel::rowsRemoved, this, &VisualDeckStorageFolderDisplayWidget::scheduleReconcile); - connect(proxy, &QAbstractItemModel::dataChanged, this, &VisualDeckStorageFolderDisplayWidget::scheduleReconcile); - connect(proxy, &QAbstractItemModel::layoutChanged, this, &VisualDeckStorageFolderDisplayWidget::scheduleReconcile); + createWidgetsForFiles(); + createWidgetsForFolders(); - reconcileTimer = new QTimer(this); - reconcileTimer->setSingleShot(true); - reconcileTimer->setInterval(150); - connect(reconcileTimer, &QTimer::timeout, this, &VisualDeckStorageFolderDisplayWidget::reconcile); - - // Building the whole folder subtree synchronously here would stall the ui thread on large - // collections, so the first reconcile runs as a chunked pass on later event loop turns. - scheduleReconcile(); + refreshUi(); } -void VisualDeckStorageFolderDisplayWidget::scheduleReconcile() +void VisualDeckStorageFolderDisplayWidget::refreshUi() { - if (deckPassActive) { - // The active pass may be scanning stale model state, so restart it from a clean - // slate once the current chunk yields. - deckPassRestartRequested = true; - return; + QString bannerText = tr("Deck Storage"); + QString deckPath = SettingsCache::instance().paths().getDeckPath(); + if (filePath != deckPath) { + QString relativePath = filePath; + + if (filePath.startsWith(deckPath)) { + relativePath = filePath.mid(deckPath.length()); // Remove the deckPath prefix + if (relativePath.startsWith('/')) { + relativePath.remove(0, 1); // Remove leading '/' if it exists + } + } + + bannerText = relativePath; } - reconcileTimer->start(); + header->setText(bannerText); } /** - * @brief Starts a new chunked scan of the source model, yielding to the event loop between chunks. - */ -void VisualDeckStorageFolderDisplayWidget::reconcile() -{ - beginDeckPass(); -} - -void VisualDeckStorageFolderDisplayWidget::beginDeckPass() -{ - deckPassActive = true; - deckPassRestartRequested = false; - deckPassRow = 0; - visibleDeckCount = 0; - deckPassPresentPaths.clear(); - - continueDeckPass(); -} - -void VisualDeckStorageFolderDisplayWidget::continueDeckPass() -{ - if (!deckPassActive) { - return; - } - - QElapsedTimer passTimer; - passTimer.start(); - - auto *proxy = visualDeckStorageWidget->proxyModel(); - const int proxyRowCount = proxy->rowCount(); - - // Scan rows of this folder, creating missing previews, until the time budget for this - // event loop turn runs out. The rest continues on the next turn. - while (deckPassRow < proxyRowCount) { - const int row = deckPassRow++; - const QModelIndex index = proxy->index(row, 0); - if (showFolders && index.data(VisualDeckStorageRoles::FolderPathRole).toString() != folderPath) { - continue; - } - const QString filePath = index.data(VisualDeckStorageRoles::FilePathRole).toString(); - deckPassPresentPaths.insert(filePath); - - DeckPreviewWidget *deckPreviewWidget = deckWidgets.value(filePath, nullptr); - if (!deckPreviewWidget) { - deckPreviewWidget = createDeckPreviewWidget(filePath); - flowWidget->addWidget(deckPreviewWidget); - } - - const bool matches = index.data(VisualDeckStorageRoles::FilterMatchRole).toBool(); - deckPreviewWidget->setVisible(matches); - if (matches) { - ++visibleDeckCount; - } - - if (passTimer.elapsed() >= DECK_PASS_TIME_BUDGET_MS) { - break; - } - } - - if (deckPassRestartRequested) { - beginDeckPass(); - return; - } - - if (deckPassRow < proxyRowCount) { - QMetaObject::invokeMethod(this, &VisualDeckStorageFolderDisplayWidget::continueDeckPass, Qt::QueuedConnection); - return; - } - - finishDeckPass(); -} - -void VisualDeckStorageFolderDisplayWidget::finishDeckPass() -{ - auto *proxy = visualDeckStorageWidget->proxyModel(); - - // Drop previews of decks that no longer exist in the model. - for (auto it = deckWidgets.begin(); it != deckWidgets.end();) { - if (!deckPassPresentPaths.contains(it.key())) { - flowWidget->removeWidget(it.value()); - it.value()->deleteLater(); - it = deckWidgets.erase(it); - } else { - ++it; - } - } - - // Order the flow layout like the proxy sorts its rows. Filtered-out decks stay part of - // the layout, hidden in their sorted place until a filter lets them through again. - QStringList orderedFilePaths; - orderedFilePaths.reserve(proxy->rowCount()); - for (int proxyRow = 0; proxyRow < proxy->rowCount(); ++proxyRow) { - const QString filePath = proxy->index(proxyRow, 0).data(VisualDeckStorageRoles::FilePathRole).toString(); - if (deckWidgets.contains(filePath)) { - orderedFilePaths.append(filePath); - } - } - - // Re-add all widgets so the flow layout order matches the proxy order. Skipped when the - // order is unchanged so that data-only updates don't invalidate the flow layout. - if (orderedFilePaths != lastOrderedFilePaths) { - for (const QString &filePath : orderedFilePaths) { - flowWidget->removeWidget(deckWidgets.value(filePath)); - } - for (const QString &filePath : orderedFilePaths) { - flowWidget->addWidget(deckWidgets.value(filePath)); - } - lastOrderedFilePaths = orderedFilePaths; - } - - createSubFolderWidgets(); - - // Mark completion before evaluating visibility so this pass's own numbers decide whether - // the folder has content. The flag only guards evaluations made *during* a build. - deckPassActive = false; - initialPassCompleted = true; - - refreshVisibility(); -} - -/** - * @brief Creates a deck preview widget and wires it up to the storage widget. + * Gets all files in the directory that have an accepted decklist file extension * - * @param filePath The absolute path of the deck file to preview. + * @param filePath The directory to search through + * @param recursive Whether to search through subdirectories */ -DeckPreviewWidget *VisualDeckStorageFolderDisplayWidget::createDeckPreviewWidget(const QString &filePath) +static QStringList getAllFiles(const QString &filePath, bool recursive) { - auto *deckPreviewWidget = - new DeckPreviewWidget(flowWidget, visualDeckStorageWidget, visualDeckStorageWidget->model(), filePath); - connect(deckPreviewWidget, &DeckPreviewWidget::deckLoadRequested, visualDeckStorageWidget, - &VisualDeckStorageWidget::deckLoadRequested); - connect(deckPreviewWidget, &DeckPreviewWidget::openDeckEditor, visualDeckStorageWidget, - &VisualDeckStorageWidget::openDeckEditor); - connect(visualDeckStorageWidget->settings(), &VisualDeckStorageQuickSettingsWidget::cardSizeChanged, - deckPreviewWidget->bannerCardDisplayWidget, &CardInfoPictureWidget::setScaleFactor); - deckPreviewWidget->bannerCardDisplayWidget->setScaleFactor(visualDeckStorageWidget->settings()->getCardSize()); - deckWidgets.insert(filePath, deckPreviewWidget); - return deckPreviewWidget; + QStringList allFiles; + + // QDirIterator with QDir::Files ensures only files are listed (no directories) + auto flags = + recursive ? QDirIterator::Subdirectories | QDirIterator::FollowSymlinks : QDirIterator::NoIteratorFlags; + QDirIterator it(filePath, DeckLoader::ACCEPTED_FILE_EXTENSIONS, QDir::Files, flags); + + while (it.hasNext()) { + allFiles << it.next(); // Add each file path to the list + } + + return allFiles; +} + +void VisualDeckStorageFolderDisplayWidget::createWidgetsForFiles() +{ + QList allDecks; + for (const QString &file : getAllFiles(filePath, !showFolders)) { + auto *display = new DeckPreviewWidget(flowWidget, visualDeckStorageWidget, file); + + connect(display, &DeckPreviewWidget::deckLoadRequested, visualDeckStorageWidget, + &VisualDeckStorageWidget::deckLoadRequested); + connect(display, &DeckPreviewWidget::openDeckEditor, visualDeckStorageWidget, + &VisualDeckStorageWidget::openDeckEditor); + connect(visualDeckStorageWidget->settings(), &VisualDeckStorageQuickSettingsWidget::cardSizeChanged, + display->bannerCardDisplayWidget, &CardInfoPictureWidget::setScaleFactor); + display->bannerCardDisplayWidget->setScaleFactor(visualDeckStorageWidget->settings()->getCardSize()); + allDecks.append(display); + } + + flowWidget->clearLayout(); // Clear existing widgets in the flow layout + + for (DeckPreviewWidget *deck : allDecks) { + flowWidget->addWidget(deck); + } } /** - * @brief Creates, removes and keeps in sync the subfolder widgets of this folder. + * Updates the visibility of this folder and all its DeckPreviewWidgets * - * Only direct child folders are created here; each child manages its own - * children, mirroring the folder tree on disk. + * @param recursive Also update the visibility of all subfolders and their DeckPreviewWidgets. */ -void VisualDeckStorageFolderDisplayWidget::createSubFolderWidgets() +void VisualDeckStorageFolderDisplayWidget::updateVisibility(bool recursive) +{ + bool atLeastOneWidgetVisible = checkVisibility(); + if (atLeastOneWidgetVisible) { + setVisible(true); + for (DeckPreviewWidget *display : flowWidget->findChildren()) { + display->updateVisibility(); + } + if (recursive) { + for (auto *subFolder : findChildren()) { + subFolder->updateVisibility(false); + } + } + } else { + setVisible(false); + } +} + +bool VisualDeckStorageFolderDisplayWidget::checkVisibility() +{ + bool atLeastOneWidgetVisible = false; + if (flowWidget) { + // Iterate through all DeckPreviewWidgets + for (DeckPreviewWidget *display : flowWidget->findChildren()) { + if (display->checkVisibility()) { + atLeastOneWidgetVisible = true; + } + } + } + for (VisualDeckStorageFolderDisplayWidget *subFolder : findChildren()) { + if (subFolder->checkVisibility()) { + atLeastOneWidgetVisible = true; + } + } + return atLeastOneWidgetVisible; +} + +static QStringList getAllSubFolders(const QString &filePath) +{ + QStringList allFolders; + + // QDirIterator with QDir::Files ensures only files are listed (no directories) + QDirIterator it(filePath, QDir::Dirs | QDir::NoDotAndDotDot); + + while (it.hasNext()) { + allFolders << it.next(); // Add each file path to the list + } + + return allFolders; +} + +void VisualDeckStorageFolderDisplayWidget::createWidgetsForFolders() { if (!showFolders) { return; } - const QStringList children = childFolderPaths(); - - for (auto it = subFolderWidgets.begin(); it != subFolderWidgets.end();) { - if (!children.contains(it.key())) { - containerLayout->removeWidget(it.value()); - it.value()->deleteLater(); - it = subFolderWidgets.erase(it); - } else { - ++it; - } - } - - for (const QString &child : children) { - if (subFolderWidgets.contains(child)) { - continue; - } - - auto *subFolderWidget = - new VisualDeckStorageFolderDisplayWidget(this, visualDeckStorageWidget, child, true, showFolders); - connect(subFolderWidget, &VisualDeckStorageFolderDisplayWidget::contentVisibilityChanged, this, - &VisualDeckStorageFolderDisplayWidget::refreshVisibility); - containerLayout->addWidget(subFolderWidget); - subFolderWidgets.insert(child, subFolderWidget); + for (const QString &dir : getAllSubFolders(filePath)) { + auto *display = new VisualDeckStorageFolderDisplayWidget(this, visualDeckStorageWidget, dir, true, showFolders); + containerLayout->addWidget(display); } } void VisualDeckStorageFolderDisplayWidget::updateShowFolders(bool enabled) { showFolders = enabled; - header->setHidden(!showFolders); if (!showFolders) { - for (auto it = subFolderWidgets.begin(); it != subFolderWidgets.end(); ++it) { - containerLayout->removeWidget(it.value()); - it.value()->deleteLater(); - } - subFolderWidgets.clear(); + flattenFolderStructure(); + } else { + // if setting was switched from disabled to enabled, we assume that there aren't any existing subfolders + createWidgetsForFiles(); + createWidgetsForFolders(); } - scheduleReconcile(); + header->setHidden(!showFolders); } /** - * @brief Hides the folder when it contains nothing visible, and reports the change upward. + * Steals all DeckPreviewWidgets from this widget's nested subfolders, and deletes those subfolders */ -void VisualDeckStorageFolderDisplayWidget::refreshVisibility() +void VisualDeckStorageFolderDisplayWidget::flattenFolderStructure() { - const bool shouldBeVisible = hasContent(); - if (isHidden() == !shouldBeVisible) { - return; + for (auto *subFolder : findChildren()) { + // steal all DeckPreviewWidgets from the subfolder + for (auto *deck : subFolder->getFlowWidget()->findChildren()) { + flowWidget->addWidget(deck); + } + + // delete the subfolder + subFolder->deleteLater(); } - setHidden(!shouldBeVisible); - emit contentVisibilityChanged(); } -/** - * @brief Whether this folder shows any deck previews or has any visible subfolder. - * - * While the first pass is still building, the folder counts as having content so it - * doesn't flicker or hide prematurely before its previews have been created. - */ -bool VisualDeckStorageFolderDisplayWidget::hasContent() const +QStringList VisualDeckStorageFolderDisplayWidget::gatherAllTagsFromFlowWidget() const { - if (!initialPassCompleted || visibleDeckCount > 0) { - return true; - } + QStringList allTags; - for (VisualDeckStorageFolderDisplayWidget *subFolderWidget : subFolderWidgets) { - if (subFolderWidget->hasContent()) { - return true; + if (flowWidget) { + // Iterate through all DeckPreviewWidgets + for (DeckPreviewWidget *display : flowWidget->findChildren()) { + // Get tags from each DeckPreviewWidget + QStringList tags = display->deckLoader->getDeck().deckList.getTags(); + + // Add tags to the list while avoiding duplicates + allTags.append(tags); } } - return false; -} + // Remove duplicates by calling 'removeDuplicates' + allTags.removeDuplicates(); -/** - * @brief The direct child folder paths of this folder, sorted by name. - */ -QStringList VisualDeckStorageFolderDisplayWidget::childFolderPaths() const -{ - QStringList children; - const QString prefix = folderPath.isEmpty() ? QString() : folderPath + "/"; - - for (const QString &candidate : visualDeckStorageWidget->model()->getFolderPaths()) { - if (!candidate.startsWith(prefix)) { - continue; - } - const QString rest = candidate.mid(prefix.length()); - if (rest.isEmpty() || rest.contains('/')) { - continue; - } - children.append(candidate); - } - - return children; -} + return allTags; +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.h index 257ce1778..a5e3be212 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.h @@ -1,105 +1,49 @@ /** * @file visual_deck_storage_folder_display_widget.h * @ingroup VisualDeckStorageWidgets - * @brief Renders the decks of one folder of the Visual Deck Storage. - * - * This is a pure view: it keeps one persistent DeckPreviewWidget alive per deck - * in its folder, and shows or hides those widgets according to each row's - * FilterMatchRole in the VisualDeckStorageSortFilterProxyModel. Subfolders are - * shown as nested VisualDeckStorageFolderDisplayWidgets when the "show folders" - * setting is enabled. - * - * Reconciling runs as a time-budgeted chunked pass that yields to the event loop - * between chunks, so scanning a large collection never stalls the ui thread. */ +//! \todo Document this file. #ifndef VISUAL_DECK_STORAGE_FOLDER_DISPLAY_WIDGET_H #define VISUAL_DECK_STORAGE_FOLDER_DISPLAY_WIDGET_H -#include -#include -#include -#include +#include "../general/display/banner_widget.h" +#include "../general/layout_containers/flow_widget.h" -class BannerWidget; -class DeckPreviewWidget; -class FlowWidget; -class QTimer; -class QVBoxLayout; class VisualDeckStorageWidget; - class VisualDeckStorageFolderDisplayWidget : public QWidget { Q_OBJECT public: VisualDeckStorageFolderDisplayWidget(QWidget *parent, - VisualDeckStorageWidget *visualDeckStorageWidget, - const QString &folderPath, + VisualDeckStorageWidget *_visualDeckStorageWidget, + QString _filePath, bool canBeHidden, - bool showFolders); + bool _showFolders); + void refreshUi(); + void createWidgetsForFiles(); + void createWidgetsForFolders(); + void flattenFolderStructure(); + [[nodiscard]] QStringList gatherAllTagsFromFlowWidget() const; + [[nodiscard]] FlowWidget *getFlowWidget() const + { + return flowWidget; + } public slots: - /** - * @brief Starts a new chunked reconcile pass that re-reads the proxy and rebuilds - * the deck previews and subfolder widgets to match. - */ - void reconcile(); - - /** - * @brief Coalesces proxy change signals (a burst of deck loads or filter - * invalidations) into a single reconcile on the next event loop turn. - */ - void scheduleReconcile(); + void updateVisibility(bool recursive = true); + bool checkVisibility(); void updateShowFolders(bool enabled); -signals: - /** - * @brief Emitted whenever this folder's visible content changes, so parent - * folders can re-evaluate their own visibility. - */ - void contentVisibilityChanged(); - private: - void beginDeckPass(); - void continueDeckPass(); - void finishDeckPass(); - [[nodiscard]] DeckPreviewWidget *createDeckPreviewWidget(const QString &filePath); - void createSubFolderWidgets(); - void refreshVisibility(); - [[nodiscard]] bool hasContent() const; - [[nodiscard]] QStringList childFolderPaths() const; - - /** - * @brief The maximum time in milliseconds spent creating deck previews per event loop turn. - * - * Creating all previews of a large folder at once blocks the ui thread for hundreds of - * milliseconds, so the pass is split into chunks that yield to the event loop instead. - */ - static constexpr int DECK_PASS_TIME_BUDGET_MS = 20; - bool showFolders; - QString folderPath; ///< Path relative to the deck folder, empty for the root folder. - int visibleDeckCount = 0; ///< The number of this folder's deck previews not filtered out. QVBoxLayout *layout; + VisualDeckStorageWidget *visualDeckStorageWidget; + QString filePath; + BannerWidget *header; QWidget *container; QVBoxLayout *containerLayout; FlowWidget *flowWidget; - BannerWidget *header; - VisualDeckStorageWidget *visualDeckStorageWidget; - QHash deckWidgets; ///< Deck file path -> preview widget. - QHash subFolderWidgets; ///< Folder path -> subfolder widget. - QTimer *reconcileTimer = nullptr; ///< Coalesces proxy change bursts. - QStringList lastOrderedFilePaths; ///< The deck order last applied to the flow layout. - - /// Whether a chunked reconcile pass is currently running. - bool deckPassActive = false; - /// Set when the model changes mid-pass. Discards progress and restarts the scan once - /// the current chunk finishes so the pass always converges on the latest model state. - bool deckPassRestartRequested = false; - /// Whether the first reconcile pass has run to completion at least once. - bool initialPassCompleted = false; - int deckPassRow = 0; ///< Next proxy row to scan in the active pass. - QSet deckPassPresentPaths; ///< File paths seen so far in the active pass. }; #endif // VISUAL_DECK_STORAGE_FOLDER_DISPLAY_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp index 5d0006539..bf2c49604 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp @@ -4,7 +4,6 @@ #include #include -#include #include #include #include @@ -33,18 +32,8 @@ struct DeckLoadResult { LoadedDeck deck; ///< The parsed deck. QDateTime lastModified; ///< File modification time at load. - QString colorIdentity; ///< WUBRG color identity, computed off the UI thread. }; -/** - * @brief How long per event loop turn the pending-load drain applies finished loads. - * - * Applying a load updates the row and wakes up the proxies and views, which is - * not free. A time budget instead of a fixed count lets fast machines apply - * many loads in one turn while keeping the ui thread responsive everywhere. - */ -constexpr int LOAD_DRAIN_TIME_BUDGET_MS = 8; - /** * @brief The path of \a path relative to the deck root, or empty if \a path * is not below it. @@ -119,8 +108,6 @@ DeckScanResult scanDeckDirectory(const QString &deckPath) } } // namespace -static QString computeColorIdentity(const LoadedDeck &deck); - VisualDeckStorageModel::VisualDeckStorageModel(QObject *parent) : QAbstractListModel(parent) { } @@ -195,22 +182,12 @@ const LoadedDeck &VisualDeckStorageModel::deckForRow(int row) const int VisualDeckStorageModel::rowForFilePath(const QString &filePath) const { - return rowByFilePath.value(filePath, -1); -} - -/** - * @brief Rebuilds the file path -> row index from scratch after bulk changes. - */ -void VisualDeckStorageModel::reindexFilePaths() -{ - rowByFilePath.clear(); - for (int row = 0; row < decks.size(); ++row) { - const QString &filePath = decks.at(row).filePath; - // First row wins, mirroring what a linear scan would return for duplicates. - if (!rowByFilePath.contains(filePath)) { - rowByFilePath.insert(filePath, row); + for (int i = 0; i < decks.size(); ++i) { + if (decks.at(i).filePath == filePath) { + return i; } } + return -1; } void VisualDeckStorageModel::startScan() @@ -218,9 +195,7 @@ void VisualDeckStorageModel::startScan() ++scanGeneration; beginResetModel(); decks.clear(); - rowByFilePath.clear(); folderPaths.clear(); - pendingLoads.clear(); endResetModel(); if (deckPath.isEmpty()) { @@ -249,7 +224,6 @@ void VisualDeckStorageModel::startScan() beginInsertRows(QModelIndex(), 0, result.decks.size() - 1); decks = result.decks; - reindexFilePaths(); endInsertRows(); for (int row = 0; row < decks.size(); ++row) { @@ -283,20 +257,26 @@ void VisualDeckStorageModel::beginLoad(int row) return; // The deck list was re-scanned while this load was running; drop the stale result. } - // Queue the result and apply a bounded number per event loop turn so that - // finishing hundreds of loads at once cannot stall the UI thread. - const std::optional result = watcher->result(); - PendingDeckLoad pending; - pending.filePath = filePath; - pending.generation = generation; - if (result) { - pending.ok = true; - pending.deck = std::move(result->deck); - pending.lastModified = result->lastModified; - pending.colorIdentity = std::move(result->colorIdentity); + const int row = rowForFilePath(filePath); + if (row == -1) { + return; } - pendingLoads.append(std::move(pending)); - schedulePendingLoadDrain(); + + DeckPreviewData &data = decks[row]; + data.loadInProgress = false; + + std::optional result = watcher->result(); + if (!result) { + return; // Leave the row unloaded; it stays visible but without deck data. + } + + data.deck = std::move(result->deck); + data.loadSucceeded = true; + data.lastModified = result->lastModified; + recomputeDeckMetadata(data); + + emit dataChanged(index(row), index(row)); + emit deckLoaded(row); }); watcher->setFuture(QtConcurrent::run([filePath, fmt]() -> std::optional { @@ -304,69 +284,10 @@ void VisualDeckStorageModel::beginLoad(int row) if (!deck) { return std::nullopt; } - // Color identity walks every card through the database, so compute it here to - // keep the completion handler on the UI thread cheap. - const QString colorIdentity = computeColorIdentity(*deck); - return DeckLoadResult{std::move(*deck), QFileInfo(filePath).lastModified(), colorIdentity}; + return DeckLoadResult{*deck, QFileInfo(filePath).lastModified()}; })); } -void VisualDeckStorageModel::schedulePendingLoadDrain() -{ - if (drainScheduled) { - return; - } - drainScheduled = true; - QMetaObject::invokeMethod(this, &VisualDeckStorageModel::drainPendingLoads, Qt::QueuedConnection); -} - -void VisualDeckStorageModel::drainPendingLoads() -{ - drainScheduled = false; - - QElapsedTimer timer; - timer.start(); - - while (!pendingLoads.isEmpty()) { - PendingDeckLoad pending = pendingLoads.takeFirst(); - - if (pending.generation != scanGeneration) { - continue; - } - - const int row = rowForFilePath(pending.filePath); - if (row == -1) { - continue; - } - - DeckPreviewData &data = decks[row]; - data.loadInProgress = false; - - if (!pending.ok) { - continue; // Leave the row unloaded so it stays visible without deck data. - } - - data.deck = std::move(pending.deck); - data.loadSucceeded = true; - data.lastModified = pending.lastModified; - recomputeDeckMetadata(data, false); - data.colorIdentity = std::move(pending.colorIdentity); - - emit dataChanged(index(row), index(row)); - emit deckLoaded(row); - - // Checked after applying at least one load, so a single slow application - // still makes progress instead of starving the queue. - if (timer.elapsed() >= LOAD_DRAIN_TIME_BUDGET_MS) { - break; - } - } - - if (!pendingLoads.isEmpty()) { - schedulePendingLoadDrain(); - } -} - /** * @brief Computes the color identity of a deck in WUBRG order. */ @@ -404,7 +325,7 @@ static QString computeColorIdentity(const LoadedDeck &deck) /** * @brief Recomputes all derived metadata of a row from its loaded deck. */ -void VisualDeckStorageModel::recomputeDeckMetadata(DeckPreviewData &data, bool recomputeColorIdentity) +void VisualDeckStorageModel::recomputeDeckMetadata(DeckPreviewData &data) { const DeckList &deckList = data.deck.deckList; @@ -413,9 +334,7 @@ void VisualDeckStorageModel::recomputeDeckMetadata(DeckPreviewData &data, bool r data.tags = deckList.getTags(); data.lastLoaded = QDateTime::fromString(deckList.getLastLoadedTimestamp()); data.bannerCard = deckList.getBannerCard(); - if (recomputeColorIdentity) { - data.colorIdentity = computeColorIdentity(data.deck); - } + data.colorIdentity = computeColorIdentity(data.deck); } void VisualDeckStorageModel::setFilePathForRow(int row, const QString &newFilePath) @@ -425,13 +344,9 @@ void VisualDeckStorageModel::setFilePathForRow(int row, const QString &newFilePa } DeckPreviewData &data = decks[row]; - rowByFilePath.remove(data.filePath); data.filePath = newFilePath; data.relativeFilePath = relativeFilePathFor(newFilePath, deckPath); data.folderPath = folderPathFor(newFilePath, deckPath); - if (!rowByFilePath.contains(newFilePath)) { - rowByFilePath.insert(newFilePath, row); - } } bool VisualDeckStorageModel::renameDeck(int row, const QString &newName) @@ -495,14 +410,7 @@ bool VisualDeckStorageModel::deleteFile(int row) } beginRemoveRows(QModelIndex(), row, row); - rowByFilePath.remove(filePath); decks.removeAt(row); - // Rows after the deleted one shift down by one. - for (auto it = rowByFilePath.begin(); it != rowByFilePath.end(); ++it) { - if (it.value() > row) { - --it.value(); - } - } endRemoveRows(); return true; } diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h index 330205356..a44e7412d 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h @@ -17,7 +17,6 @@ #include #include -#include #include #include #include @@ -39,14 +38,7 @@ enum LastModifiedRole, /**< QDateTime of the deck file's last modification. */ LastLoadedRole, /**< QDateTime when the deck was last loaded from the file. */ BannerCardNameRole, /**< Name of the deck's banner card. */ - BannerCardProviderIdRole, /**< Provider id of the deck's banner card. */ - /** - * @brief Whether the row passes the proxy's current search / tag / color filters. - * - * Not served by this model, but by VisualDeckStorageSortFilterProxyModel on top - * of it. Declared here so every role read through a proxy index stays unique. - */ - FilterMatchRole + BannerCardProviderIdRole /**< Provider id of the deck's banner card. */ }; } // namespace VisualDeckStorageRoles @@ -73,25 +65,11 @@ struct DeckPreviewData bool loadInProgress = false; ///< Whether the deck file is currently being loaded. }; -/** - * @brief One finished background deck load that has not been applied to the model yet. - */ -struct PendingDeckLoad -{ - QString filePath; ///< Identifies the row the result belongs to. - int generation; ///< Scan generation the load was started in. - bool ok = false; ///< Whether the file parsed successfully. - LoadedDeck deck; ///< The parsed deck, valid when ok. - QDateTime lastModified; ///< File modification time at load, valid when ok. - QString colorIdentity; ///< WUBRG color identity computed off the UI thread, valid when ok. -}; - /** * @brief The list model backing the Visual Deck Storage widget tree. * * Rows are in filesystem scan order; ordering and filtering are handled by - * VisualDeckStorageSortFilterProxyModel on top of this model. The proxy keeps - * every row and exposes each row's filter result through its FilterMatchRole. + * VisualDeckStorageSortFilterProxyModel on top of this model. */ class VisualDeckStorageModel : public QAbstractListModel { @@ -166,23 +144,13 @@ signals: private: void startScan(); void beginLoad(int row); - static void recomputeDeckMetadata(DeckPreviewData &data, bool recomputeColorIdentity = true); - void reindexFilePaths(); - void schedulePendingLoadDrain(); - -private slots: - void drainPendingLoads(); - -private: + static void recomputeDeckMetadata(DeckPreviewData &data); void setFilePathForRow(int row, const QString &newFilePath); QString deckPath; QList decks; - QHash rowByFilePath; ///< Maps each deck's file path to its row for O(1) lookups. - QStringList folderPaths; ///< All subdirectories of the deck folder, sorted. - int scanGeneration = 0; ///< Bumped on every scan so stale results are ignored. - QVector pendingLoads; ///< Finished background loads waiting to be applied. - bool drainScheduled = false; ///< Whether a queued drain pass is already pending. + QStringList folderPaths; ///< All subdirectories of the deck folder, sorted. + int scanGeneration = 0; ///< Bumped on every scan so stale results are ignored. }; #endif // VISUAL_DECK_STORAGE_MODEL_H diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp index 478431703..cce3ff6ce 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp @@ -1,7 +1,6 @@ #include "visual_deck_storage_quick_settings_widget.h" #include "../../../client/settings/cache_settings.h" -#include "../cards/card_size_widget.h" #include "visual_deck_storage_widget.h" #include diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp index baa5e5792..0580126c4 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp @@ -1,20 +1,23 @@ #include "visual_deck_storage_search_widget.h" +#include "../../../client/settings/cache_settings.h" +#include "../../../filters/deck_filter_string.h" #include "../../../filters/syntax_help.h" #include "../../pixel_map_generator.h" #include -#include +#include +#include /** - * @brief Constructs a search bar for filtering decks in the Visual Deck Storage. + * @brief Constructs a PrintingSelectorCardSearchWidget for searching cards by set name or set code. * - * Provides a search bar that allows users to search decks by filename or search - * expression, with a debounced timer to trigger the search after the user stops typing. + * This widget provides a search bar that allows users to search for cards by either their set name + * or set code. It uses a debounced timer to trigger the search action after the user stops typing. * - * @param parent The parent widget. + * @param parent The parent PrintingSelector widget that will handle the search results. */ -VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(QWidget *parent) : QWidget(parent) +VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(VisualDeckStorageWidget *parent) : parent(parent) { layout = new QHBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); @@ -37,5 +40,47 @@ VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(QWidget *parent) : searchDebounceTimer->start(300); // 300ms debounce }); - connect(searchDebounceTimer, &QTimer::timeout, this, [this] { emit searchTextChanged(searchBar->text()); }); + connect(searchDebounceTimer, &QTimer::timeout, parent, &VisualDeckStorageWidget::updateSearchFilter); +} + +/** + * @brief Retrieves the current text in the search bar. + * + * @return The text entered by the user in the search bar. + */ +QString VisualDeckStorageSearchWidget::getSearchText() +{ + return searchBar->text(); +} + +/** + * Converts the filepath into a relative filepath starting from the deck folder. + * If the file isn't in the deck folder, then this will just return the filename. + * + * @param filePath The filepath to convert into a relative filepath + */ +static QString toRelativeFilepath(const QString &filePath) +{ + QString deckPath = SettingsCache::instance().paths().getDeckPath(); + if (filePath.startsWith(deckPath)) { + return filePath.mid(deckPath.length()); + } + + QFileInfo fileInfo(filePath); + QString fileName = fileInfo.fileName(); + return fileName; +} + +void VisualDeckStorageSearchWidget::filterWidgets(QList widgets, const QString &searchText) +{ + const auto filterString = DeckFilterString(searchText); + + for (auto widget : widgets) { + const DeckSearchData searchData{.deck = &widget->deckLoader->getDeck(), + .filePath = widget->filePath, + .displayName = widget->getDisplayName(), + .relativeFilePath = toRelativeFilepath(widget->filePath)}; + + widget->filteredBySearch = !filterString.check(searchData); + } } diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.h index 7769ea911..2f3d81aeb 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.h @@ -2,32 +2,30 @@ * @file visual_deck_storage_search_widget.h * @ingroup VisualDeckStorageWidgets */ +//! \todo Document this file. #ifndef VISUAL_DECK_STORAGE_SEARCH_WIDGET_H #define VISUAL_DECK_STORAGE_SEARCH_WIDGET_H +#include "deck_preview/deck_preview_widget.h" + #include #include #include -class QTimer; - +class VisualDeckStorageWidget; class VisualDeckStorageSearchWidget : public QWidget { Q_OBJECT public: - explicit VisualDeckStorageSearchWidget(QWidget *parent); - -signals: - /** - * Emitted once the debounce timer fires after the user stopped typing. - * @param text The current contents of the search bar. - */ - void searchTextChanged(const QString &text); + explicit VisualDeckStorageSearchWidget(VisualDeckStorageWidget *parent); + QString getSearchText(); + void filterWidgets(QList widgets, const QString &searchText); private: QHBoxLayout *layout; + VisualDeckStorageWidget *parent; QLineEdit *searchBar; QTimer *searchDebounceTimer; }; diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp index c05da1cb3..8968f6cb3 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp @@ -104,21 +104,12 @@ void VisualDeckStorageSortFilterProxyModel::resort() sort(0); } -QVariant VisualDeckStorageSortFilterProxyModel::data(const QModelIndex &index, int role) const +bool VisualDeckStorageSortFilterProxyModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const { - if (role == VisualDeckStorageRoles::FilterMatchRole) { - if (!index.isValid()) { - return true; - } - const QModelIndex sourceIndex = mapToSource(index); - return rowMatches(sourceIndex.row()); + if (sourceParent.isValid()) { + return true; } - return QSortFilterProxyModel::data(index, role); -} - -bool VisualDeckStorageSortFilterProxyModel::rowMatches(int sourceRow) const -{ // If the match lists aren't sized to the current model yet, don't hide anything. if (sourceRow < 0 || sourceRow >= searchMatches.size() || sourceRow >= tagMatches.size() || sourceRow >= colorMatches.size()) { @@ -128,14 +119,6 @@ bool VisualDeckStorageSortFilterProxyModel::rowMatches(int sourceRow) const return searchMatches.at(sourceRow) && tagMatches.at(sourceRow) && colorMatches.at(sourceRow); } -bool VisualDeckStorageSortFilterProxyModel::filterAcceptsRow(int /*sourceRow*/, - const QModelIndex & /*sourceParent*/) const -{ - // Rows are never dropped: the filter result is exposed per row through - // FilterMatchRole, so views can keep their widgets alive and just hide them. - return true; -} - bool VisualDeckStorageSortFilterProxyModel::lessThan(const QModelIndex &left, const QModelIndex &right) const { const auto *source = deckSourceModel(); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.h index 7e771f6a9..d2842a02f 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.h @@ -6,10 +6,6 @@ * Owns all search / tag / color filter state and the sort order. Filtering is * evaluated against the model's data (never against widgets), so it can run * before any view exists and re-evaluate whenever deck data finishes loading. - * - * Rows are never removed by filtering. Instead, every row carries the - * FilterMatchRole, which views read to show or hide their widgets while keeping - * them alive; all rows stay in the proxy so they keep their sorted position. */ #ifndef VISUAL_DECK_STORAGE_SORT_FILTER_PROXY_MODEL_H @@ -51,8 +47,6 @@ public: explicit VisualDeckStorageSortFilterProxyModel(QObject *parent = nullptr); - [[nodiscard]] QVariant data(const QModelIndex &index, int role) const override; - void setSourceModel(QAbstractItemModel *model) override; /// @name Filter input setters (each re-evaluates the affected matches) @@ -83,7 +77,6 @@ protected: bool lessThan(const QModelIndex &left, const QModelIndex &right) const override; private: - [[nodiscard]] bool rowMatches(int sourceRow) const; void resizeMatchLists(); void updateSearchMatches(); void updateTagMatches(); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.cpp index 6289a4941..e4eedb078 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.cpp @@ -1,11 +1,20 @@ #include "visual_deck_storage_sort_widget.h" #include "../../../client/settings/cache_settings.h" -#include "visual_deck_storage_widget.h" +#include #include -VisualDeckStorageSortWidget::VisualDeckStorageSortWidget(VisualDeckStorageWidget *parent) : QWidget(parent) +/** + * @brief Constructs a PrintingSelectorCardSortWidget for searching cards by set name or set code. + * + * This widget provides a search bar that allows users to search for cards by either their set name + * or set code. It uses a debounced timer to trigger the search action after the user stops typing. + * + * @param parent The parent PrintingSelector widget that will handle the search results. + */ +VisualDeckStorageSortWidget::VisualDeckStorageSortWidget(VisualDeckStorageWidget *parent) + : parent(parent), sortOrder(Alphabetical) { layout = new QHBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); @@ -21,10 +30,12 @@ VisualDeckStorageSortWidget::VisualDeckStorageSortWidget(VisualDeckStorageWidget // Set the current sort order sortComboBox->setCurrentIndex(SettingsCache::instance().visualDeckStorage().getVisualDeckStorageSortingOrder()); + sortOrder = static_cast(sortComboBox->currentIndex()); - // Connect sorting change signal to persist the order and refresh the file list + // Connect sorting change signal to refresh the file list connect(sortComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, &VisualDeckStorageSortWidget::updateSortOrder); + connect(this, &VisualDeckStorageSortWidget::sortOrderChanged, parent, &VisualDeckStorageWidget::updateSortOrder); } void VisualDeckStorageSortWidget::retranslateUi() @@ -36,13 +47,10 @@ void VisualDeckStorageSortWidget::retranslateUi() // Clear and repopulate the ComboBox with translated items sortComboBox->clear(); - sortComboBox->addItem(tr("Sort Alphabetically (Deck Name)"), - VisualDeckStorageSortFilterProxyModel::SortOrder::ByName); - sortComboBox->addItem(tr("Sort Alphabetically (Filename)"), - VisualDeckStorageSortFilterProxyModel::SortOrder::Alphabetical); - sortComboBox->addItem(tr("Sort by Last Modified"), - VisualDeckStorageSortFilterProxyModel::SortOrder::ByLastModified); - sortComboBox->addItem(tr("Sort by Last Loaded"), VisualDeckStorageSortFilterProxyModel::SortOrder::ByLastLoaded); + sortComboBox->addItem(tr("Sort Alphabetically (Deck Name)"), ByName); + sortComboBox->addItem(tr("Sort Alphabetically (Filename)"), Alphabetical); + sortComboBox->addItem(tr("Sort by Last Modified"), ByLastModified); + sortComboBox->addItem(tr("Sort by Last Loaded"), ByLastLoaded); // Restore the current index sortComboBox->setCurrentIndex(oldIndex); @@ -51,13 +59,60 @@ void VisualDeckStorageSortWidget::retranslateUi() sortComboBox->blockSignals(false); } -VisualDeckStorageSortFilterProxyModel::SortOrder VisualDeckStorageSortWidget::currentSortOrder() const -{ - return static_cast(sortComboBox->currentIndex()); -} - void VisualDeckStorageSortWidget::updateSortOrder() { + sortOrder = static_cast(sortComboBox->currentIndex()); SettingsCache::instance().visualDeckStorage().setVisualDeckStorageSortingOrder(sortComboBox->currentIndex()); emit sortOrderChanged(); } + +void VisualDeckStorageSortWidget::sortFolder(VisualDeckStorageFolderDisplayWidget *folderWidget) +{ + auto children = + folderWidget->getFlowWidget()->findChildren(QString(), Qt::FindChildOption::FindDirectChildrenOnly); + for (auto widget : children) { + auto deckPreviewWidgets = + widget->findChildren(QString(), Qt::FindChildOption::FindDirectChildrenOnly); + auto newOrder = filterFiles(deckPreviewWidgets); + for (DeckPreviewWidget *previewWidget : newOrder) { + folderWidget->getFlowWidget()->removeWidget(previewWidget); + } + for (DeckPreviewWidget *previewWidget : newOrder) { + folderWidget->getFlowWidget()->addWidget(previewWidget); + } + } +} + +QList VisualDeckStorageSortWidget::filterFiles(QList widgets) +{ + // Sort the widgets list based on the current sort order + std::sort(widgets.begin(), widgets.end(), [this](DeckPreviewWidget *widget1, DeckPreviewWidget *widget2) { + if (!widget1 || !widget2) { + return false; // Handle null pointers gracefully + } + + QFileInfo info1(widget1->filePath); + QFileInfo info2(widget2->filePath); + + switch (sortOrder) { + case ByName: + return widget1->deckLoader->getDeck().deckList.getName() < + widget2->deckLoader->getDeck().deckList.getName(); + case Alphabetical: + return QString::localeAwareCompare(info1.fileName(), info2.fileName()) <= 0; + case ByLastModified: + return info1.lastModified() > info2.lastModified(); + case ByLastLoaded: { + QDateTime time1 = + QDateTime::fromString(widget1->deckLoader->getDeck().deckList.getLastLoadedTimestamp()); + QDateTime time2 = + QDateTime::fromString(widget2->deckLoader->getDeck().deckList.getLastLoadedTimestamp()); + return time1 > time2; + } + } + + return false; // Default case, no sorting applied + }); + + return widgets; +} diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.h index 633924d84..24eddba33 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.h @@ -2,17 +2,19 @@ * @file visual_deck_storage_sort_widget.h * @ingroup VisualDeckStorageWidgets */ +//! \todo Document this file. #ifndef VISUAL_DECK_STORAGE_SORT_WIDGET_H #define VISUAL_DECK_STORAGE_SORT_WIDGET_H -#include "visual_deck_storage_sort_filter_proxy_model.h" +#include "visual_deck_storage_widget.h" #include #include #include class VisualDeckStorageWidget; +class VisualDeckStorageFolderDisplayWidget; class VisualDeckStorageSortWidget : public QWidget { Q_OBJECT @@ -20,23 +22,25 @@ class VisualDeckStorageSortWidget : public QWidget public: explicit VisualDeckStorageSortWidget(VisualDeckStorageWidget *parent); void retranslateUi(); - - /** - * @brief The currently selected sort order. - */ - [[nodiscard]] VisualDeckStorageSortFilterProxyModel::SortOrder currentSortOrder() const; + void updateSortOrder(); + void sortFolder(VisualDeckStorageFolderDisplayWidget *folderWidget); + QString getSearchText(); + QList filterFiles(QList widgets); signals: - /** - * @brief Emitted when the user picks a different sort order. - */ void sortOrderChanged(); -private slots: - void updateSortOrder(); - private: + enum SortOrder + { + ByName, + Alphabetical, + ByLastModified, + ByLastLoaded, + }; QHBoxLayout *layout; + VisualDeckStorageWidget *parent; + SortOrder sortOrder; // Current sorting option QComboBox *sortComboBox; }; diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp index ba52cf8e9..c4c8d18a8 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp @@ -2,8 +2,6 @@ #include "../general/layout_containers/flow_widget.h" #include "deck_preview/deck_preview_tag_display_widget.h" -#include "visual_deck_storage_model.h" -#include "visual_deck_storage_sort_filter_proxy_model.h" #include "visual_deck_storage_widget.h" #include @@ -20,7 +18,7 @@ VisualDeckStorageTagFilterWidget::VisualDeckStorageTagFilterWidget(VisualDeckSto setFixedHeight(100); - flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); + auto *flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); layout->addWidget(flowWidget); } @@ -31,26 +29,45 @@ void VisualDeckStorageTagFilterWidget::showEvent(QShowEvent *event) refreshTags(); } -/** - * @brief The tags of all decks currently accepted by the proxy model. - */ -QSet VisualDeckStorageTagFilterWidget::gatherAllTags() const +void VisualDeckStorageTagFilterWidget::filterDecksBySelectedTags(const QList &deckPreviews) const { - QSet allTags; - auto *proxy = parent->proxyModel(); + QStringList selectedTags; + QStringList excludedTags; - for (int proxyRow = 0; proxyRow < proxy->rowCount(); ++proxyRow) { - const QModelIndex index = proxy->index(proxyRow, 0); - if (!index.data(VisualDeckStorageRoles::FilterMatchRole).toBool()) { - continue; - } - const QStringList deckTags = index.data(VisualDeckStorageRoles::TagsRole).toStringList(); - for (const QString &tag : deckTags) { - allTags.insert(tag); + // Collect selected and excluded tags + for (DeckPreviewTagDisplayWidget *tagWidget : findChildren()) { + switch (tagWidget->getState()) { + case TagState::Selected: + selectedTags.append(tagWidget->getTagName()); + break; + case TagState::Excluded: + excludedTags.append(tagWidget->getTagName()); + break; + default: + break; } } - return allTags; + // If no tags are selected or excluded, show all + if (selectedTags.isEmpty() && excludedTags.isEmpty()) { + for (DeckPreviewWidget *deckPreview : deckPreviews) { + deckPreview->filteredByTags = false; + } + return; + } + + for (DeckPreviewWidget *deckPreview : deckPreviews) { + QStringList deckTags = deckPreview->deckLoader->getDeck().deckList.getTags(); + + bool hasAllSelected = std::all_of(selectedTags.begin(), selectedTags.end(), + [&deckTags](const QString &tag) { return deckTags.contains(tag); }); + + bool hasAnyExcluded = std::any_of(excludedTags.begin(), excludedTags.end(), + [&deckTags](const QString &tag) { return deckTags.contains(tag); }); + + // Filter out if any excluded tag is present or if any selected tag is missing + deckPreview->filteredByTags = !(hasAllSelected && !hasAnyExcluded); + } } void VisualDeckStorageTagFilterWidget::refreshTags() @@ -63,6 +80,8 @@ void VisualDeckStorageTagFilterWidget::refreshTags() void VisualDeckStorageTagFilterWidget::removeTagsNotInList(const QSet &tags) { + auto *flowWidget = findChild(); + for (DeckPreviewTagDisplayWidget *tagWidget : findChildren()) { const QString &tagName = tagWidget->getTagName(); @@ -97,12 +116,20 @@ void VisualDeckStorageTagFilterWidget::addTagIfNotPresent(const QString &tag) auto *newTagWidget = new DeckPreviewTagDisplayWidget(this, tag); connect(newTagWidget, &DeckPreviewTagDisplayWidget::tagClicked, parent, &VisualDeckStorageWidget::updateTagFilter); + connect(newTagWidget, &DeckPreviewTagDisplayWidget::tagClicked, this, + &VisualDeckStorageTagFilterWidget::refreshTags); + auto *flowWidget = findChild(); flowWidget->addWidget(newTagWidget); } } void VisualDeckStorageTagFilterWidget::sortTags() { + auto *flowWidget = findChild(); + if (!flowWidget) { + return; + } + // Get all tag widgets QList tagWidgets = findChildren(); @@ -120,26 +147,19 @@ void VisualDeckStorageTagFilterWidget::sortTags() } } -QStringList VisualDeckStorageTagFilterWidget::selectedTags() const +QSet VisualDeckStorageTagFilterWidget::gatherAllTags() const { - QStringList selected; - for (DeckPreviewTagDisplayWidget *tagWidget : findChildren()) { - if (tagWidget->getState() == TagState::Selected) { - selected.append(tagWidget->getTagName()); - } - } - return selected; -} + QSet allTags; + QList deckWidgets = parent->findChildren(); -QStringList VisualDeckStorageTagFilterWidget::excludedTags() const -{ - QStringList excluded; - for (DeckPreviewTagDisplayWidget *tagWidget : findChildren()) { - if (tagWidget->getState() == TagState::Excluded) { - excluded.append(tagWidget->getTagName()); + for (DeckPreviewWidget *widget : deckWidgets) { + if (widget->checkVisibility()) { + for (const QString &tag : widget->deckLoader->getDeck().deckList.getTags()) { + allTags.insert(tag); + } } } - return excluded; + return allTags; } QStringList VisualDeckStorageTagFilterWidget::getAllKnownTags() const diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.h index 337c053c7..3290c9e9a 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.h @@ -2,22 +2,21 @@ * @file visual_deck_storage_tag_filter_widget.h * @ingroup VisualDeckStorageWidgets */ +//! \todo Document this file. #ifndef VISUAL_DECK_STORAGE_TAG_FILTER_WIDGET_H #define VISUAL_DECK_STORAGE_TAG_FILTER_WIDGET_H -#include -#include +#include "deck_preview/deck_preview_widget.h" + #include -class FlowWidget; class VisualDeckStorageWidget; class VisualDeckStorageTagFilterWidget : public QWidget { Q_OBJECT VisualDeckStorageWidget *parent; - FlowWidget *flowWidget; [[nodiscard]] QSet gatherAllTags() const; void removeTagsNotInList(const QSet &tags); @@ -28,21 +27,9 @@ class VisualDeckStorageTagFilterWidget : public QWidget public: explicit VisualDeckStorageTagFilterWidget(VisualDeckStorageWidget *_parent); [[nodiscard]] QStringList getAllKnownTags() const; - - /** - * @brief The tags currently in "selected" state. - */ - [[nodiscard]] QStringList selectedTags() const; - - /** - * @brief The tags currently in "excluded" state. - */ - [[nodiscard]] QStringList excludedTags() const; + void filterDecksBySelectedTags(const QList &deckPreviews) const; public slots: - /** - * @brief Rebuilds the tag chips from the tags of the currently visible decks. - */ void refreshTags(); void showEvent(QShowEvent *event) override; }; diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp index acb0dcab2..4b4dee55b 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp @@ -2,29 +2,24 @@ #include "../../../client/settings/cache_settings.h" #include "../quick_settings/settings_button_widget.h" -#include "deck_preview/deck_preview_color_identity_filter_widget.h" #include "deck_preview/deck_preview_widget.h" #include "visual_deck_storage_folder_display_widget.h" -#include "visual_deck_storage_quick_settings_widget.h" #include "visual_deck_storage_search_widget.h" #include "visual_deck_storage_sort_widget.h" #include "visual_deck_storage_tag_filter_widget.h" -#include -#include +#include +#include +#include #include #include #include #include -VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(parent) +VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(parent), folderWidget(nullptr) { - // The model and proxy own all deck data, sorting and filtering. The view widgets below - // only display the proxy's rows and their FilterMatchRole, so nothing touches the - // filesystem outside the model. - storageModel = new VisualDeckStorageModel(this); - storageProxyModel = new VisualDeckStorageSortFilterProxyModel(this); - storageProxyModel->setSourceModel(storageModel); + deckListModel = new DeckListModel(this); + deckListModel->setObjectName("visualDeckModel"); layout = new QVBoxLayout(this); layout->setSpacing(0); @@ -80,33 +75,6 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare layout->addWidget(tagFilterWidget); layout->addWidget(scrollArea); - // The deck data changed (a load finished, or a mutation happened): re-evaluate the filters, - // since search/tag/color matches are computed against the data. Debounced so that a burst of - // load completions triggers a single re-application instead of one O(n) pass per deck. - refreshTimer = new QTimer(this); - refreshTimer->setSingleShot(true); - refreshTimer->setInterval(150); - connect(refreshTimer, &QTimer::timeout, this, [this] { - storageProxyModel->reapplyFilters(); - // A batch of decks finished loading: re-gather the tag chips from the visible decks once - // the burst settles instead of on every individual load. - tagFilterWidget->refreshTags(); - }); - connect(storageModel, &QAbstractItemModel::dataChanged, this, [this] { refreshTimer->start(); }); - connect(storageModel, &VisualDeckStorageModel::deckLoaded, this, [this] { refreshTimer->start(); }); - // A deck's file path changed: re-apply the sort, since orders like "filename" depend on it. - connect(storageModel, &VisualDeckStorageModel::deckFilePathChanged, this, [this] { storageProxyModel->resort(); }); - connect(sortWidget, &VisualDeckStorageSortWidget::sortOrderChanged, this, - &VisualDeckStorageWidget::updateSortOrder); - // The filter widgets only own their ui state. Pushing it into the proxy model - // happens here, so the children stay decoupled from the model layer. - connect(deckPreviewColorIdentityFilterWidget, &DeckPreviewColorIdentityFilterWidget::activeColorsChanged, this, - &VisualDeckStorageWidget::updateColorFilter); - connect(deckPreviewColorIdentityFilterWidget, &DeckPreviewColorIdentityFilterWidget::filterModeChanged, this, - &VisualDeckStorageWidget::updateColorFilter); - connect(searchWidget, &VisualDeckStorageSearchWidget::searchTextChanged, this, - &VisualDeckStorageWidget::updateSearchFilter); - connect(CardDatabaseManager::getInstance(), &CardDatabase::cardDatabaseLoadingFinished, this, &VisualDeckStorageWidget::createRootFolderWidget); @@ -155,8 +123,6 @@ void VisualDeckStorageWidget::retranslateUi() refreshButton->setToolTip(tr("Refresh loaded files")); quickSettingsWidget->setToolTip(tr("Visual Deck Storage Settings")); - - sortWidget->retranslateUi(); } /** @@ -168,69 +134,72 @@ const VisualDeckStorageQuickSettingsWidget *VisualDeckStorageWidget::settings() } /** - * Reapplies all sort and filter options by updating the proxy model. + * Reapplies all sort and filter options by calling the appropriate update methods. */ void VisualDeckStorageWidget::reapplySortAndFilters() { - storageProxyModel->setSortOrder(sortWidget->currentSortOrder()); - storageProxyModel->reapplyFilters(); + updateSortOrder(); + updateTagFilter(); + updateColorFilter(); + updateSearchFilter(); } -/** - * @brief Scans the deck folder and rebuilds the folder tree of deck previews. - */ void VisualDeckStorageWidget::createRootFolderWidget() { - storageModel->setDeckPath(SettingsCache::instance().paths().getDeckPath()); - - folderWidget = - new VisualDeckStorageFolderDisplayWidget(this, this, QString(), false, quickSettingsWidget->getShowFolders()); + folderWidget = new VisualDeckStorageFolderDisplayWidget(this, this, SettingsCache::instance().paths().getDeckPath(), + false, quickSettingsWidget->getShowFolders()); scrollArea->setWidget(folderWidget); // this automatically destroys the old folderWidget scrollArea->widget()->setMaximumWidth(scrollArea->viewport()->width()); scrollArea->widget()->adjustSize(); - // Sort and filter runs against the model data, so it is safe to apply immediately. - reapplySortAndFilters(); + /* We have to schedule a QTimer here so that the sorting logic doesn't try to access widgets that haven't been + * processed by the event loop yet. Otherwise, deck sorting will intermittently segfault on some systems. + */ + QTimer::singleShot(0, this, &VisualDeckStorageWidget::reapplySortAndFilters); } void VisualDeckStorageWidget::updateShowFolders(bool enabled) { if (folderWidget) { folderWidget->updateShowFolders(enabled); + QTimer::singleShot(0, this, &VisualDeckStorageWidget::reapplySortAndFilters); } } void VisualDeckStorageWidget::updateSortOrder() { - storageProxyModel->setSortOrder(sortWidget->currentSortOrder()); + if (folderWidget) { + sortWidget->sortFolder(folderWidget); + for (VisualDeckStorageFolderDisplayWidget *subFolderWidget : + folderWidget->findChildren()) { + sortWidget->sortFolder(subFolderWidget); + } + } } void VisualDeckStorageWidget::updateTagFilter() { - const QStringList selected = tagFilterWidget->selectedTags(); - const QStringList excluded = tagFilterWidget->excludedTags(); - storageProxyModel->setTagFilter(QSet(selected.cbegin(), selected.cend()), - QSet(excluded.cbegin(), excluded.cend())); - // The visible deck set changed, so the chips are re-gathered from it. - tagFilterWidget->refreshTags(); + if (folderWidget) { + tagFilterWidget->filterDecksBySelectedTags(folderWidget->findChildren()); + folderWidget->updateVisibility(); + } } -/** - * Pushes the color identity filter widget's state into the proxy model. - */ void VisualDeckStorageWidget::updateColorFilter() { - storageProxyModel->setColorFilter(deckPreviewColorIdentityFilterWidget->getFilterMode(), - deckPreviewColorIdentityFilterWidget->getActiveColors()); + if (folderWidget) { + deckPreviewColorIdentityFilterWidget->filterWidgets(folderWidget->findChildren()); + folderWidget->updateVisibility(); + } } -/** - * Pushes the search bar's text into the proxy model. - */ -void VisualDeckStorageWidget::updateSearchFilter(const QString &text) +void VisualDeckStorageWidget::updateSearchFilter() { - storageProxyModel->setSearchText(text); + if (folderWidget) { + searchWidget->filterWidgets(folderWidget->findChildren(), searchWidget->getSearchText()); + folderWidget->updateVisibility(); + } } void VisualDeckStorageWidget::updateTagsVisibility(const bool visible) @@ -246,4 +215,4 @@ void VisualDeckStorageWidget::updateTagsVisibility(const bool visible) void VisualDeckStorageWidget::updateSelectionAnimationEnabled(const bool enabled) { deckPreviewSelectionAnimationEnabled = enabled; -} +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h index fe6389414..c3c0ae91b 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h @@ -2,30 +2,29 @@ * @file visual_deck_storage_widget.h * @ingroup VisualDeckStorageWidgets */ +//! \todo Document this file. #ifndef VISUAL_DECK_STORAGE_WIDGET_H #define VISUAL_DECK_STORAGE_WIDGET_H -#include "visual_deck_storage_model.h" -#include "visual_deck_storage_sort_filter_proxy_model.h" +#include "../../deck_loader/deck_loader.h" +#include "../cards/card_size_widget.h" +#include "../quick_settings/settings_button_widget.h" +#include "deck_preview/deck_preview_color_identity_filter_widget.h" +#include "visual_deck_storage_folder_display_widget.h" +#include "visual_deck_storage_quick_settings_widget.h" +#include "visual_deck_storage_search_widget.h" +#include "visual_deck_storage_sort_widget.h" +#include "visual_deck_storage_tag_filter_widget.h" -#include -#include -#include -#include -#include +#include -class QLabel; -class QResizeEvent; -class QShowEvent; -class QTimer; -class DeckPreviewColorIdentityFilterWidget; -class VisualDeckStorageFolderDisplayWidget; -class VisualDeckStorageQuickSettingsWidget; +class QSpinBox; class VisualDeckStorageSearchWidget; class VisualDeckStorageSortWidget; class VisualDeckStorageTagFilterWidget; - +class VisualDeckStorageFolderDisplayWidget; +class DeckPreviewColorIdentityFilterWidget; class VisualDeckStorageWidget final : public QWidget { Q_OBJECT @@ -38,43 +37,29 @@ public: bool deckPreviewSelectionAnimationEnabled; [[nodiscard]] const VisualDeckStorageQuickSettingsWidget *settings() const; - [[nodiscard]] VisualDeckStorageModel *model() const - { - return storageModel; - } - [[nodiscard]] VisualDeckStorageSortFilterProxyModel *proxyModel() const - { - return storageProxyModel; - } public slots: - /** - * @brief Starts scanning the deck folder and rebuilds the folder tree and previews. - */ - void createRootFolderWidget(); + void createRootFolderWidget(); // Refresh the display of cards based on the current sorting option void updateShowFolders(bool enabled); + void updateTagFilter(); + void updateColorFilter(); + void updateSearchFilter(); void updateTagsVisibility(bool visible); void updateSelectionAnimationEnabled(bool enabled); void updateSortOrder(); - void updateTagFilter(); - void updateColorFilter(); - void updateSearchFilter(const QString &text); - -signals: - void deckLoadRequested(const QString &filePath); - void openDeckEditor(const LoadedDeck &deck); - -protected: void resizeEvent(QResizeEvent *event) override; void showEvent(QShowEvent *event) override; -private: - void reapplySortAndFilters(); +signals: + void bannerCardsRefreshed(); + void deckLoadRequested(const QString &filePath); + void openDeckEditor(const LoadedDeck &deck); private: QVBoxLayout *layout; QWidget *searchAndSortContainer; QHBoxLayout *searchAndSortLayout; + DeckListModel *deckListModel; QLabel *databaseLoadIndicator; VisualDeckStorageSortWidget *sortWidget; VisualDeckStorageSearchWidget *searchWidget; @@ -82,10 +67,9 @@ private: QToolButton *refreshButton; VisualDeckStorageQuickSettingsWidget *quickSettingsWidget; QScrollArea *scrollArea; - VisualDeckStorageFolderDisplayWidget *folderWidget = nullptr; - VisualDeckStorageModel *storageModel = nullptr; - VisualDeckStorageSortFilterProxyModel *storageProxyModel = nullptr; - QTimer *refreshTimer = nullptr; ///< Coalesces the re-apply/refresh burst following a batch of deck loads. + VisualDeckStorageFolderDisplayWidget *folderWidget; + + void reapplySortAndFilters(); }; #endif // VISUAL_DECK_STORAGE_WIDGET_H diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 4567991c8..13c37473e 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -32,7 +32,6 @@ #include "../interface/widgets/dialogs/dlg_tip_of_the_day.h" #include "../interface/widgets/dialogs/dlg_update.h" #include "../interface/widgets/dialogs/dlg_view_log.h" -#include "../interface/widgets/onboarding/first_run_wizard.h" #include "../interface/widgets/tabs/tab_game.h" #include "../interface/widgets/tabs/tab_server.h" #include "../interface/widgets/tabs/tab_supervisor.h" @@ -351,7 +350,6 @@ void MainWindow::retranslateUi() aStatusBar->setText(tr("Show Status Bar")); aViewLog->setText(tr("View &Debug Log")); aOpenSettingsFolder->setText(tr("Open Settings Folder")); - aFirstRunWizard->setText(tr("Re-run Onboarding Wizard...")); aShow->setText(tr("Show/Hide")); @@ -413,8 +411,6 @@ void MainWindow::createActions() connect(aViewLog, &QAction::triggered, this, &MainWindow::actViewLog); aOpenSettingsFolder = new QAction(this); connect(aOpenSettingsFolder, &QAction::triggered, this, &MainWindow::actOpenSettingsFolder); - aFirstRunWizard = new QAction(this); - connect(aFirstRunWizard, &QAction::triggered, this, [this] { runFirstRunWizard(); }); aShow = new QAction(this); connect(aShow, &QAction::triggered, this, &MainWindow::actShow); @@ -493,8 +489,6 @@ void MainWindow::createMenus() helpMenu->addAction(aStatusBar); helpMenu->addAction(aViewLog); helpMenu->addAction(aOpenSettingsFolder); - helpMenu->addSeparator(); - helpMenu->addAction(aFirstRunWizard); } MainWindow::MainWindow(QWidget *parent) @@ -591,10 +585,9 @@ void MainWindow::startupConfigCheck() // no config found, 99% new clean install qCInfo(WindowMainStartupVersionLog) << "Startup: old client version empty, assuming first start after clean install"; + alertForcedOracleRun(VERSION_STRING, false); SettingsCache::instance().downloads().resetToDefaultURLs(); // populate the download urls SettingsCache::instance().network().setClientVersion(VERSION_STRING); - actCheckServerUpdates(); - runFirstRunWizard(); if (QString(VERSION_STRING).contains("custom", Qt::CaseInsensitive)) { SettingsCache::instance().updates().setCheckUpdatesOnStartup(false); @@ -674,21 +667,6 @@ void MainWindow::startupConfigCheck() } } -void MainWindow::runFirstRunWizard() -{ - auto *wizard = new FirstRunWizard(this); - wizard->setAttribute(Qt::WA_DeleteOnClose); - - connect(wizard, &FirstRunWizard::cardDatabaseUpdateRequested, this, &MainWindow::actCheckCardUpdatesBackground); - connect(wizard, &FirstRunWizard::manualCardDatabaseSetupRequested, this, &MainWindow::actCheckCardUpdates); - connect(this, &MainWindow::cardDatabaseUpdateFinished, wizard, &FirstRunWizard::onCardDatabaseUpdateFinished); - connect(wizard, &FirstRunWizard::registerRequested, connectionController, &ConnectionController::registerToServer); - connect(wizard, &FirstRunWizard::connectRequested, connectionController, &ConnectionController::connectToServer); - - wizard->setModal(true); - wizard->show(); -} - /** * Drives the server-based startup destinations (Server lobby, Server Room) through the intent * system: fetch saved credentials, connect to the configured server, then land on the Lobby or @@ -1050,7 +1028,6 @@ void MainWindow::createCardUpdateProcess(bool background) QMessageBox::warning(this, tr("Error"), tr("Unable to run the card database updater: ") + dir.absoluteFilePath(binaryName)); exitCardDatabaseUpdate(); - emit cardDatabaseUpdateFinished(false); return; } @@ -1064,9 +1041,6 @@ void MainWindow::createCardUpdateProcess(bool background) void MainWindow::exitCardDatabaseUpdate() { - if (!cardUpdateProcess) { - return; - } cardUpdateProcess->deleteLater(); cardUpdateProcess = nullptr; statusBar()->clearMessage(); @@ -1104,17 +1078,14 @@ void MainWindow::cardUpdateError(QProcess::ProcessError err) exitCardDatabaseUpdate(); QMessageBox::warning(this, tr("Error"), tr("The card database updater exited with an error:\n%1").arg(error)); - emit cardDatabaseUpdateFinished(false); } -void MainWindow::cardUpdateFinished(int exitCode, QProcess::ExitStatus exitStatus) +void MainWindow::cardUpdateFinished(int, QProcess::ExitStatus exitStatus) { - const bool success = (exitStatus == QProcess::NormalExit) && (exitCode == 0); if (exitStatus == QProcess::NormalExit) { SettingsCache::instance().updates().setLastCardUpdateCheck(QDateTime::currentDateTime().date()); } exitCardDatabaseUpdate(); - emit cardDatabaseUpdateFinished(success); } void MainWindow::actCheckServerUpdates() diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index 920145552..baacd3096 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -64,10 +64,6 @@ class IntentUrlParser; class MainWindow : public QMainWindow { Q_OBJECT -signals: - /** @brief Emitted after the background card-database update subprocess exits. */ - void cardDatabaseUpdateFinished(bool success); - public slots: void actCheckCardUpdates(); void actCheckCardUpdatesBackground(); @@ -129,9 +125,6 @@ private: void createTrayIcon(); int getNextCustomSetPrefix(QDir dataDir); - - void runFirstRunWizard(); - inline QString getCardUpdaterBinaryName() { return "oracle"; @@ -147,8 +140,8 @@ private: QAction *aConnect, *aDisconnect, *aRegister, *aForgotPassword, *aSinglePlayer, *aWatchReplay, *aFullScreen; QAction *aManageSets, *aEditTokens, *aOpenCustomFolder, *aOpenCustomsetsFolder, *aAddCustomSet, *aReloadCardDatabase; - QAction *aTips, *aUpdate, *aCheckCardUpdates, *aCheckCardUpdatesBackground, *aFirstRunWizard, *aStatusBar, - *aViewLog, *aOpenSettingsFolder; + QAction *aTips, *aUpdate, *aCheckCardUpdates, *aCheckCardUpdatesBackground, *aStatusBar, *aViewLog, + *aOpenSettingsFolder; TabSupervisor *tabSupervisor; IntentUrlParser *urlParser; diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index d8aa1cd08..814da9808 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -53,7 +53,6 @@ #include #include #include -#include QTranslator *translator, *qtTranslator; RNG_Abstract *rng; @@ -293,7 +292,7 @@ int main(int argc, char *argv[]) } } - rng = new RNG_SFMT(CryptoUtil::randomUInt64()); + rng = new RNG_SFMT; themeManager = new ThemeManager; soundEngine = new SoundEngine; @@ -391,7 +390,6 @@ int main(int argc, char *argv[]) PingPixmapGenerator::clear(); CountryPixmapGenerator::clear(); UserLevelPixmapGenerator::clear(); - ManaSymbolPixmapGenerator::clear(); return ret; } diff --git a/doc/doxygen/extra-pages/user_documentation/index.md b/doc/doxygen/extra-pages/user_documentation/index.md index 468a28f8d..d7e9d529d 100644 --- a/doc/doxygen/extra-pages/user_documentation/index.md +++ b/doc/doxygen/extra-pages/user_documentation/index.md @@ -11,11 +11,6 @@ - @subpage beta_release -## Troubleshooting - -- @subpage fixing_card_pictures -- @subpage enabling_debug_logs - ## Syntax Help - @subpage search_syntax_help diff --git a/doc/doxygen/extra-pages/user_documentation/troubleshooting/enabling_debug_logs.md b/doc/doxygen/extra-pages/user_documentation/troubleshooting/enabling_debug_logs.md deleted file mode 100644 index 7e4196a50..000000000 --- a/doc/doxygen/extra-pages/user_documentation/troubleshooting/enabling_debug_logs.md +++ /dev/null @@ -1,140 +0,0 @@ -@page enabling_debug_logs Enabling Debug Logs - -Cockatrice ships with a "diagnostics mode" that prints detailed debug messages about what the client is doing. This is -extremely useful when asking for help, because it shows exactly what happened: which URL the card picture loader tried, -whether it found or missed a file on disk, whether a download succeeded or failed, which redirects were followed, and -much more. Each of these messages belongs to a category, and you can enable or disable categories individually. - -Don't worry, this sounds more technical than it is. You only need to do two things: create one small text file, and tell -Cockatrice where it is. There are no installation steps and you can undo everything later (see [When you are -done](#when-you-are-done)). - -# Step 1: Create the file - -Open a plain text editor (Notepad on Windows, TextEdit on macOS, or any text editor on Linux) and paste the following -content: - -```ini -[Rules] -# The default log level is info -*.debug = false - -# Turn on debug level logs for the card picture loader and all its sub categories -card_picture_loader.* = true -``` - -Save the file with the exact name `qtlogging.ini` in a place you can find again, for example your Documents folder. - -\attention The file name matters: it must be `qtlogging.ini`, not `qtlogging.ini.txt`. If your text editor adds a -`.txt` extension automatically, you need to stop it from doing so (see below). On macOS, TextEdit must be switched to -plain text mode first via 'Format → Make Plain Text'. - -The file contains one rule per line. The `*.debug = false` rule turns off debug messages everywhere by default, and the -`card_picture_loader.* = true` line then re-enables them for the card picture loader. The `.*` at the end means "this -category and all of its sub categories". To enable a different category instead, just replace that line with the -category name of your choice, for example `card_database.loading = true` or `window_main.startup = true`. - -# Step 2: Tell Cockatrice where the file is - -Cockatrice does not know about the file yet. You have to point it there by setting an environment variable called -`QT_LOGGING_CONF` to the full location of your file. How to do this depends on your operating system: - -**Windows** - -1. Press the Windows key, type "environment variables", and open "Edit the system environment variables". -2. Click "Environment Variables...", then under "User variables" click "New...". -3. Set "Variable name" to `QT_LOGGING_CONF` and "Variable value" to the full path of your file, for example - `C:\Users\YourName\Documents\qtlogging.ini`. -4. Confirm all dialogs, then close and reopen Cockatrice. - -Alternatively, if Cockatrice is installed in a folder you can write to, you can simply place the `qtlogging.ini` file -directly next to the Cockatrice executable (in the same folder as `cockatrice.exe`) and skip the environment variable -altogether. Note that this copy may be replaced when you update the client. - -**macOS** - -Open the Terminal app (it is in 'Applications → Utilities') and run the following two commands, replacing the path -with the full location of your file: - -```shell -launchctl setenv QT_LOGGING_CONF /path/to/qtlogging.ini -open -a Cockatrice -``` - -You can also use `export QT_LOGGING_CONF=/path/to/qtlogging.ini` to set the variable for the current terminal session. - -The setting stays active until you log out or restart your Mac. If you have multiple users on the same Mac, be aware -that this setting only applies to your user account. - -**Linux** - -For a quick test, open a terminal and start Cockatrice with the file on the command line, replacing the path with the -full location of your file: - -```shell -QT_LOGGING_CONF=/path/to/qtlogging.ini cockatrice -``` - -If this works and you want it to apply every time you start Cockatrice, add the following line to your `~/.profile` -file and log in again: - -```shell -export QT_LOGGING_CONF="/path/to/qtlogging.ini" -``` - -# Step 3: See the logs - -Now that debug logging is enabled, open Cockatrice and trigger the behavior you are investigating, for example by -opening a deck, reloading the card database, or starting a game. - -The easiest way to see the logs is to use the built-in log viewer inside Cockatrice itself: open 'Help → View Debug -Log'. A window appears that shows the log messages live and keeps the most recent entries. It even has a 'Copy to -clipboard' button so you can paste the output into a bug report or a Discord message. This works the same on every -operating system. - -If you prefer to capture everything to a file instead, start Cockatrice with the `--debug-output` option: - -```shell -cockatrice --debug-output -``` - -Cockatrice then writes the full log to a file called `qdebug.txt` in the folder it was started from. - -# Which categories are available? - -Every message Cockatrice logs belongs to a category. The following table lists the most useful ones for troubleshooting, -grouped by area. Enable a category by adding a line like `category = true` to your `qtlogging.ini` file (or use a `.*` -suffix, e.g. `card_picture_loader.*`, to include all sub categories). - -| What you want to see | Categories | -| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| Card picture loading (URLs, local file hits/misses, downloads, redirects) | `card_picture_loader.*` | -| Card database loading and parsing | `card_database`, `card_database.loading`, `card_database.loading.success_or_failure`, `cockatrice_xml.*` | -| Card, set, and deck information | `card_info`, `card_list`, `deck_loader` | -| Startup sequence and update checks | `window_main.startup.*`, `release_channel`, `spoiler_background_updater` | -| User interface and themes | `theme_manager`, `sound_engine`, `flow_layout`, `flow_widget.*`, `pixel_map_generator`, `card_info_picture_widget` | -| Networking and servers | `local_client`, `remote_client`, `tapped_out_interface`, `servers_settings` | -| In-game logic | `player`, `game_scene.*`, `card_zone.*`, `view_zone`, `game_event_handler` | -| Dialogs and tabs | `dlg_settings`, `dlg_update`, `dlg_tip_of_the_day`, `tab_game`, `tab_message`, `tab_supervisor` | -| Settings and shortcuts | `settings_cache`, `shortcuts_settings` | -| Deck and card filtering | `filter_string`, `deck_filter_string`, `syntax_help` | - -For example, to investigate why a card database update seems to fail, enable the card database categories: - -```ini -[Rules] -*.debug = false - -card_database = true -card_database.loading = true -card_database.loading.success_or_failure = true -cockatrice_xml.* = true -``` - -# When you are done - -To turn the diagnostics back off, just reverse what you did: remove the `QT_LOGGING_CONF` environment variable (or -unset it again via `launchctl unsetenv QT_LOGGING_CONF` on macOS) and/or delete the `qtlogging.ini` file, then restart -Cockatrice. Leaving it on is harmless, but the extra logging can make the client slightly slower. - -For the full details on how Cockatrice logging works, including the complete list of categories, see @ref logging. diff --git a/doc/doxygen/extra-pages/user_documentation/troubleshooting/fixing_card_pictures.md b/doc/doxygen/extra-pages/user_documentation/troubleshooting/fixing_card_pictures.md deleted file mode 100644 index 78ba5586b..000000000 --- a/doc/doxygen/extra-pages/user_documentation/troubleshooting/fixing_card_pictures.md +++ /dev/null @@ -1,109 +0,0 @@ -@page fixing_card_pictures Fixing Card Pictures - -This guide collects the common causes for card pictures not showing up, showing the wrong printing, or showing the -card back instead of the artwork, and how to fix them. - -Work through the steps in order. In most cases the problem is caused by an outdated client, misconfigured download -URLs, stale local images, or a corrupted cache. - -# Update Your Client - -Picture handling bugs are fixed on a regular basis, so the first thing to try is updating your client. - -Use the update check in 'Help → Check for Updates' (or look for the update prompt shown on startup). - -If a fix has not yet made it into the latest stable release, it may already be available in the beta release. The beta -ships very frequently and usually receives a follow-up fix within a day or two if something breaks. - -See @subpage beta_release for instructions on how to switch to the beta channel. - -# Check Your Download URLs - -Card pictures are downloaded from a list of URL templates. Each template is tried in order until one produces a valid -image, so the order matters: URLs at the top of the list are tried first. - -The list can be found in 'Cockatrice → Settings' (or Ctrl + Shift + P by default), on the 'Deck Editor' tab, in the -'URL Download Priority' section. Make sure 'Download card pictures on the fly' is enabled and that the list contains -valid URLs. If you suspect the list has been modified or corrupted, press 'Reset Download URLs' to restore the -defaults. - -For information on how to add your own custom URL templates, see the 'How to add a custom URL' link in the same -settings section. - -# Check Your Local Picture Folder - -Before any network request is made, Cockatrice looks for local image files. If a matching file is found on disk it is -shown instead of anything downloaded, even if it is the wrong image. - -The pictures directory is configured on the 'General' settings tab, under 'Directories' → 'Pictures directory'. -Cockatrice checks the following locations, in order: - -- The custom pictures folder (recursively indexed by file name). -- `//` -- `/downloadedPics//` - -The following import naming schemes are recognized (using both `_` and `-` as separators): - -| Scheme | Pattern | -| --------------------------- | -------------------------- | -| Card Name + Provider ID | `{name}_{providerId}` | -| Card Name + Set + Collector | `{name}_{set}_{collector}` | -| Set + Collector + Card Name | `{set}_{collector}_{name}` | -| Card Name + Set | `{name}_{set}` | -| Card Name | `{name}` | - -If a picture you downloaded or placed manually is wrong, stale, or corrupted, delete the offending file. Pay special -attention to the `downloadedPics` subfolder: this is where the filesystem caching method writes downloaded images, and -after a provider outage it can permanently contain the wrong printing until you delete it manually. - -See @ref loading_card_pictures for details on how local images are loaded. - -# Clear Caches - -Cockatrice caches card pictures in three places. All of them can be managed on the 'Storage' settings tab: - -- **Network cache** — downloaded images stored on disk. Press 'Delete Cached Images' to clear it. -- **Filesystem / image backup** — downloaded images written directly to `downloadedPics`. Press 'Delete Saved Images' - to clear it. -- **In-memory (pixmap) cache** — images currently held in RAM. Press 'Clear In-Memory Images' to clear it. - -If a provider outage caused the wrong pictures to be downloaded and cached, clearing the network cache (and the -'Delete Saved Images' button if you use the filesystem caching method) will force Cockatrice to download the correct -images again. The redirect cache TTL (also on the Storage tab) controls how long previously seen redirects for -download URLs are remembered; lowering it can help if a URL used to redirect somewhere else. - -# Restart the Client - -After updating the client, changing the download URLs, moving or deleting local image files, or clearing caches, it is -recommended to restart Cockatrice so that all changes are fully picked up. - -# Check Logs - -Before changing any settings, check the existing logs first. Rate limit errors and most download errors are already -logged at warn level, so you may find the cause without enabling debug mode. - -Open 'Help → View Debug Log' and look for error or warning messages related to card picture loading. If you need more -detail than the default log level provides, see below. - -# Enable Picture Loader Debug Logs - -If the steps above did not solve the problem, you can turn on a "diagnostics mode" that prints what the picture loader -is actually doing: which URL it is trying, whether it found or missed a file on disk, whether the download succeeded or -failed, and which redirects it followed. This information is extremely useful when asking for help. - -See @subpage enabling_debug_logs for a step-by-step guide on how to enable the logs, including instructions -for Windows, macOS, and Linux. - -# Provider Accuracy - -Cards in Cockatrice are identified by a provider ID, which is the Scryfall UUID of a specific printing. Decks store -this ID, which is why the exact printing a card was added as can be looked up again. - -The Scryfall URL templates built into Cockatrice use this provider ID directly (`!set:uuid!`), so they always download -the exact printing that was requested. - -The Gatherer URL templates, on the other hand, do **not** use the provider ID. They resolve pictures by multiverse ID -(`!set:muid!`) or by card name (`!name!`) only. As a result they may return a different printing than the one the -provider ID refers to, or no picture at all for cards Gatherer does not know. If you need pictures to match the exact -printing of a card, make sure the Scryfall URLs are at the top of your download URL priority list and consider removing -or demoting the Gatherer URLs. diff --git a/format.sh b/format.sh index 9e3a6069b..3fa435be1 100755 --- a/format.sh +++ b/format.sh @@ -18,11 +18,11 @@ include=("cockatrice/src" \ libcockatrice_* \ "oracle/src" \ "servatrice/src" \ -"cmake/pch" \ "tests") exclude=("libcockatrice_rng/libcockatrice/rng/sfmt/" \ "libcockatrice_utility/libcockatrice/utility/peglib.h" \ "oracle/src/lzma/" \ +"oracle/src/qt-json/" \ "oracle/src/zip/" \ "servatrice/src/smtp/") exts=("cpp" "h" "proto") diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp index 91fe1874b..efe20595b 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp @@ -5,8 +5,6 @@ #include #include -static constexpr int MAX_DECK_SIZE = 1e5; - DecklistNodeTree::DecklistNodeTree() : root(new InnerDecklistNode()) { } @@ -115,7 +113,7 @@ void DecklistNodeTree::readZoneElement(QXmlStreamReader *xml) { QString zoneName = xml->attributes().value("name").toString(); InnerDecklistNode *newZone = getZoneObjFromName(zoneName); - totalCards += newZone->readElement(xml, MAX_DECK_SIZE - totalCards); + newZone->readElement(xml); } DecklistCardNode *DecklistNodeTree::addCard(const QString &cardName, @@ -127,8 +125,6 @@ DecklistCardNode *DecklistNodeTree::addCard(const QString &cardName, const QString &cardProviderId, const bool formatLegal) { - amount = qMin(amount, MAX_DECK_SIZE - totalCards); - totalCards += amount; auto *zoneNode = getZoneObjFromName(zoneName); auto *node = new DecklistCardNode(cardName, amount, zoneNode, position, cardSetName, cardSetCollectorNumber, cardProviderId, formatLegal); @@ -149,8 +145,7 @@ bool DecklistNodeTree::deleteNode(AbstractDecklistNode *node, InnerDecklistNode if (index != -1) { delete rootNode->takeAt(index); - // Empty custom zones are kept while empty board zones get pruned. - if (rootNode->empty() && rootNode->getParent() == root) { + if (rootNode->empty()) { deleteNode(rootNode, rootNode->getParent()); } @@ -193,157 +188,15 @@ void DecklistNodeTree::forEachCard(const std::functionsize(); i++) { auto *node = dynamic_cast(root->at(i)); - if (node && node->getName() == zoneName) { + if (node->getName() == zoneName) { return node; } } - if (auto *customZone = findCustomZoneByName(zoneName)) { - return customZone; - } - return new InnerDecklistNode(zoneName, root); } - -InnerDecklistNode *DecklistNodeTree::findBoardZone(const QString &boardZoneName) const -{ - return dynamic_cast(root->findChild(boardZoneName)); -} - -InnerDecklistNode *DecklistNodeTree::findOrCreateBoardZone(const QString &boardZoneName) -{ - auto *boardZone = findBoardZone(boardZoneName); - if (!boardZone && - (boardZoneName == DECK_ZONE_MAYBEBOARD || boardZoneName == DECK_ZONE_MAIN || boardZoneName == DECK_ZONE_SIDE)) { - // The boards are lazy zones: they only exist once cards or custom zones need them. - boardZone = new InnerDecklistNode(boardZoneName, root); - } - return boardZone; -} - -InnerDecklistNode *DecklistNodeTree::addCustomZone(const QString &boardZoneName, const QString &zoneName) -{ - if (hasZoneName(zoneName)) { - return nullptr; - } - - auto *boardZone = findOrCreateBoardZone(boardZoneName); - - if (!boardZone) { - return nullptr; - } - - return new InnerDecklistNode(zoneName, boardZone); -} - -bool DecklistNodeTree::renameCustomZone(const QString &oldZoneName, const QString &newZoneName) -{ - if (hasZoneName(newZoneName)) { - return false; - } - - auto *zone = findCustomZoneByName(oldZoneName); - if (!zone) { - return false; - } - - zone->setName(newZoneName); - return true; -} - -bool DecklistNodeTree::moveCustomZone(const QString &zoneName, const QString &newBoardZoneName) -{ - auto *zone = findCustomZoneByName(zoneName); - if (!zone) { - return false; - } - - auto *currentBoardZone = zone->getParent(); - if (currentBoardZone && currentBoardZone->getName() == newBoardZoneName) { - return true; - } - - auto *newBoardZone = findOrCreateBoardZone(newBoardZoneName); - if (!newBoardZone) { - return false; - } - - currentBoardZone->removeOne(zone); - newBoardZone->append(zone); - zone->setParent(newBoardZone); - return true; -} - -bool DecklistNodeTree::removeCustomZone(const QString &zoneName) -{ - auto *zone = findCustomZoneByName(zoneName); - if (!zone) { - return false; - } - - // Detach and delete without pruning the board zone. - auto *boardZone = zone->getParent(); - boardZone->removeOne(zone); - delete zone; - return true; -} - -QList DecklistNodeTree::getCustomZones(const QString &boardZoneName) const -{ - QList result; - - auto *boardZone = findBoardZone(boardZoneName); - if (!boardZone) { - return result; - } - - for (int i = 0; i < boardZone->size(); i++) { - if (auto *customZone = dynamic_cast(boardZone->at(i))) { - result.append(customZone); - } - } - - return result; -} - -InnerDecklistNode *DecklistNodeTree::findCustomZoneByName(const QString &zoneName) const -{ - for (int i = 0; i < root->size(); i++) { - auto *boardZone = dynamic_cast(root->at(i)); - if (!boardZone) { - continue; - } - - for (int j = 0; j < boardZone->size(); j++) { - auto *customZone = dynamic_cast(boardZone->at(j)); - if (customZone && customZone->getName() == zoneName) { - return customZone; - } - } - } - - return nullptr; -} - -bool DecklistNodeTree::hasZoneName(const QString &zoneName) const -{ - // The standard zones are reserved names even before they are created lazily. - if (zoneName == DECK_ZONE_MAIN || zoneName == DECK_ZONE_SIDE || zoneName == DECK_ZONE_MAYBEBOARD || - zoneName == DECK_ZONE_TOKENS) { - return true; - } - - if (root->findChild(zoneName)) { - return true; - } - - return findCustomZoneByName(zoneName) != nullptr; -} diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h index 5d91cd233..eae20aa23 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h @@ -10,7 +10,6 @@ class DecklistNodeTree { InnerDecklistNode *root; ///< Root of the deck tree (zones + cards). - int totalCards = 0; public: /** @brief Constructs an empty DecklistNodeTree. */ @@ -78,62 +77,6 @@ public: const bool formatLegal = true); bool deleteNode(AbstractDecklistNode *node, InnerDecklistNode *rootNode = nullptr); - /** - * @brief Creates a new custom zone nested under a board zone. - * - * Custom zone names must be unique across the whole deck so that cards can be - * added to a custom zone without specifying its board zone. - * - * @param boardZoneName Name of the board zone (e.g. DECK_ZONE_MAIN). - * @param zoneName Name of the custom zone. - * @return The created zone node, or nullptr if the name is already in use. - */ - InnerDecklistNode *addCustomZone(const QString &boardZoneName, const QString &zoneName); - - /** - * @brief Renames a custom zone. - * @return true on success, false if the zone was not found or the new name is taken. - */ - bool renameCustomZone(const QString &oldZoneName, const QString &newZoneName); - - /** - * @brief Moves a custom zone (and all its cards) to another board zone. - * @return true on success, false if the zone or the new board zone was not found. - */ - bool moveCustomZone(const QString &zoneName, const QString &newBoardZoneName); - - /** - * @brief Removes a custom zone and all its cards. - * @return true if the zone was found and removed. - */ - bool removeCustomZone(const QString &zoneName); - - /** - * @brief Gets all custom zones nested under a board zone. - * @param boardZoneName Name of the board zone. - * @return The custom zones, in insertion order. - */ - QList getCustomZones(const QString &boardZoneName) const; - - /** - * @brief Checks whether a zone name is taken anywhere in the deck. - * - * Covers the standard board names and any top-level or nested custom zone. - * @param zoneName The checked name. - * @return true if the name is reserved or already in use. - */ - bool hasZoneName(const QString &zoneName) const; - - /** - * @brief Finds a custom zone anywhere in the deck by name. - * - * Walks the children of every top-level zone, so a zone nested under any - * board (and not just the standard ones) is found. - * @param zoneName The zone name to find. - * @return The matching zone node, or nullptr if none exists. - */ - InnerDecklistNode *findCustomZoneByName(const QString &zoneName) const; - /** * @brief Applies a function to every card in the deck tree. This can modify the cards. * @@ -145,8 +88,6 @@ public: private: // Helpers for traversing the tree InnerDecklistNode *getZoneObjFromName(const QString &zoneName) const; - InnerDecklistNode *findBoardZone(const QString &boardZoneName) const; - InnerDecklistNode *findOrCreateBoardZone(const QString &boardZoneName); }; #endif // COCKATRICE_DECKLIST_NODE_TREE_H diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.cpp index 7200ede5f..705dfae4c 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.cpp @@ -34,15 +34,15 @@ bool AbstractDecklistCardNode::compareName(AbstractDecklistNode *other) const } } -int AbstractDecklistCardNode::readElement(QXmlStreamReader *xml, int /* limit */) +bool AbstractDecklistCardNode::readElement(QXmlStreamReader *xml) { while (!xml->atEnd()) { xml->readNext(); if (xml->isEndElement() && xml->name().toString() == "card") { - return 0; + return false; } } - return 0; + return true; } void AbstractDecklistCardNode::writeElement(QXmlStreamWriter *xml) @@ -60,4 +60,4 @@ void AbstractDecklistCardNode::writeElement(QXmlStreamWriter *xml) if (!getCardProviderId().isEmpty()) { xml->writeAttribute("uuid", getCardProviderId()); } -} +} \ No newline at end of file diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.h b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.h index 52dd56529..df903a168 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.h @@ -141,7 +141,7 @@ public: * * This supports loading deck files from Cockatrice’s XML format. */ - int readElement(QXmlStreamReader *xml, int limit) override; + bool readElement(QXmlStreamReader *xml) override; /** * @brief Serialize this node’s properties to XML. diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_node.h b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_node.h index 9c4290db0..a39f0e7b2 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_node.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_node.h @@ -142,12 +142,6 @@ public: return parent; } - /** @param newParent Reparent this node. The new parent takes ownership. */ - void setParent(InnerDecklistNode *newParent) - { - parent = newParent; - } - /** * @brief Compute the depth of this node in the tree. * @return Distance from the root (root = 0, children = 1, etc.). @@ -183,7 +177,7 @@ public: * Cockatrice deck XML format. * @{ */ - virtual int readElement(QXmlStreamReader *xml, int limit) = 0; + virtual bool readElement(QXmlStreamReader *xml) = 0; virtual void writeElement(QXmlStreamWriter *xml) = 0; /// @} }; diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp index d082b3cca..1f470695d 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp @@ -43,13 +43,6 @@ void InnerDecklistNode::setSortMethod(DeckSortMethod method) } } -const QList &InnerDecklistNode::boardZoneNames() -{ - static const QList names = {QString(DECK_ZONE_MAIN), QString(DECK_ZONE_SIDE), - QString(DECK_ZONE_MAYBEBOARD)}; - return names; -} - QString InnerDecklistNode::getVisibleName() const { return visibleNameFromName(name); @@ -94,9 +87,6 @@ AbstractDecklistNode *InnerDecklistNode::findCardChildByNameProviderIdAndNumber( int InnerDecklistNode::height() const { - if (isEmpty()) { - return 1; - } return at(0)->height() + 1; } @@ -151,31 +141,27 @@ bool InnerDecklistNode::compareName(AbstractDecklistNode *other) const } } -int InnerDecklistNode::readElement(QXmlStreamReader *xml, int limit) +bool InnerDecklistNode::readElement(QXmlStreamReader *xml) { - int totalCards = 0; while (!xml->atEnd()) { xml->readNext(); const QString childName = xml->name().toString(); if (xml->isStartElement()) { if (childName == "zone") { auto *newZone = new InnerDecklistNode(xml->attributes().value("name").toString(), this); - totalCards += newZone->readElement(xml, limit - totalCards); + newZone->readElement(xml); } else if (childName == "card") { - int amount = xml->attributes().value("number").toString().toInt(); - amount = qMin(amount, limit - totalCards); - auto *newCard = new DecklistCardNode(xml->attributes().value("name").toString(), amount, this, -1, - xml->attributes().value("setShortName").toString(), - xml->attributes().value("collectorNumber").toString(), - xml->attributes().value("uuid").toString()); - totalCards += amount; - totalCards += newCard->readElement(xml, limit - totalCards); + auto *newCard = new DecklistCardNode( + xml->attributes().value("name").toString(), xml->attributes().value("number").toString().toInt(), + this, -1, xml->attributes().value("setShortName").toString(), + xml->attributes().value("collectorNumber").toString(), xml->attributes().value("uuid").toString()); + newCard->readElement(xml); } } else if (xml->isEndElement() && (childName == "zone")) { - return totalCards; + return false; } } - return totalCards; + return true; } void InnerDecklistNode::writeElement(QXmlStreamWriter *xml) @@ -215,4 +201,4 @@ QVector> InnerDecklistNode::sort(Qt::SortOrder order) } return result; -} +} \ No newline at end of file diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h index 0d454c11e..f8fdedf30 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h @@ -18,9 +18,6 @@ #include "abstract_deck_list_node.h" -#include -#include - /** @brief Constant for the "main" deck zone name. */ #define DECK_ZONE_MAIN "main" /** @brief Constant for the "sideboard" zone name. */ @@ -121,13 +118,6 @@ public: */ static QString visibleNameFromName(const QString &_name); - /** - * @brief The standard board zone names, in display order. - * - * @return main, side and maybeboard. - */ - static const QList &boardZoneNames(); - /** * @brief Get this node’s display-friendly name. * @return Human-readable name (zone/group name). @@ -226,10 +216,9 @@ public: /** * @brief Deserialize this node and its children from XML. * @param xml Reader positioned at this element. - * @param limit The maximum amount of cards to read - * @return the amount of cards found + * @return true if parsing succeeded. */ - int readElement(QXmlStreamReader *xml, int limit) override; + bool readElement(QXmlStreamReader *xml) override; /** * @brief Serialize this node and its children to XML. diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp index aaf391c03..25e8e97db 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp @@ -5,7 +5,6 @@ #include #include #include -#include #include static peg::parser search(R"( @@ -20,7 +19,7 @@ SomewhatComplexQueryPart <- [(] QueryPartList [)] / QueryPart QueryPart <- NotQuery / SetQuery / RarityQuery / CMCQuery / FormatQuery / PowerQuery / ToughnessQuery / ColorQuery / TypeQuery / OracleQuery / FieldQuery / GenericQuery NotQuery <- ('NOT' ws/'-') SomewhatComplexQueryPart -SetQuery <- ('e'/'set') SetExpression / ([:] FlexStringValue) +SetQuery <- ('e'/'set') [:] FlexStringValue OracleQuery <- 'o' [:] MatcherString @@ -65,8 +64,6 @@ RegexMatcherString <- ('\\/' / !'/' .)+ FlexStringValue <- CompactStringSet / String / [(] StringList [)] CompactStringSet <- StringListString ([,+] StringListString)+ -SetExpression <- NumericOperator ws? String - NumericExpression <- NumericOperator ws? NumericValue NumericOperator <- [=:] / <[> NumericValue <- [0-9]+ @@ -104,25 +101,12 @@ static void setupParserRules() return [=](const CardData &x) -> bool { return matcher(x->getCardType()); }; }; search["SetQuery"] = [](const peg::SemanticValues &sv) -> Filter { - if (sv.choice() == 1) { - auto matcher = std::any_cast(sv[0]); - return [=](const CardData &x) -> bool { - QList sets = x->getSets().keys(); - - auto matchesSet = [&matcher](const QString &set) { return matcher(set); }; - return std::any_of(sets.begin(), sets.end(), matchesSet); - }; - } - - auto matcher = std::any_cast(sv[0]); + auto matcher = std::any_cast(sv[0]); return [=](const CardData &x) -> bool { - const auto &sets = x->getSets().values(); - auto matchesSet = [&](const PrintingInfo &printing) { - return printing.getSet()->getEnabled() && matcher(printing.getSet()->getReleaseDate().toJulianDay()); - }; - return std::any_of(sets.begin(), sets.end(), [&](const auto &printings) { - return std::any_of(printings.begin(), printings.end(), matchesSet); - }); + QList sets = x->getSets().keys(); + + auto matchesSet = [&matcher](const QString &set) { return matcher(set); }; + return std::any_of(sets.begin(), sets.end(), matchesSet); }; }; search["Rarity"] = [](const peg::SemanticValues &sv) -> QString { @@ -263,54 +247,40 @@ static void setupParserRules() return QString::fromStdString(std::string(sv.sv())); }; - search["NumericOperator"] = [](const peg::SemanticValues &sv) -> NumberComparer { - const auto op = QString::fromStdString(std::string(sv.sv())); + search["NumericExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher { + const auto arg = std::any_cast(sv[1]); + const auto op = std::any_cast(sv[0]); if (op == ">") { - return [=](const int s, const int arg) { return s > arg; }; + return [=](const int s) { return s > arg; }; } if (op == ">=") { - return [=](const int s, const int arg) { return s >= arg; }; + return [=](const int s) { return s >= arg; }; } if (op == "<") { - return [=](const int s, const int arg) { return s < arg; }; + return [=](const int s) { return s < arg; }; } if (op == "<=") { - return [=](const int s, const int arg) { return s <= arg; }; + return [=](const int s) { return s <= arg; }; } if (op == "=") { - return [=](const int s, const int arg) { return s == arg; }; + return [=](const int s) { return s == arg; }; } if (op == ":") { - return [=](const int s, const int arg) { return s == arg; }; + return [=](const int s) { return s == arg; }; } if (op == "!=") { - return [=](const int s, const int arg) { return s != arg; }; + return [=](const int s) { return s != arg; }; } - return [](int, int) { return false; }; + return [](int) { return false; }; }; search["NumericValue"] = [](const peg::SemanticValues &sv) -> int { return QString::fromStdString(std::string(sv.sv())).toInt(); }; - search["NumericExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher { - const auto comparer = std::any_cast(sv[0]); - const auto arg = std::any_cast(sv[1]); - return [=](int s) { return comparer(s, arg); }; - }; - - search["SetExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher { - const auto comparer = std::any_cast(sv[0]); - const auto setCode = std::any_cast(sv[1]); - const auto allSets = CardDatabaseManager::getInstance()->getSetList(); - for (auto &set : allSets) { - if (set->getShortName() == setCode) { - const int releaseDate = set->getReleaseDate().toJulianDay(); - return [=](int s) { return comparer(s, releaseDate); }; - } - } - return [](int) { return false; }; + search["NumericOperator"] = [](const peg::SemanticValues &sv) -> QString { + return QString::fromStdString(std::string(sv.sv())); }; search["NormalMatcher"] = [](const peg::SemanticValues &sv) -> StringMatcher { diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.h b/libcockatrice_filters/libcockatrice/filters/filter_string.h index a058f7d07..71a99f7b5 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.h @@ -22,7 +22,6 @@ typedef CardInfoPtr CardData; typedef std::function Filter; typedef std::function StringMatcher; typedef std::function NumberMatcher; -typedef std::function NumberComparer; namespace peg { diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h index 054c4cd72..a81616cb0 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h @@ -21,7 +21,6 @@ public: [[nodiscard]] virtual bool getTabLogOpen() const = 0; [[nodiscard]] virtual bool getTabReportOpen() const = 0; [[nodiscard]] virtual bool getTabModerationOpen() const = 0; - [[nodiscard]] virtual bool getTabCardArtRulesOpen() const = 0; }; #endif // COCKATRICE_INTERFACE_TABS_SETTINGS_PROVIDER_H diff --git a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt index a6ab2a204..d4aee3686 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt +++ b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt @@ -7,8 +7,7 @@ set(HEADERS deck_list_model.h deck_list_sort_filter_proxy_model.h) qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) add_library( - libcockatrice_models_deck_list STATIC ${MOC_SOURCES} deck_list_model.cpp deck_list_model_custom_zones.cpp - deck_list_sort_filter_proxy_model.cpp + libcockatrice_models_deck_list STATIC ${MOC_SOURCES} deck_list_model.cpp deck_list_sort_filter_proxy_model.cpp ) target_include_directories(libcockatrice_models_deck_list PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp index 76afca0c4..9b43281c1 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp @@ -66,8 +66,7 @@ void DeckListModel::rebuildTree() for (int j = 0; j < currentZone->size(); j++) { auto *currentCard = dynamic_cast(currentZone->at(j)); - // Non-card children are custom zones; they are mirrored in a single - // pass below so each is mirrored exactly once. + //! \todo Better sanity checking. if (currentCard == nullptr) { continue; } @@ -83,19 +82,8 @@ void DeckListModel::rebuildTree() new DecklistModelCardNode(currentCard, groupNode); } - - // Custom zones nested under the board zone are mirrored as-is, with their - // cards as direct children (no further grouping). - DeckListModelCustomZones::mirrorCustomZones(currentZone, node); } - // The shadow tree was built in deck file order. Apply the active sort while - // the reset is still open so every consumer (tree view and visual editor) - // sees the canonical order from the start. sortShadowTree emits no signals, - // which is only valid before endResetModel closes the reset. - root->setSortMethod(lastKnownColumn == 0 ? DeckSortMethod::ByNumber : DeckSortMethod::ByName); - sortShadowTree(root, lastKnownOrder); - endResetModel(); refreshCardFormatLegalities(); @@ -166,9 +154,6 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const case DeckRoles::IsLegalRole: return true; - case DeckRoles::IsCustomZoneRole: - return DeckListModelCustomZones::isCustomZone(group); - default: return {}; } @@ -205,10 +190,6 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const return card->getFormatLegality(); } - case DeckRoles::IsCustomZoneRole: { - return false; - } - default: { return {}; } @@ -346,13 +327,6 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent) return false; } - // Custom zone rows are managed through the deck tree, never removed as model rows. - for (int i = 0; i < count; i++) { - if (DeckListModelCustomZones::isCustomZone(node->at(row + i))) { - return false; - } - } - beginRemoveRows(parent, row, row + count - 1); for (int i = 0; i < count; i++) { AbstractDecklistNode *toDelete = node->takeAt(row); @@ -363,8 +337,7 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent) } endRemoveRows(); - // Empty criteria groups get pruned, but custom zones stay until explicitly deleted. - if (node->empty() && (node != root) && !DeckListModelCustomZones::isCustomZone(node)) { + if (node->empty() && (node != root)) { removeRows(parent.row(), 1, parent.parent()); } else { emitRecursiveUpdates(parent); @@ -378,8 +351,7 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent) InnerDecklistNode *DeckListModel::createNodeIfNeeded(const QString &name, InnerDecklistNode *parent) { - // Group lookups must not resolve a mirrored custom zone that shares the name. - auto *newNode = DeckListModelCustomZones::findGroupChild(parent, name); + auto *newNode = dynamic_cast(parent->findChild(name)); if (!newNode) { beginInsertRows(nodeToIndex(parent), parent->size(), parent->size()); newNode = new InnerDecklistNode(name, parent); @@ -393,44 +365,24 @@ DecklistModelCardNode *DeckListModel::findCardNode(const QString &cardName, const QString &providerId, const QString &cardNumber) const { + InnerDecklistNode *zoneNode = dynamic_cast(root->findChild(zoneName)); + if (!zoneNode) { + return nullptr; + } + CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(cardName); if (!info) { return nullptr; } - // 1. Board zone lookup: search the criteria groups, then the custom zones - // nested under the board. - if (auto *zoneNode = dynamic_cast(root->findChild(zoneName))) { - QString groupCriteria = extractGroupCriteriaValue(info, activeGroupCriteria); - if (auto *groupNode = DeckListModelCustomZones::findGroupChild(zoneNode, groupCriteria)) { - if (auto *card = dynamic_cast( - groupNode->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber))) { - return card; - } - } - - for (auto *child : *zoneNode) { - if (!DeckListModelCustomZones::isCustomZone(child)) { - continue; - } - auto *customZone = dynamic_cast(child); - if (!customZone) { - continue; - } - if (auto *card = dynamic_cast( - customZone->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber))) { - return card; - } - } + QString groupCriteria = extractGroupCriteriaValue(info, activeGroupCriteria); + InnerDecklistNode *groupNode = dynamic_cast(zoneNode->findChild(groupCriteria)); + if (!groupNode) { + return nullptr; } - // 2. Custom zone lookup by name (custom zone names are deck-unique). - if (auto *customZone = DeckListModelCustomZones::findSubZoneByName(root, zoneName)) { - return dynamic_cast( - customZone->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber)); - } - - return nullptr; + return dynamic_cast( + groupNode->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber)); } QModelIndex DeckListModel::findCard(const QString &cardName, @@ -471,95 +423,29 @@ QModelIndex DeckListModel::addCard(const ExactCard &card, const QString &zoneNam return {}; } + InnerDecklistNode *zoneNode = createNodeIfNeeded(zoneName, root); + CardInfoPtr cardInfo = card.getCardPtr(); PrintingInfo printingInfo = card.getPrinting(); - InnerDecklistNode *cardParent = nullptr; + QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria); + InnerDecklistNode *groupNode = createNodeIfNeeded(groupCriteria, zoneNode); - auto *boardNode = dynamic_cast(root->findChild(zoneName)); - auto *customZoneNode = boardNode ? nullptr : DeckListModelCustomZones::findSubZoneByName(root, zoneName); - - // Mirroring flattens nested deck sub-zones into shadow rows, so a shadow row - // index is only usable as a deck-tree position while both sides have the same - // direct-children shape. When they diverge, the card is appended to the deck - // zone instead of being written out of range. - InnerDecklistNode *deckCardParent = nullptr; - bool customZoneNeedsAppend = false; - - if (boardNode) { - // Board zone: cards are grouped by the active criteria. - QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria); - cardParent = createNodeIfNeeded(groupCriteria, boardNode); - } else if (customZoneNode) { - // Custom zone: cards live flat inside the zone. - cardParent = customZoneNode; - auto *listRoot = deckList->getTree()->getRoot(); - for (int i = 0; i < listRoot->size(); ++i) { - auto *boardZone = dynamic_cast(listRoot->at(i)); - if (!boardZone) { - continue; - } - deckCardParent = dynamic_cast(boardZone->findChild(zoneName)); - if (deckCardParent) { - break; - } - } - // A deck custom zone holding nested sub-zones mirrors with flattened rows, - // so a shadow row index does not map onto its direct children. - if (deckCardParent) { - for (int i = 0; i < deckCardParent->size(); ++i) { - if (dynamic_cast(deckCardParent->at(i))) { - customZoneNeedsAppend = true; - break; - } - } - } - } else { - // Not present in the shadow tree. The deck tree may still hold a custom - // zone that has not been mirrored (callers can add a zone and then a - // card without a rebuild). Check before falling back to creating a - // top-level zone the deck does not actually have. - auto *listRoot = deckList->getTree()->getRoot(); - bool hasDeckZone = false; - for (int i = 0; i < listRoot->size(); ++i) { - if (auto *boardZone = dynamic_cast(listRoot->at(i))) { - // Only real zones count: a card sitting directly under the board - // shares the name comparison but is not a zone, and treating it as - // one would recurse forever without mirroring anything. - if (dynamic_cast(boardZone->findChild(zoneName))) { - hasDeckZone = true; - break; - } - } - } - - if (hasDeckZone) { - rebuildTree(); - return addCard(card, zoneName); - } - - // Unknown zone: create a top-level zone (legacy behavior). - QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria); - auto *newZone = createNodeIfNeeded(zoneName, root); - cardParent = createNodeIfNeeded(groupCriteria, newZone); - } - - const QModelIndex parentIndex = nodeToIndex(cardParent); - auto *cardNode = dynamic_cast(cardParent->findCardChildByNameProviderIdAndNumber( + const QModelIndex parentIndex = nodeToIndex(groupNode); + auto *cardNode = dynamic_cast(groupNode->findCardChildByNameProviderIdAndNumber( card.getName(), printingInfo.getUuid(), printingInfo.getProperty("num"))); const auto cardSetName = printingInfo.getSet().isNull() ? "" : printingInfo.getSet()->getCorrectedShortName(); bool cardNodeAdded = false; if (!cardNode) { // Determine the correct index - int insertRow = findSortedInsertRow(cardParent, cardInfo); - int deckInsertRow = customZoneNeedsAppend ? -1 : insertRow; + int insertRow = findSortedInsertRow(groupNode, cardInfo); - auto *decklistCard = deckList->addCard(cardInfo->getName(), zoneName, deckInsertRow, cardSetName, + auto *decklistCard = deckList->addCard(cardInfo->getName(), zoneName, insertRow, cardSetName, printingInfo.getProperty("num"), printingInfo.getProperty("uuid")); beginInsertRows(parentIndex, insertRow, insertRow); - cardNode = new DecklistModelCardNode(decklistCard, cardParent, insertRow); + cardNode = new DecklistModelCardNode(decklistCard, groupNode, insertRow); endInsertRows(); cardNodeAdded = true; @@ -690,41 +576,21 @@ QModelIndex DeckListModel::nodeToIndex(AbstractDecklistNode *node) const return createIndex(node->getParent()->indexOf(node), 0, node); } -/** - * @brief Sorts a freshly built shadow subtree without emitting model signals. - * - * Used by rebuildTree while the model reset is still open (emitting layout - * changes during a reset is invalid). Reorders every node just like - * sortHelper does, but ignores the movement mapping because there are no - * persistent indices established yet. - */ -void DeckListModel::sortShadowTree(InnerDecklistNode *node, Qt::SortOrder order) -{ - // The mapping is not needed: fresh shadow nodes have no persistent indices yet. - (void)DeckListModelCustomZones::sortWithCustomZonesLast(root, node, order); - - for (int i = node->size() - 1; i >= 0; --i) { - if (auto *subNode = dynamic_cast(node->at(i))) { - sortShadowTree(subNode, order); - } - } -} - void DeckListModel::sortHelper(InnerDecklistNode *node, Qt::SortOrder order) { - // Sort children (custom zones always sorted after groups within a board) and - // use the movement mapping to update the list of persistent indices. - const auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(root, node, order); + // Sort children of node and save the information needed to + // update the list of persistent indexes. + QVector> sortResult = node->sort(order); QModelIndexList from, to; int columns = columnCount(); - for (const auto &move : mapping) { - const int preSortRow = move.first; - const int finalRow = move.second; - AbstractDecklistNode *temp = node->at(finalRow); + for (int i = sortResult.size() - 1; i >= 0; --i) { + const int fromRow = sortResult[i].first; + const int toRow = sortResult[i].second; + AbstractDecklistNode *temp = node->at(toRow); for (int j = 0; j < columns; ++j) { - from << createIndex(preSortRow, j, temp); - to << createIndex(finalRow, j, temp); + from << createIndex(fromRow, j, temp); + to << createIndex(toRow, j, temp); } } changePersistentIndexList(from, to); @@ -838,15 +704,6 @@ QList DeckListModel::getZones() const return zones; } -QStringList DeckListModel::getCustomZoneNames(const QString &boardZoneName) const -{ - QStringList zoneNames; - for (const auto *customZone : deckList->getTree()->getCustomZones(boardZoneName)) { - zoneNames.append(customZone->getName()); - } - return zoneNames; -} - static int maxAllowedForLegality(const FormatRules &format, const QString &legality) { for (const AllowedCount &c : format.allowedCounts) { diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h index 09600ca67..209ec8c42 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h @@ -1,8 +1,6 @@ #ifndef DECKLISTMODEL_H #define DECKLISTMODEL_H -#include "deck_list_model_custom_zones.h" - #include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.h> #include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.h> #include @@ -32,8 +30,7 @@ enum { IsCardRole = Qt::UserRole + 1, /**< Indicates whether the item represents a card. */ DepthRole, /**< Depth level within the deck's grouping hierarchy. */ - IsLegalRole, /**< Whether the card is legal in the current deck format. */ - IsCustomZoneRole /**< Whether the item represents a custom zone nested under a board zone. */ + IsLegalRole /**< Whether the card is legal in the current deck format. */ }; } // namespace DeckRoles @@ -394,14 +391,6 @@ public: */ [[nodiscard]] QList getZones() const; - /** - * @brief Gets the names of the custom zones nested under the given board zone. - * - * @param boardZoneName The board zone to query (main/side/maybeboard) - * @return The custom zone names, in deck order - */ - [[nodiscard]] QStringList getCustomZoneNames(const QString &boardZoneName) const; - private: QSharedPointer deckList; /**< Pointer to the decklist providing the underlying data. */ InnerDecklistNode *root; /**< Root node of the model tree. */ @@ -438,7 +427,6 @@ private: void emitRecursiveUpdates(const QModelIndex &index); void sortHelper(InnerDecklistNode *node, Qt::SortOrder order); - void sortShadowTree(InnerDecklistNode *node, Qt::SortOrder order); template T getNode(const QModelIndex &index) const { diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp deleted file mode 100644 index 1dc745e63..000000000 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp +++ /dev/null @@ -1,152 +0,0 @@ -#include "deck_list_model_custom_zones.h" - -#include "deck_list_model.h" - -#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.h> -#include -#include - -namespace DeckListModelCustomZones -{ - -bool isCustomZone(const AbstractDecklistNode *node) -{ - return dynamic_cast(node) != nullptr; -} - -namespace -{ - -/** - * @brief Flattens every card under @p zone into @p shadowZone, preserving order. - * - * Custom zones mirror as a single row level: cards nested in sub-zones of any - * depth are added as direct children of the mirrored zone so no card is left - * without a model row. - */ -void flattenCards(const InnerDecklistNode *zone, InnerDecklistNode *shadowZone) -{ - for (int k = 0; k < zone->size(); k++) { - if (auto *zoneCard = dynamic_cast(zone->at(k))) { - new DecklistModelCardNode(zoneCard, shadowZone); - } else if (auto *subZone = dynamic_cast(zone->at(k))) { - flattenCards(subZone, shadowZone); - } - } -} - -} // namespace - -void mirrorCustomZones(const InnerDecklistNode *deckBoardZone, InnerDecklistNode *shadowBoardZone) -{ - for (int j = 0; j < deckBoardZone->size(); j++) { - auto *customZone = dynamic_cast(deckBoardZone->at(j)); - if (!customZone) { - continue; - } - - auto *shadowZone = new DecklistModelSubZoneNode(customZone->getName(), shadowBoardZone); - flattenCards(customZone, shadowZone); - } -} - -InnerDecklistNode *findGroupChild(InnerDecklistNode *parent, const QString &name) -{ - for (int i = 0; i < parent->size(); i++) { - AbstractDecklistNode *child = parent->at(i); - if (isCustomZone(child)) { - continue; - } - auto *group = dynamic_cast(child); - if (group && group->getName() == name) { - return group; - } - } - return nullptr; -} - -DecklistModelSubZoneNode *findSubZoneByName(InnerDecklistNode *root, const QString &zoneName) -{ - for (int i = 0; i < root->size(); i++) { - auto *boardZone = dynamic_cast(root->at(i)); - if (!boardZone) { - continue; - } - - for (int j = 0; j < boardZone->size(); j++) { - auto *customZone = dynamic_cast(boardZone->at(j)); - if (customZone && customZone->getName() == zoneName) { - return customZone; - } - } - } - - return nullptr; -} - -namespace -{ - -/** - * @brief Sorts a node's children and returns the (preSortRow, finalRow) mapping. - */ -QList> plainSort(InnerDecklistNode *node, Qt::SortOrder order) -{ - const QVector> sortResult = node->sort(order); - - QList> mapping; - mapping.reserve(node->size()); - for (int i = 0; i < node->size(); ++i) { - mapping.append({sortResult[i].first, i}); - } - return mapping; -} - -/** - * @brief Sorts a board zone's children, then stably moves custom zones to the end. - * - * @return The (preSortRow, finalRow) mapping covering both the sort and the shift. - */ -QList> boardSort(InnerDecklistNode *node, Qt::SortOrder order) -{ - const QVector> sortResult = node->sort(order); - - QVector groups; - QVector customZones; - QHash preSortRowOf; - - groups.reserve(node->size()); - customZones.reserve(node->size()); - - for (int i = 0; i < node->size(); ++i) { - AbstractDecklistNode *child = node->at(i); - preSortRowOf.insert(child, sortResult[i].first); - if (isCustomZone(child)) { - customZones.append(child); - } else { - groups.append(child); - } - } - - QVector ordered = groups + customZones; - for (int i = 0; i < ordered.size(); ++i) { - node->replace(i, ordered[i]); - } - - QList> mapping; - mapping.reserve(ordered.size()); - for (int i = 0; i < ordered.size(); ++i) { - mapping.append({preSortRowOf.value(ordered[i]), i}); - } - return mapping; -} - -} // namespace - -QList> sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order) -{ - const bool isBoardZone = (node != root) && (node->getParent() == root); - return isBoardZone ? boardSort(node, order) : plainSort(node, order); -} - -} // namespace DeckListModelCustomZones diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h deleted file mode 100644 index 518a9e1d2..000000000 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h +++ /dev/null @@ -1,98 +0,0 @@ -#ifndef DECK_LIST_MODEL_CUSTOM_ZONES_H -#define DECK_LIST_MODEL_CUSTOM_ZONES_H - -#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h> -#include -#include -#include - -/** - * @class DecklistModelSubZoneNode - * @ingroup DeckModels - * @brief Model node representing a custom zone nested under a board zone. - * - * Custom zones group cards by user-defined names (e.g. "Removal", "Utility") - * inside a board zone. They are mirrored from the underlying deck tree so that - * they can be told apart from criteria group nodes by type. - */ -class DecklistModelSubZoneNode : public InnerDecklistNode -{ -public: - using InnerDecklistNode::InnerDecklistNode; -}; - -/** - * @namespace DeckListModelCustomZones - * @ingroup DeckModels - * @brief Tree-level helpers for the deck list model's custom-zone shadow nodes. - * - * The deck list model keeps a second "shadow" tree of InnerDecklistNode that - * mirrors the canonical deck tree for grouping and sorting. Custom zones add a - * layer of bookkeeping to that shadow tree: they must be mirrored alongside - * criteria groups, always sort after the groups within a board, and be - * resolvable by deck-unique name. - * - * This namespace centralizes every "what is / where is a custom zone" decision - * so the model itself only wires the results into Qt model signals. - */ -namespace DeckListModelCustomZones -{ - -/** - * @brief Whether the given node is a custom zone (as opposed to a criteria group). - */ -[[nodiscard]] bool isCustomZone(const AbstractDecklistNode *node); - -/** - * @brief Finds a criteria-group child of @p parent by name, skipping custom zones. - * - * The shadow tree keeps criteria groups and mirrored custom zones as siblings - * under a board zone, and `InnerDecklistNode::findChild` matches both by name. - * Group lookups must not resolve a custom zone that happens to share the group - * name (e.g. a zone called "Creature"), so this searches only non-custom - * children. - * - * @param parent The shadow node whose children are searched. - * @param name The group name to find. - * @return The matching group node, or nullptr if none exists. - */ -[[nodiscard]] InnerDecklistNode *findGroupChild(InnerDecklistNode *parent, const QString &name); - -/** - * @brief Mirrors the custom zones of a deck board zone into its shadow board node. - * - * Each custom zone becomes a DecklistModelSubZoneNode under @p shadowBoardZone - * with its cards as direct (un-grouped) children. - * - * @param deckBoardZone The board zone in the canonical deck tree. - * @param shadowBoardZone The matching board zone in the model's shadow tree. - */ -void mirrorCustomZones(const InnerDecklistNode *deckBoardZone, InnerDecklistNode *shadowBoardZone); - -/** - * @brief Finds a custom zone in the shadow tree by deck-unique name. - * @param root Root of the shadow tree. - * @param zoneName The custom zone name to find. - * @return The matching custom zone node, or nullptr if not found. - */ -[[nodiscard]] DecklistModelSubZoneNode *findSubZoneByName(InnerDecklistNode *root, const QString &zoneName); - -/** - * @brief Sorts a shadow node's children, keeping a board's custom zones last. - * - * Sorting alone would interleave custom zones with criteria groups by name, but - * custom zones must always stay after the groups within a board, regardless of - * name. This applies the sort and, for board zones, stably moves the custom - * zones to the end. - * - * @param root Root of the shadow tree (used to classify board zones). - * @param node The shadow node whose children are reordered. - * @param order Sort order to apply. - * @return A list of (preSortRow, finalRow) pairs describing how each node moved. - */ -[[nodiscard]] QList> -sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order); - -} // namespace DeckListModelCustomZones - -#endif // DECK_LIST_MODEL_CUSTOM_ZONES_H diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index f22828f46..3a193ae3c 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -90,6 +90,7 @@ set(PROTO_FILES event_game_log_notice.proto event_game_say.proto event_game_state_changed.proto + event_game_state_changed.proto event_join.proto event_join_room.proto event_kicked.proto diff --git a/libcockatrice_rng/libcockatrice/rng/rng_sfmt.cpp b/libcockatrice_rng/libcockatrice/rng/rng_sfmt.cpp index 4c578b4e4..5b38deb3f 100644 --- a/libcockatrice_rng/libcockatrice/rng/rng_sfmt.cpp +++ b/libcockatrice_rng/libcockatrice/rng/rng_sfmt.cpp @@ -1,5 +1,6 @@ #include "rng_sfmt.h" +#include #include #include #include @@ -10,11 +11,10 @@ #define UINT64_MAX (~(uint64_t)0) #endif -RNG_SFMT::RNG_SFMT(uint64_t seed, QObject *parent) : RNG_Abstract(parent) +RNG_SFMT::RNG_SFMT(QObject *parent) : RNG_Abstract(parent) { - // initialize the random number generator with a 64bit seed, e.g. from a CSPRNG - uint32_t seedArray[2] = {static_cast(seed), static_cast(seed >> 32)}; - sfmt_init_by_array(&sfmt, seedArray, 2); + // initialize the random number generator with a 32bit integer seed (timestamp) + sfmt_init_gen_rand(&sfmt, QDateTime::currentDateTime().toSecsSinceEpoch()); } /** diff --git a/libcockatrice_rng/libcockatrice/rng/rng_sfmt.h b/libcockatrice_rng/libcockatrice/rng/rng_sfmt.h index a180dad99..7e9f53df3 100644 --- a/libcockatrice_rng/libcockatrice/rng/rng_sfmt.h +++ b/libcockatrice_rng/libcockatrice/rng/rng_sfmt.h @@ -36,7 +36,7 @@ private: unsigned int cdf(unsigned int min, unsigned int max); public: - explicit RNG_SFMT(uint64_t seed, QObject *parent = nullptr); + explicit RNG_SFMT(QObject *parent = nullptr); unsigned int rand(int min, int max) override; }; diff --git a/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp b/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp index 2f19d6224..45a02299e 100644 --- a/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp @@ -69,14 +69,3 @@ void AppearanceSettings::setHomeTabDisplayCardName(bool _displayCardName) setValue(_displayCardName, "homeTabDisplayCardName"); emit homeTabDisplayCardNameChanged(); } - -int AppearanceSettings::getHomeTabButtonColorSourceIndex() const -{ - return getValue("homeTabButtonColorSource", "", "", 0).toInt(); -} - -void AppearanceSettings::setHomeTabButtonColorSourceIndex(int index) -{ - setValue(index, "homeTabButtonColorSource"); - emit homeTabButtonColorChanged(); -} diff --git a/libcockatrice_settings/libcockatrice/settings/appearance_settings.h b/libcockatrice_settings/libcockatrice/settings/appearance_settings.h index 3a63f0df0..d9b326bee 100644 --- a/libcockatrice_settings/libcockatrice/settings/appearance_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/appearance_settings.h @@ -27,8 +27,6 @@ public: void setHomeTabBackgroundShuffleFrequency(int _frequency); [[nodiscard]] bool getHomeTabDisplayCardName() const; void setHomeTabDisplayCardName(bool _displayCardName); - [[nodiscard]] int getHomeTabButtonColorSourceIndex() const; - void setHomeTabButtonColorSourceIndex(int index); signals: void themeNameChanged(); @@ -36,7 +34,6 @@ signals: void homeTabBackgroundSourceChanged(); void homeTabBackgroundShuffleFrequencyChanged(); void homeTabDisplayCardNameChanged(); - void homeTabButtonColorChanged(); public: explicit AppearanceSettings(const QString &settingPath, QObject *parent = nullptr); diff --git a/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp b/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp index cf5bfd81a..85a1424a6 100644 --- a/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp @@ -106,11 +106,6 @@ bool TabsSettings::getTabModerationOpen() const return getValue("moderation", QString(), QString(), false).toBool(); } -bool TabsSettings::getTabCardArtRulesOpen() const -{ - return getValue("cardArtRules", QString(), QString(), false).toBool(); -} - void TabsSettings::setTabVisualDeckStorageOpen(bool value) { setValue(value, "visualDeckStorage"); @@ -155,8 +150,3 @@ void TabsSettings::setTabModerationOpen(bool value) { setValue(value, "moderation"); } - -void TabsSettings::setTabCardArtRulesOpen(bool value) -{ - setValue(value, "cardArtRules"); -} diff --git a/libcockatrice_settings/libcockatrice/settings/tabs_settings.h b/libcockatrice_settings/libcockatrice/settings/tabs_settings.h index eb78d311b..365d91af7 100644 --- a/libcockatrice_settings/libcockatrice/settings/tabs_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/tabs_settings.h @@ -43,7 +43,6 @@ public: [[nodiscard]] bool getTabLogOpen() const override; [[nodiscard]] bool getTabReportOpen() const override; [[nodiscard]] bool getTabModerationOpen() const override; - [[nodiscard]] bool getTabCardArtRulesOpen() const override; void setStartupTabIndex(int value); void setStartupServerHost(const QString &host); @@ -58,7 +57,6 @@ public: void setTabLogOpen(bool value); void setTabReportOpen(bool value); void setTabModerationOpen(bool value); - void setTabCardArtRulesOpen(bool value); signals: void startupTabIndexChanged(int index); diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index db23f7951..c6411ea76 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -6,15 +6,13 @@ set(CMAKE_AUTOUIC ON) set(CMAKE_AUTORCC ON) set(UTILITY_SOURCES - libcockatrice/utility/cryptoutil.cpp libcockatrice/utility/expression.cpp libcockatrice/utility/levenshtein.cpp - libcockatrice/utility/passwordhasher.cpp libcockatrice/utility/server_rate_limiter.cpp - libcockatrice/utility/warning_categories.cpp + libcockatrice/utility/expression.cpp libcockatrice/utility/levenshtein.cpp libcockatrice/utility/passwordhasher.cpp + libcockatrice/utility/server_rate_limiter.cpp libcockatrice/utility/warning_categories.cpp ) set(UTILITY_HEADERS libcockatrice/utility/card_ref.h libcockatrice/utility/color.h - libcockatrice/utility/cryptoutil.h libcockatrice/utility/expression.h libcockatrice/utility/levenshtein.h libcockatrice/utility/macros.h @@ -34,9 +32,7 @@ add_library(libcockatrice_utility STATIC ${UTILITY_SOURCES} ${UTILITY_HEADERS}) target_include_directories(libcockatrice_utility PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) -find_package(OpenSSL REQUIRED) - -target_link_libraries(libcockatrice_utility PUBLIC libcockatrice_rng OpenSSL::Crypto ${QT_CORE_MODULE}) +target_link_libraries(libcockatrice_utility PUBLIC libcockatrice_rng ${QT_CORE_MODULE}) set(ORACLE_LIBS) diff --git a/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp b/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp deleted file mode 100644 index 416ef261b..000000000 --- a/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp +++ /dev/null @@ -1,25 +0,0 @@ -#include "cryptoutil.h" - -#include - -namespace CryptoUtil -{ -QByteArray randomBytes(int count) -{ - QByteArray bytes(count, '\0'); - if (RAND_bytes(reinterpret_cast(bytes.data()), count) != 1) { - // Randomness failure is fatal: never fall back to a predictable source. - qFatal("CryptoUtil::randomBytes: RAND_bytes failed"); - } - return bytes; -} - -quint64 randomUInt64() -{ - quint64 value; - if (RAND_bytes(reinterpret_cast(&value), sizeof(value)) != 1) { - qFatal("CryptoUtil::randomUInt64: RAND_bytes failed"); - } - return value; -} -} // namespace CryptoUtil diff --git a/libcockatrice_utility/libcockatrice/utility/cryptoutil.h b/libcockatrice_utility/libcockatrice/utility/cryptoutil.h deleted file mode 100644 index dba9dc37d..000000000 --- a/libcockatrice_utility/libcockatrice/utility/cryptoutil.h +++ /dev/null @@ -1,13 +0,0 @@ -#ifndef CRYPTOUTIL_H -#define CRYPTOUTIL_H - -#include -#include - -namespace CryptoUtil -{ -QByteArray randomBytes(int count); -quint64 randomUInt64(); -} // namespace CryptoUtil - -#endif diff --git a/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp b/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp index 1c22fdcfa..c40c5f94f 100644 --- a/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp +++ b/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp @@ -1,7 +1,7 @@ #include "passwordhasher.h" #include -#include +#include QString PasswordHasher::computeHash(const QString &password, const QString &salt) { @@ -21,28 +21,12 @@ QString PasswordHasher::generateRandomSalt(const int len) static const char alphanum[] = "0123456789" "ABCDEFGHIJKLMNOPQRSTUVWXYZ" "abcdefghijklmnopqrstuvwxyz"; - const int size = sizeof(alphanum) - 1; - - // Two bytes per character, corrected for modulo bias via rejection sampling. - const int bucketSize = 65536 / size; - const int limit = bucketSize * size; QString ret; - ret.reserve(len); - QByteArray random = CryptoUtil::randomBytes(len * 2); - int bytesUsed = 0; + int size = sizeof(alphanum) - 1; + for (int i = 0; i < len; ++i) { - unsigned int value; - do { - if (bytesUsed >= random.size()) { - random = CryptoUtil::randomBytes(len * 2); - bytesUsed = 0; - } - value = static_cast(static_cast(random.at(bytesUsed))) << 8 | - static_cast(static_cast(random.at(bytesUsed + 1))); - bytesUsed += 2; - } while (value >= limit); - ret.append(alphanum[value / bucketSize]); + ret.append(alphanum[rng->rand(0, size)]); } return ret; @@ -50,5 +34,5 @@ QString PasswordHasher::generateRandomSalt(const int len) QString PasswordHasher::generateActivationToken() { - return QString(CryptoUtil::randomBytes(16).toBase64().left(16)); + return QCryptographicHash::hash(generateRandomSalt().toUtf8(), QCryptographicHash::Md5).toBase64().left(16); } diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 68c4709bb..0736db7f5 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -23,7 +23,7 @@ set(oracle_SOURCES src/pages.cpp src/pagetemplates.cpp src/parsehelpers.cpp - src/raw_json_scanner.cpp + src/qt-json/json.cpp ../cockatrice/src/client/settings/cache_settings.cpp ../cockatrice/src/client/settings/card_counter_settings.cpp ../cockatrice/src/client/settings/shortcuts_settings.cpp @@ -113,8 +113,6 @@ qt6_add_executable( MANUAL_FINALIZATION ) -target_precompile_headers(oracle PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtwidgets_pch.h") - # ------------------------ # Link libraries # ------------------------ diff --git a/oracle/src/oracleimporter.cpp b/oracle/src/oracleimporter.cpp index d745b250c..fdb32bb8d 100644 --- a/oracle/src/oracleimporter.cpp +++ b/oracle/src/oracleimporter.cpp @@ -3,13 +3,10 @@ #include "libcockatrice/interfaces/noop_card_preference_provider.h" #include "libcockatrice/interfaces/noop_card_set_priority_controller.h" #include "parsehelpers.h" +#include "qt-json/json.h" #include -#include -#include -#include #include -#include #include #include #include @@ -45,29 +42,31 @@ static CardSet::Priority getSetPriority(const QString &setType, const QString &s return priority; } -bool OracleImporter::readSetsFromByteArray(QByteArray data) +bool OracleImporter::readSetsFromByteArray(const QByteArray &data) { - RawJson::ScanError error; - const QList ranges = RawJson::scanSetRanges(data, &error); - if (error.isError()) { - qDebug() << "error: RawJson::scanSetRanges():" << error.message; + bool ok; + auto setsMap = QtJson::Json::parse(QString(data), ok).toMap().value("data").toMap(); + if (!ok) { + qDebug() << "error: QtJson::Json::parse()"; return false; } QList newSetList; - newSetList.reserve(ranges.size()); - for (const RawJson::SetRange &range : ranges) { - QString shortName = range.code.toUpper(); - QString longName = range.name; - QString setType = range.type; - QDate releaseDate = QDate::fromString(range.releaseDate, Qt::ISODate); + QListIterator it(setsMap.values()); + + while (it.hasNext()) { + QVariantMap map = it.next().toMap(); + QString shortName = map.value("code").toString().toUpper(); + QString longName = map.value("name").toString(); + QList setCards = map.value("cards").toList(); + QString setType = map.value("type").toString(); + QDate releaseDate = map.value("releaseDate").toDate(); CardSet::Priority priority = getSetPriority(setType, shortName); // capitalize set type if (setType.length() > 0) { // basic grammar for words that aren't capitalized, like in "From the Vault" - static const QStringList noCapitalize = {"the", "a", "an", "on", "to", "for", - "of", "in", "and", "with", "or"}; + const QStringList noCapitalize = {"the", "a", "an", "on", "to", "for", "of", "in", "and", "with", "or"}; QStringList words = setType.split("_"); setType.clear(); bool first = false; @@ -75,15 +74,13 @@ bool OracleImporter::readSetsFromByteArray(QByteArray data) if (first && noCapitalize.contains(item)) { setType += item + QString(" "); } else { - setType += item[0].toUpper() + item.mid(1) + QString(" "); + setType += item[0].toUpper() + item.mid(1, -1) + QString(" "); first = true; } } setType = setType.trimmed(); } - SetToDownload set(shortName, longName, priority, setType, releaseDate); - set.setRawRange(range.dataRange); - newSetList.append(set); + newSetList.append(SetToDownload(shortName, longName, setCards, priority, setType, releaseDate)); } std::sort(newSetList.begin(), newSetList.end()); @@ -92,7 +89,6 @@ bool OracleImporter::readSetsFromByteArray(QByteArray data) return false; } allSets = newSetList; - rawSetsData = std::move(data); return true; } @@ -126,8 +122,14 @@ static void sortAndReduceColors(QString &colors) std::sort(colors.begin(), colors.end(), [](const QChar a, const QChar b) { return colorOrder.value(a, INT_MAX) < colorOrder.value(b, INT_MAX); }); // reduce - auto last = std::unique(colors.begin(), colors.end()); - colors.erase(last, colors.end()); + QChar lastChar = '\0'; + for (int i = 0; i < colors.size(); ++i) { + if (colors.at(i) == lastChar) { + colors.remove(i, 1); + } else { + lastChar = colors.at(i); + } + } } CardInfoPtr OracleImporter::addCard(QString name, @@ -140,12 +142,9 @@ CardInfoPtr OracleImporter::addCard(QString name, // Workaround for card name weirdness name = name.replace("Æ", "AE"); name = name.replace("’", "'"); - auto existingIt = cards.constFind(name); - if (existingIt != cards.constEnd()) { - CardInfoPtr card = existingIt.value(); + if (cards.contains(name)) { + CardInfoPtr card = cards.value(name); card->addToSet(printingInfo.getSet(), printingInfo); - // Only merge legalities when the card has none yet, so multi-format - // printings don't overwrite each other's legality lists. if (card->getProperties().filter(formatRegex).empty()) { card->combineLegalities(properties); } @@ -183,9 +182,8 @@ CardInfoPtr OracleImporter::addCard(QString name, // DETECT CARD POSITIONING INFO - QString layoutVal = properties.value("layout"); - bool landscapeOrientation = - properties.value("maintype") == "Battle" || layoutVal == "split" || layoutVal == "planar"; + bool landscapeOrientation = properties.value("maintype") == "Battle" || properties.value("layout") == "split" || + properties.value("layout") == "planar"; // cards that enter the field tapped bool cipt = parseCipt(name, text) || landscapeOrientation; @@ -224,15 +222,12 @@ CardInfoPtr OracleImporter::addCard(QString name, return newCard; } -static QString getJsonString(const QJsonObject &obj, const QString &key) +static QString getStringPropertyFromMap(const QVariantMap &card, const QString &propertyName) { - // QVariant coerces numbers and booleans to text, while QJsonValue::toString() - // returns a null string for them — some MTGJSON fields (manaValue, - // convertedManaCost, isOnlineOnly, isRebalanced) carry those types. - return obj.value(key).toVariant().toString(); + return card.contains(propertyName) ? card.value(propertyName).toString() : QString(""); } -int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJsonArray &cardsList) +int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList &cardsList) { // mtgjson name => xml name static const QMap cardProperties{ @@ -253,7 +248,7 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson static const QString ptSeparator = "/"; static constexpr bool isToken = false; - static const QSet setsWithCardsWithSameNameButDifferentText = {"UST"}; + static const QList setsWithCardsWithSameNameButDifferentText = {"UST"}; int numCards = 0; @@ -261,16 +256,16 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson QMap, QString>> splitCards; // Keeps track of all names encountered so far - QSet allNameProps; + QList allNameProps; - for (const QJsonValue &cardVal : cardsList) { - QJsonObject card = cardVal.toObject(); + for (const QVariant &cardVar : cardsList) { + QVariantMap card = cardVar.toMap(); /* Currently used layouts are: * augment, double_faced_token, flip, host, leveler, meld, normal, planar, * saga, scheme, split, token, transform, vanguard */ - QString layout = getJsonString(card, "layout"); + QString layout = getStringPropertyFromMap(card, "layout"); // don't import tokens from the json file if (layout == "token") { @@ -278,9 +273,9 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson } // normal cards handling - QString name = getJsonString(card, "name"); - QString text = getJsonString(card, "text"); - QString faceName = getJsonString(card, "faceName"); + QString name = getStringPropertyFromMap(card, "name"); + QString text = getStringPropertyFromMap(card, "text"); + QString faceName = getStringPropertyFromMap(card, "faceName"); if (faceName.isEmpty()) { faceName = name; } @@ -288,34 +283,39 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson // card properties QHash properties; for (auto i = cardProperties.cbegin(), end = cardProperties.cend(); i != end; ++i) { - QString propertyValue = getJsonString(card, i.key()); + QString mtgjsonProperty = i.key(); + QString xmlPropertyName = i.value(); + QString propertyValue = getStringPropertyFromMap(card, mtgjsonProperty); if (!propertyValue.isEmpty()) { - properties.insert(i.value(), propertyValue); + properties.insert(xmlPropertyName, propertyValue); } } // per-set properties QHash printingProps; for (auto i = setInfoProperties.cbegin(), end = setInfoProperties.cend(); i != end; ++i) { - QString propertyValue = getJsonString(card, i.key()); + QString mtgjsonProperty = i.key(); + QString xmlPropertyName = i.value(); + QString propertyValue = getStringPropertyFromMap(card, mtgjsonProperty); if (!propertyValue.isEmpty()) { - printingProps.insert(i.value(), propertyValue); + printingProps.insert(xmlPropertyName, propertyValue); } } // handle flavorNames specially due to double-faced cards - QString faceFlavorName = getJsonString(card, "faceFlavorName"); - QString flavorName = !faceFlavorName.isEmpty() ? faceFlavorName : getJsonString(card, "flavorName"); + QString faceFlavorName = getStringPropertyFromMap(card, "faceFlavorName"); + QString flavorName = !faceFlavorName.isEmpty() ? faceFlavorName : getStringPropertyFromMap(card, "flavorName"); if (!flavorName.isEmpty()) { printingProps.insert("flavorName", flavorName); } // Identifiers - QJsonObject identifiers = card.value("identifiers").toObject(); for (auto i = identifierProperties.cbegin(), end = identifierProperties.cend(); i != end; ++i) { - QString propertyValue = getJsonString(identifiers, i.key()); + QString mtgjsonProperty = i.key(); + QString xmlPropertyName = i.value(); + QString propertyValue = getStringPropertyFromMap(card.value("identifiers").toMap(), mtgjsonProperty); if (!propertyValue.isEmpty()) { - printingProps.insert(i.value(), propertyValue); + printingProps.insert(xmlPropertyName, propertyValue); } } @@ -331,26 +331,21 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson allNameProps.contains(faceName) && layout == "normal" && lastChar.isLetter()) { numComponent = " (" + QString(lastChar).toLower() + ")"; } - allNameProps.insert(faceName); + allNameProps.append(faceName); // special handling properties - QString colors; - for (const QJsonValue &color : card.value("colors").toArray()) { - colors += color.toString(); - } + QString colors = card.value("colors").toStringList().join(""); if (!colors.isEmpty()) { properties.insert("colors", colors); } - QString colorIdentity; - for (const QJsonValue &color : card.value("colorIdentity").toArray()) { - colorIdentity += color.toString(); - } + // special handling properties + QString colorIdentity = card.value("colorIdentity").toStringList().join(""); if (!colorIdentity.isEmpty()) { properties.insert("coloridentity", colorIdentity); } - const auto &mainCardType = getMainCardType(card.value("types").toVariant().toStringList()); + const auto &mainCardType = getMainCardType(card.value("types").toStringList()); if (mainCardType.isEmpty()) { qDebug() << "warning: no mainCardType for card:" << name; } else { @@ -359,22 +354,22 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson // Depending on whether power and/or toughness are present, the format // is either P/T (most common), P (no toughness), or /T (no power). - QString power = getJsonString(card, "power"); - QString toughness = getJsonString(card, "toughness"); + QString power = getStringPropertyFromMap(card, "power"); + QString toughness = getStringPropertyFromMap(card, "toughness"); if (toughness.isEmpty() && !power.isEmpty()) { properties.insert("pt", power); } else if (!toughness.isEmpty()) { properties.insert("pt", power + ptSeparator + toughness); } - auto legalities = card.value("legalities").toObject(); - for (auto i = legalities.constBegin(), end = legalities.constEnd(); i != end; ++i) { + auto legalities = card.value("legalities").toMap(); + for (auto i = legalities.cbegin(), end = legalities.cend(); i != end; ++i) { properties.insert(QString("format-%1").arg(i.key()), i.value().toString().toLower()); } // split cards are considered a single card, enqueue for later merging if (layout == "split" || layout == "aftermath" || layout == "adventure" || layout == "prepare") { - auto _faceName = getJsonString(card, "faceName"); + auto _faceName = getStringPropertyFromMap(card, "faceName"); SplitCardPart split(_faceName, text, properties, printingInfo); auto found_iter = splitCards.find(name + numProperty); if (found_iter == splitCards.end()) { @@ -387,11 +382,11 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson QList relatedCards; // add other face for split cards as card relation - if (!getJsonString(card, "side").isEmpty()) { - auto faceManaValue = getJsonString(card, "faceManaValue"); + if (!getStringPropertyFromMap(card, "side").isEmpty()) { + auto faceManaValue = getStringPropertyFromMap(card, "faceManaValue"); if (faceManaValue.isEmpty()) { // check the old name for the property, for backwards compatibility purposes - faceManaValue = getJsonString(card, "faceConvertedManaCost"); + faceManaValue = getStringPropertyFromMap(card, "faceConvertedManaCost"); } properties["cmc"] = faceManaValue; @@ -411,21 +406,20 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson name = faceName; } - // mtgjson related cards - QJsonObject givenRelated = card.value("relatedCards").toObject(); - if (!givenRelated.isEmpty()) { + // mtgjon related cards + if (card.contains("relatedCards")) { + QVariantMap givenRelated = card.value("relatedCards").toMap(); // conjured cards from a spellbook - QJsonArray spellbook = givenRelated.value("spellbook").toArray(); - if (!spellbook.isEmpty()) { - for (const QJsonValue &spbkVal : spellbook) { - relatedCards.append(new CardRelation(spbkVal.toString(), CardRelationType::DoesNotAttach, false, - false, 1, true)); + if (givenRelated.contains("spellbook")) { + auto spbk = givenRelated.value("spellbook").toStringList(); + for (const QString &spbkName : spbk) { + relatedCards.append( + new CardRelation(spbkName, CardRelationType::DoesNotAttach, false, false, 1, true)); } } } - CardInfoPtr newCard = - addCard(name + numComponent, text, isToken, std::move(properties), relatedCards, printingInfo); + CardInfoPtr newCard = addCard(name + numComponent, text, isToken, properties, relatedCards, printingInfo); numCards++; } } @@ -433,6 +427,7 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson // split cards handling static const QString splitCardPropSeparator = QString(" // "); static const QString splitCardTextSeparator = QString("\n\n---\n\n"); + static const QList noRelatedCards = {}; QList, QString>> partsAndNames = splitCards.values(); for (auto [splitCardParts, name] : partsAndNames) { @@ -458,7 +453,7 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson if (!thisCardPropertyValue.isEmpty() && originalPropertyValue != thisCardPropertyValue) { if (originalPropertyValue.isEmpty()) { // don't create //es if one field is empty properties.insert(prop, thisCardPropertyValue); - } else if (prop == "colors" || prop == "coloridentity") { // the card is both colors + } else if (prop == "colors") { // the card is both colors properties.insert(prop, originalPropertyValue + thisCardPropertyValue); } else if (prop == "maintype") { // don't create maintypes with //es in them continue; @@ -470,20 +465,20 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson } } } - CardInfoPtr newCard = addCard(name, text, isToken, std::move(properties), {}, printingInfo); + CardInfoPtr newCard = addCard(name, text, isToken, properties, noRelatedCards, printingInfo); numCards++; } return numCards; } -static FormatRulesNameMap buildDefaultMagicFormats() +FormatRulesNameMap OracleImporter::createDefaultMagicFormats() { // Predefined common exceptions CardCondition superTypeIsBasic; superTypeIsBasic.field = "type"; superTypeIsBasic.matchType = "regex"; - superTypeIsBasic.value = R"(\bBasic\b[^—]+\bLand\b)"; + superTypeIsBasic.value = "\bBasic\b[^—]+\bLand\b"; ExceptionRule basicLands; basicLands.conditions.append(superTypeIsBasic); @@ -496,6 +491,7 @@ static FormatRulesNameMap buildDefaultMagicFormats() ExceptionRule mayContainAnyNumber; mayContainAnyNumber.conditions.append(anyNumberAllowed); + // Map to store default rules FormatRulesNameMap defaultFormatRulesNameMap; // ----------------- Helper lambda to create format ----------------- @@ -541,27 +537,10 @@ static FormatRulesNameMap buildDefaultMagicFormats() return defaultFormatRulesNameMap; } -const FormatRulesNameMap &OracleImporter::createDefaultMagicFormats() -{ - static const FormatRulesNameMap cached = buildDefaultMagicFormats(); - return cached; -} - int OracleImporter::startImport() { static ICardSetPriorityController *noOpController = new NoopCardSetPriorityController(); - // Pre-allocate the cards hash to avoid rehashing during import. Keys are - // distinct card names while raw ranges only count printings (AllPrintings - // ~100k printings vs ~35k names), so this over-reserves somewhat; an exact - // distinct-name count would require eagerly parsing, which the lazy reader - // deliberately avoids. It's a capacity hint, so the overshoot is harmless. - int estimatedCards = 0; - for (const SetToDownload &curSetToParse : allSets) { - estimatedCards += curSetToParse.getRawRange().cardCount; - } - cards.reserve(estimatedCards); - // add an empty set for tokens CardSetPtr tokenSet = CardSet::newInstance(noOpController, CardSet::TOKENS_SETNAME, tr("Dummy set containing tokens"), "Tokens"); @@ -573,44 +552,11 @@ int OracleImporter::startImport() CardSetPtr newSet = CardSet::newInstance(noOpController, curSetToParse.getShortName(), curSetToParse.getLongName(), curSetToParse.getSetType(), curSetToParse.getReleaseDate(), curSetToParse.getPriority()); - - // parse only this set's slice of the raw document so the whole JSON tree is - // never kept in memory at once - const RawJson::SetDataRange &rawRange = curSetToParse.getRawRange(); - const qsizetype rangeEnd = rawRange.start + rawRange.length; - if (rawRange.start < 0 || rawRange.length <= 0 || rangeEnd > rawSetsData.size()) { - // rawSetsData is cleared by releaseSetData() while SetToDownload copies - // taken from getSets() keep their ranges, and nothing else enforces the - // pairing — so never index past the buffer on stale/mismatched ranges. - qWarning() << "error: out-of-bounds raw range for set" << curSetToParse.getShortName() << "skipping"; - ++setIndex; - emit setIndexChanged(0, setIndex, curSetToParse.getLongName()); - continue; - } - // sliced() shares the buffer instead of deep-copying the slice; the largest - // sets in AllPrintings are tens of MB, so the copy is worth avoiding here. - const QByteArray setBytes = rawSetsData.sliced(rawRange.start, rawRange.length); - QJsonParseError parseError; - const QJsonDocument setDoc = QJsonDocument::fromJson(setBytes, &parseError); - if (parseError.error != QJsonParseError::NoError) { - qWarning() << "error: parsing card data for set" << curSetToParse.getShortName() << ":" - << parseError.errorString(); - ++setIndex; - // Keep the progress accounting honest: a set that failed to parse - // still advanced the index, so report it (with zero imported cards) - // rather than letting SaveSetsPage's bar stall per failed set. - emit setIndexChanged(0, setIndex, curSetToParse.getLongName()); - continue; - } - - // Only add the set to the database once its slice parsed cleanly; - // a set that fails here must not persist as an empty set in cards.xml. if (!sets.contains(newSet->getShortName())) { sets.insert(newSet->getShortName(), newSet); } - const QJsonArray setCards = setDoc.object().value("cards").toArray(); - int numCardsInSet = importCardsFromSet(newSet, setCards); + int numCardsInSet = importCardsFromSet(newSet, curSetToParse.getCards()); ++setIndex; @@ -630,16 +576,9 @@ bool OracleImporter::saveToFile(const QString &fileName, const QString &sourceUr return parser.saveToFile(createDefaultMagicFormats(), sets, cards, fileName, sourceUrl, sourceVersion); } -void OracleImporter::releaseSetData() -{ - allSets.clear(); - rawSetsData.clear(); -} - void OracleImporter::clear() { sets.clear(); cards.clear(); allSets.clear(); - rawSetsData.clear(); } diff --git a/oracle/src/oracleimporter.h b/oracle/src/oracleimporter.h index 8cb30ca40..99644f9ce 100644 --- a/oracle/src/oracleimporter.h +++ b/oracle/src/oracleimporter.h @@ -1,11 +1,6 @@ #ifndef ORACLEIMPORTER_H #define ORACLEIMPORTER_H -#include "raw_json_scanner.h" - -#include -#include -#include #include #include #include @@ -49,12 +44,10 @@ class SetToDownload { private: QString shortName, longName; + QList cards; QDate releaseDate; QString setType; CardSet::Priority priority; - // Byte range of this set's object within the importer's raw JSON text. Parsing - // one set at a time keeps peak memory low instead of holding the whole document. - RawJson::SetDataRange rawRange; public: const QString &getShortName() const @@ -65,6 +58,10 @@ public: { return longName; } + const QList &getCards() const + { + return cards; + } const QString &getSetType() const { return setType; @@ -77,23 +74,16 @@ public: { return priority; } - const RawJson::SetDataRange &getRawRange() const - { - return rawRange; - } SetToDownload(QString _shortName, QString _longName, + QList _cards, CardSet::Priority _priority, QString _setType = QString(), const QDate &_releaseDate = QDate()) - : shortName(std::move(_shortName)), longName(std::move(_longName)), releaseDate(_releaseDate), - setType(std::move(_setType)), priority(_priority) + : shortName(std::move(_shortName)), longName(std::move(_longName)), cards(std::move(_cards)), + releaseDate(_releaseDate), setType(std::move(_setType)), priority(_priority) { } - void setRawRange(const RawJson::SetDataRange &_rawRange) - { - rawRange = _rawRange; - } bool operator<(const SetToDownload &set) const { return longName.compare(set.longName, Qt::CaseInsensitive) < 0; @@ -149,12 +139,6 @@ private: QList allSets; - /** - * The raw JSON text of the source document, retained for lazy per-set - * parsing during startImport(). Frees the card data as each set is imported. - */ - QByteArray rawSetsData; - CardInfoPtr addCard(QString name, const QString &text, bool isToken, @@ -167,18 +151,11 @@ signals: public: explicit OracleImporter(QObject *parent = nullptr); - /** - * Scans the given JSON document for set metadata. Takes the data by value so - * the wizard can hand over its decompressed buffer without copying it. - */ - bool readSetsFromByteArray(QByteArray data); + bool readSetsFromByteArray(const QByteArray &data); int startImport(); bool saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion); - int importCardsFromSet(const CardSetPtr ¤tSet, const QJsonArray &cardsList); - /** - * @brief Returns the default format rules. The result is memoized on first use and must be treated as immutable. - */ - const FormatRulesNameMap &createDefaultMagicFormats(); + int importCardsFromSet(const CardSetPtr ¤tSet, const QList &cardsList); + FormatRulesNameMap createDefaultMagicFormats(); const CardNameMap &getCardList() const { return cards; @@ -187,11 +164,6 @@ public: { return allSets; } - const QByteArray &getRawSetsData() const - { - return rawSetsData; - } - void releaseSetData(); void clear(); }; diff --git a/oracle/src/pages.cpp b/oracle/src/pages.cpp index 94e662ffe..df4d1a98c 100644 --- a/oracle/src/pages.cpp +++ b/oracle/src/pages.cpp @@ -560,9 +560,6 @@ void SaveSetsPage::initializePage() int setsImported = wizard()->importer->startImport(); - // JSON data no longer needed after CardInfo objects are built - wizard()->importer->releaseSetData(); - if (setsImported == 0) { QMessageBox::critical(this, tr("Error"), tr("No set has been imported.")); } diff --git a/oracle/src/qt-json/AUTHORS b/oracle/src/qt-json/AUTHORS new file mode 100644 index 000000000..29a85929f --- /dev/null +++ b/oracle/src/qt-json/AUTHORS @@ -0,0 +1,3 @@ +Eeli Reilin +Luis Gustavo S. Barreto +Stephen Kockentiedt diff --git a/oracle/src/qt-json/LICENSE b/oracle/src/qt-json/LICENSE new file mode 100644 index 000000000..3c42b515a --- /dev/null +++ b/oracle/src/qt-json/LICENSE @@ -0,0 +1,27 @@ +Copyright 2011 Eeli Reilin. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY ''AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO +EVENT SHALL EELI REILIN OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, +OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE +OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +The views and conclusions contained in the software and documentation +are those of the authors and should not be interpreted as representing +official policies, either expressed or implied, of Eeli Reilin. + diff --git a/oracle/src/qt-json/README b/oracle/src/qt-json/README new file mode 100644 index 000000000..b60c1599b --- /dev/null +++ b/oracle/src/qt-json/README @@ -0,0 +1,96 @@ +######################################################################## +1. INTRODUCTION + +The Json class is a simple class for parsing JSON data into a QVariant +hierarchies. Now, we can also reverse the process and serialize +QVariant hierarchies into valid JSON data. + + +######################################################################## +2. HOW TO USE + +The parser is really easy to use. Let's say we have the following +QString of JSON data: + +------------------------------------------------------------------------ +{ + "encoding" : "UTF-8", + "plug-ins" : [ + "python", + "c++", + "ruby" + ], + "indent" : { + "length" : 3, + "use_space" : true + } +} +------------------------------------------------------------------------ + +We would first call the parse-method: + +------------------------------------------------------------------------ +//Say that we're using the QtJson namespace +using namespace QtJson; +bool ok; +//json is a QString containing the JSON data +QVariantMap result = Json::parse(json, ok).toMap(); + +if(!ok) { + qFatal("An error occurred during parsing"); + exit(1); +} +------------------------------------------------------------------------ + +Assuming the parsing process completed without errors, we would then +go through the hierarchy: + +------------------------------------------------------------------------ +qDebug() << "encoding:" << result["encoding"].toString(); +qDebug() << "plugins:"; + +foreach(QVariant plugin, result["plug-ins"].toList()) { + qDebug() << "\t-" << plugin.toString(); +} + +QVariantMap nestedMap = result["indent"].toMap(); +qDebug() << "length:" << nestedMap["length"].toInt(); +qDebug() << "use_space:" << nestedMap["use_space"].toBool(); +------------------------------------------------------------------------ + +The previous code would print out the following: + +------------------------------------------------------------------------ +encoding: "UTF-8" +plugins: + - "python" + - "c++" + - "ruby" +length: 3 +use_space: true +------------------------------------------------------------------------ + +To write JSON data from Qt object is as simple as parsing: + +------------------------------------------------------------------------ +QVariantMap map; +map["name"] = "Name"; +map["age"] = 22; + +QByteArray data = Json::serialize(map); +------------------------------------------------------------------------ + +The byte array 'data' contains valid JSON data: + +------------------------------------------------------------------------ +{ + name: "Luis Gustavo", + age: 22, +} +------------------------------------------------------------------------ + + +######################################################################## +4. CONTRIBUTING + +The code is available to download at GitHub. Contribute if you dare! diff --git a/oracle/src/qt-json/json.cpp b/oracle/src/qt-json/json.cpp new file mode 100644 index 000000000..ff739b49d --- /dev/null +++ b/oracle/src/qt-json/json.cpp @@ -0,0 +1,545 @@ +/* Copyright 2011 Eeli Reilin. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY ''AS IS'' AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL EELI REILIN OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, + * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * The views and conclusions contained in the software and documentation + * are those of the authors and should not be interpreted as representing + * official policies, either expressed or implied, of Eeli Reilin. + */ + +/** + * \file json.cpp + */ + +#include "json.h" + +#include +#include + +namespace QtJson +{ + +static QString sanitizeString(QString str) +{ + str.replace(QLatin1String("\\"), QLatin1String("\\\\")); + str.replace(QLatin1String("\""), QLatin1String("\\\"")); + str.replace(QLatin1String("\b"), QLatin1String("\\b")); + str.replace(QLatin1String("\f"), QLatin1String("\\f")); + str.replace(QLatin1String("\n"), QLatin1String("\\n")); + str.replace(QLatin1String("\r"), QLatin1String("\\r")); + str.replace(QLatin1String("\t"), QLatin1String("\\t")); + return QString(QLatin1String("\"%1\"")).arg(str); +} + +static QByteArray join(const QList &list, const QByteArray &sep) +{ + QByteArray res; + for (const QByteArray &i : list) { + if (!res.isEmpty()) { + res += sep; + } + res += i; + } + return res; +} + +/** + * parse + */ +QVariant Json::parse(const QString &json) +{ + bool success = true; + return Json::parse(json, success); +} + +/** + * parse + */ +QVariant Json::parse(const QString &json, bool &success) +{ + success = true; + + // Return an empty QVariant if the JSON data is either null or empty + if (!json.isNull() || !json.isEmpty()) { + // We'll start from index 0 + int index = 0; + + // Parse the first value + QVariant value = Json::parseValue(json, index, success); + + // Return the parsed value + return value; + } else { + // Return the empty QVariant + return QVariant(); + } +} + +QByteArray Json::serialize(const QVariant &data) +{ + bool success = true; + return Json::serialize(data, success); +} + +QByteArray Json::serialize(const QVariant &data, bool &success) +{ + QByteArray str; + success = true; + + if (!data.isValid()) // invalid or null? + { + str = "null"; + } + else if ((data.typeId() == QMetaType::Type::QVariantList) || + (data.typeId() == QMetaType::Type::QStringList)) // variant is a list? + { + QList values; + const QVariantList list = data.toList(); + for (const QVariant &v : list) { + QByteArray serializedValue = serialize(v); + if (serializedValue.isNull()) { + success = false; + break; + } + values << serializedValue; + } + + str = "[ " + join(values, ", ") + " ]"; + } + else if ((data.typeId() == QMetaType::Type::QVariantHash)) // variant is a hash? + { + const QVariantHash vhash = data.toHash(); + QHashIterator it(vhash); + str = "{ "; + QList pairs; + + while (it.hasNext()) { + it.next(); + QByteArray serializedValue = serialize(it.value()); + + if (serializedValue.isNull()) { + success = false; + break; + } + + pairs << sanitizeString(it.key()).toUtf8() + " : " + serializedValue; + } + + str += join(pairs, ", "); + str += " }"; + } + else if ((data.typeId() == QMetaType::Type::QVariantMap)) // variant is a map? + { + const QVariantMap vmap = data.toMap(); + QMapIterator it(vmap); + str = "{ "; + QList pairs; + while (it.hasNext()) { + it.next(); + QByteArray serializedValue = serialize(it.value()); + if (serializedValue.isNull()) { + success = false; + break; + } + pairs << sanitizeString(it.key()).toUtf8() + " : " + serializedValue; + } + str += join(pairs, ", "); + str += " }"; + } + else if ((data.typeId() == QMetaType::Type::QString) || + (data.typeId() == QMetaType::Type::QByteArray)) // a string or a byte array? + { + str = sanitizeString(data.toString()).toUtf8(); + } + else if (data.typeId() == QMetaType::Type::Double) // double? + { + str = QByteArray::number(data.toDouble(), 'g', 20); + if (!str.contains(".") && !str.contains("e")) { + str += ".0"; + } + } + else if (data.typeId() == QMetaType::Type::Bool) // boolean value? + { + str = data.toBool() ? "true" : "false"; + } + else if (data.typeId() == QMetaType::Type::ULongLong) // large unsigned number? + { + str = QByteArray::number(data.value()); + } else if (data.canConvert()) // any signed number? + { + str = QByteArray::number(data.value()); + } else if (data.canConvert()) { + str = QString::number(data.value()).toUtf8(); + } else if (data.canConvert()) // can value be converted to string? + { + // this will catch QDate, QDateTime, QUrl, ... + str = sanitizeString(data.toString()).toUtf8(); + } else { + success = false; + } + if (success) { + return str; + } else { + return QByteArray(); + } +} + +/** + * parseValue + */ +QVariant Json::parseValue(const QString &json, int &index, bool &success) +{ + // Determine what kind of data we should parse by + // checking out the upcoming token + switch (Json::lookAhead(json, index)) { + case JsonTokenString: + return Json::parseString(json, index, success); + case JsonTokenNumber: + return Json::parseNumber(json, index); + case JsonTokenCurlyOpen: + return Json::parseObject(json, index, success); + case JsonTokenSquaredOpen: + return Json::parseArray(json, index, success); + case JsonTokenTrue: + Json::nextToken(json, index); + return QVariant(true); + case JsonTokenFalse: + Json::nextToken(json, index); + return QVariant(false); + case JsonTokenNull: + Json::nextToken(json, index); + return QVariant(); + case JsonTokenNone: + break; + } + + // If there were no tokens, flag the failure and return an empty QVariant + success = false; + return QVariant(); +} + +/** + * parseObject + */ +QVariant Json::parseObject(const QString &json, int &index, bool &success) +{ + QVariantMap map; + int token; + + // Get rid of the whitespace and increment index + Json::nextToken(json, index); + + // Loop through all of the key/value pairs of the object + bool done = false; + while (!done) { + // Get the upcoming token + token = Json::lookAhead(json, index); + + if (token == JsonTokenNone) { + success = false; + return QVariantMap(); + } else if (token == JsonTokenComma) { + Json::nextToken(json, index); + } else if (token == JsonTokenCurlyClose) { + Json::nextToken(json, index); + return map; + } else { + // Parse the key/value pair's name + QString name = Json::parseString(json, index, success).toString(); + + if (!success) { + return QVariantMap(); + } + + // Get the next token + token = Json::nextToken(json, index); + + // If the next token is not a colon, flag the failure + // return an empty QVariant + if (token != JsonTokenColon) { + success = false; + return QVariant(QVariantMap()); + } + + // Parse the key/value pair's value + QVariant value = Json::parseValue(json, index, success); + + if (!success) { + return QVariantMap(); + } + + // Assign the value to the key in the map + map[name] = value; + } + } + + // Return the map successfully + return QVariant(map); +} + +/** + * parseArray + */ +QVariant Json::parseArray(const QString &json, int &index, bool &success) +{ + QVariantList list; + + Json::nextToken(json, index); + + bool done = false; + while (!done) { + int token = Json::lookAhead(json, index); + + if (token == JsonTokenNone) { + success = false; + return QVariantList(); + } else if (token == JsonTokenComma) { + Json::nextToken(json, index); + } else if (token == JsonTokenSquaredClose) { + Json::nextToken(json, index); + break; + } else { + QVariant value = Json::parseValue(json, index, success); + + if (!success) { + return QVariantList(); + } + + list.push_back(value); + } + } + + return QVariant(list); +} + +/** + * parseString + */ +QVariant Json::parseString(const QString &json, int &index, bool &success) +{ + QString s; + QChar c; + + Json::eatWhitespace(json, index); + + c = json[index++]; + + bool complete = false; + while (!complete) { + if (index == json.size()) { + break; + } + + c = json[index++]; + + if (c == '\"') { + complete = true; + break; + } else if (c == '\\') { + if (index == json.size()) { + break; + } + + c = json[index++]; + + if (c == '\"') { + s.append('\"'); + } else if (c == '\\') { + s.append('\\'); + } else if (c == '/') { + s.append('/'); + } else if (c == 'b') { + s.append('\b'); + } else if (c == 'f') { + s.append('\f'); + } else if (c == 'n') { + s.append('\n'); + } else if (c == 'r') { + s.append('\r'); + } else if (c == 't') { + s.append('\t'); + } else if (c == 'u') { + int remainingLength = json.size() - index; + + if (remainingLength >= 4) { + QString unicodeStr = json.mid(index, 4); + + int symbol = unicodeStr.toInt(0, 16); + + s.append(QChar(symbol)); + + index += 4; + } else { + break; + } + } + } else { + s.append(c); + } + } + + if (!complete) { + success = false; + return QVariant(); + } + + return QVariant(s); +} + +/** + * parseNumber + */ +QVariant Json::parseNumber(const QString &json, int &index) +{ + Json::eatWhitespace(json, index); + + int lastIndex = Json::lastIndexOfNumber(json, index); + int charLength = (lastIndex - index) + 1; + QString numberStr; + + numberStr = json.mid(index, charLength); + + index = lastIndex + 1; + + if (numberStr.contains('.')) { + return QVariant(numberStr.toDouble(NULL)); + } else if (numberStr.startsWith('-')) { + return QVariant(numberStr.toLongLong(NULL)); + } else { + return QVariant(numberStr.toULongLong(NULL)); + } +} + +/** + * lastIndexOfNumber + */ +int Json::lastIndexOfNumber(const QString &json, int index) +{ + static const QString numericCharacters("0123456789+-.eE"); + int lastIndex; + + for (lastIndex = index; lastIndex < json.size(); lastIndex++) { + if (numericCharacters.indexOf(json[lastIndex]) == -1) { + break; + } + } + + return lastIndex - 1; +} + +/** + * eatWhitespace + */ +void Json::eatWhitespace(const QString &json, int &index) +{ + static const QString whitespaceChars(" \t\n\r"); + for (; index < json.size(); index++) { + if (whitespaceChars.indexOf(json[index]) == -1) { + break; + } + } +} + +/** + * lookAhead + */ +int Json::lookAhead(const QString &json, int index) +{ + int saveIndex = index; + return Json::nextToken(json, saveIndex); +} + +/** + * nextToken + */ +int Json::nextToken(const QString &json, int &index) +{ + Json::eatWhitespace(json, index); + + if (index == json.size()) { + return JsonTokenNone; + } + + QChar c = json[index]; + index++; + switch (c.toLatin1()) { + case '{': + return JsonTokenCurlyOpen; + case '}': + return JsonTokenCurlyClose; + case '[': + return JsonTokenSquaredOpen; + case ']': + return JsonTokenSquaredClose; + case ',': + return JsonTokenComma; + case '"': + return JsonTokenString; + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + case '-': + return JsonTokenNumber; + case ':': + return JsonTokenColon; + } + + index--; + + int remainingLength = json.size() - index; + + // True + if (remainingLength >= 4) { + if (json[index] == 't' && json[index + 1] == 'r' && json[index + 2] == 'u' && json[index + 3] == 'e') { + index += 4; + return JsonTokenTrue; + } + } + + // False + if (remainingLength >= 5) { + if (json[index] == 'f' && json[index + 1] == 'a' && json[index + 2] == 'l' && json[index + 3] == 's' && + json[index + 4] == 'e') { + index += 5; + return JsonTokenFalse; + } + } + + // Null + if (remainingLength >= 4) { + if (json[index] == 'n' && json[index + 1] == 'u' && json[index + 2] == 'l' && json[index + 3] == 'l') { + index += 4; + return JsonTokenNull; + } + } + + return JsonTokenNone; +} + +} // namespace QtJson diff --git a/oracle/src/qt-json/json.h b/oracle/src/qt-json/json.h new file mode 100644 index 000000000..cf0499d4e --- /dev/null +++ b/oracle/src/qt-json/json.h @@ -0,0 +1,204 @@ +/* Copyright 2011 Eeli Reilin. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY ''AS IS'' AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL EELI REILIN OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, + * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * The views and conclusions contained in the software and documentation + * are those of the authors and should not be interpreted as representing + * official policies, either expressed or implied, of Eeli Reilin. + */ + +/** + * \file json.h + */ + +#ifndef JSON_H +#define JSON_H + +#include +#include + +namespace QtJson +{ + +/** + * \enum JsonToken + */ +enum JsonToken +{ + JsonTokenNone = 0, + JsonTokenCurlyOpen = 1, + JsonTokenCurlyClose = 2, + JsonTokenSquaredOpen = 3, + JsonTokenSquaredClose = 4, + JsonTokenColon = 5, + JsonTokenComma = 6, + JsonTokenString = 7, + JsonTokenNumber = 8, + JsonTokenTrue = 9, + JsonTokenFalse = 10, + JsonTokenNull = 11 +}; + +/** + * \class Json + * \brief A JSON data parser + * + * Json parses a JSON data into a QVariant hierarchy. + */ +class Json +{ + public: + /** + * Parse a JSON string + * + * \param json The JSON data + */ + static QVariant parse(const QString &json); + + /** + * Parse a JSON string + * + * \param json The JSON data + * \param success The success of the parsing + */ + static QVariant parse(const QString &json, bool &success); + + /** + * This method generates a textual JSON representation + * + * \param data The JSON data generated by the parser. + * \param success The success of the serialization + */ + static QByteArray serialize(const QVariant &data); + + /** + * This method generates a textual JSON representation + * + * \param data The JSON data generated by the parser. + * \param success The success of the serialization + * + * \return QByteArray Textual JSON representation + */ + static QByteArray serialize(const QVariant &data, bool &success); + + private: + /** + * Parses a value starting from index + * + * \param json The JSON data + * \param index The start index + * \param success The success of the parse process + * + * \return QVariant The parsed value + */ + static QVariant parseValue(const QString &json, int &index, + bool &success); + + /** + * Parses an object starting from index + * + * \param json The JSON data + * \param index The start index + * \param success The success of the object parse + * + * \return QVariant The parsed object map + */ + static QVariant parseObject(const QString &json, int &index, + bool &success); + + /** + * Parses an array starting from index + * + * \param json The JSON data + * \param index The starting index + * \param success The success of the array parse + * + * \return QVariant The parsed variant array + */ + static QVariant parseArray(const QString &json, int &index, + bool &success); + + /** + * Parses a string starting from index + * + * \param json The JSON data + * \param index The starting index + * \param success The success of the string parse + * + * \return QVariant The parsed string + */ + static QVariant parseString(const QString &json, int &index, + bool &success); + + /** + * Parses a number starting from index + * + * \param json The JSON data + * \param index The starting index + * + * \return QVariant The parsed number + */ + static QVariant parseNumber(const QString &json, int &index); + + /** + * Get the last index of a number starting from index + * + * \param json The JSON data + * \param index The starting index + * + * \return The last index of the number + */ + static int lastIndexOfNumber(const QString &json, int index); + + /** + * Skip unwanted whitespace symbols starting from index + * + * \param json The JSON data + * \param index The start index + */ + static void eatWhitespace(const QString &json, int &index); + + /** + * Check what token lies ahead + * + * \param json The JSON data + * \param index The starting index + * + * \return int The upcoming token + */ + static int lookAhead(const QString &json, int index); + + /** + * Get the next JSON token + * + * \param json The JSON data + * \param index The starting index + * + * \return int The next JSON token + */ + static int nextToken(const QString &json, int &index); +}; + + +} //end namespace + +#endif //JSON_H diff --git a/oracle/src/raw_json_scanner.cpp b/oracle/src/raw_json_scanner.cpp deleted file mode 100644 index db972491c..000000000 --- a/oracle/src/raw_json_scanner.cpp +++ /dev/null @@ -1,621 +0,0 @@ -#include "raw_json_scanner.h" - -#include - -namespace -{ - -// Nesting cap matching QJsonDocument's limit, so a pathologically deep document -// fails shallowly instead of overflowing the stack through the recursive -// skipValue/skipArray/skipObject walk (Qt's parser caps at 1024 for the same -// reason and reports DeepNesting). -constexpr int kMaxNestingDepth = 1024; - -inline bool isWhitespace(char c) -{ - return c == ' ' || c == '\t' || c == '\r' || c == '\n'; -} - -const char *skipWhitespace(const char *p, const char *end) -{ - while (p < end && isWhitespace(*p)) { - ++p; - } - return p; -} - -inline bool isHexDigit(char c) -{ - return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); -} - -inline quint8 hexValue(char c) -{ - if (c >= '0' && c <= '9') { - return c - '0'; - } - if (c >= 'a' && c <= 'f') { - return c - 'a' + 10; - } - return c - 'A' + 10; -} - -/** - * @brief Skips past a JSON string without decoding it, validating escapes. - * @param p In: pointing at the opening quote. Out: pointing past the closing quote. - */ -bool skipString(const char *&p, const char *end) -{ - ++p; // opening quote - for (;;) { - const void *quote = memchr(p, '"', static_cast(end - p)); - if (!quote) { - return false; // unterminated string - } - // Backslash escapes can only appear before the closing quote, so bound - // the scan to the string extent instead of the rest of the document. - const void *backslash = memchr(p, '\\', static_cast(static_cast(quote) - p)); - if (!backslash) { - p = static_cast(quote) + 1; - return true; - } - const char *b = static_cast(backslash); - if (end - b < 2) { - return false; - } - const char escaped = b[1]; - if (escaped == 'u') { - if (end - b < 6) { - return false; - } - quint32 codepoint = 0; - for (int i = 0; i < 4; ++i) { - if (!isHexDigit(b[2 + i])) { - return false; - } - codepoint = codepoint * 16 + hexValue(b[2 + i]); - } - p = b + 6; - if (codepoint >= 0xD800 && codepoint <= 0xDBFF) { - // expect the low-surrogate escape for the second half - if (end - p < 6 || p[0] != '\\' || p[1] != 'u') { - return false; // unpaired high surrogate - } - quint32 low = 0; - for (int i = 0; i < 4; ++i) { - if (!isHexDigit(p[2 + i])) { - return false; - } - low = low * 16 + hexValue(p[2 + i]); - } - if (low < 0xDC00 || low > 0xDFFF) { - return false; - } - p += 6; - } else if (codepoint >= 0xDC00 && codepoint <= 0xDFFF) { - return false; // unpaired low surrogate - } - continue; - } - switch (escaped) { - case '"': - case '\\': - case '/': - case 'b': - case 'f': - case 'n': - case 'r': - case 't': - p = b + 2; - continue; - default: - return false; // invalid escape - } - } -} - -/** - * @brief Decodes a JSON string into @p out, validating it as it goes. - * @param p In: pointing at the opening quote. Out: pointing past the closing quote. - */ -bool decodeString(const char *&p, const char *end, QString &out) -{ - out.clear(); - QByteArray utf8; - auto flush = [&out, &utf8]() { - if (!utf8.isEmpty()) { - out += QString::fromUtf8(utf8); - utf8.clear(); - } - }; - - ++p; // opening quote - while (p < end) { - const char c = *p; - if (c == '\\') { - flush(); - ++p; // escaped character - if (p >= end) { - return false; - } - const char escaped = *p; - if (escaped == 'u') { - ++p; // first hex digit - if (p + 4 > end) { - return false; - } - quint32 codepoint = 0; - for (int i = 0; i < 4; ++i) { - if (!isHexDigit(p[i])) { - return false; - } - codepoint = codepoint * 16 + hexValue(p[i]); - } - p += 4; - if (codepoint >= 0xD800 && codepoint <= 0xDBFF) { - // expect a low-surrogate escape for the second half - if (p + 6 > end || p[0] != '\\' || p[1] != 'u') { - return false; // unpaired high surrogate - } - quint32 low = 0; - for (int i = 0; i < 4; ++i) { - if (!isHexDigit(p[2 + i])) { - return false; - } - low = low * 16 + hexValue(p[2 + i]); - } - if (low < 0xDC00 || low > 0xDFFF) { - return false; - } - out += QChar(codepoint); - out += QChar(low); - p += 6; - } else if (codepoint >= 0xDC00 && codepoint <= 0xDFFF) { - return false; // unpaired low surrogate - } else { - out += QChar(codepoint); - } - continue; - } - switch (escaped) { - case '"': - out += '"'; - break; - case '\\': - out += '\\'; - break; - case '/': - out += '/'; - break; - case 'b': - out += '\b'; - break; - case 'f': - out += '\f'; - break; - case 'n': - out += '\n'; - break; - case 'r': - out += '\r'; - break; - case 't': - out += '\t'; - break; - default: - return false; - } - ++p; - continue; - } - if (c == '"') { - ++p; - flush(); - return true; - } - // Deliberately accept unescaped control characters (e.g. a tab inside - // a set name): QJsonDocument and skipString accept them too, so - // rejecting them here would fail the whole document on a byte that - // Qt is fine with — the very total-failure mode this scanner avoids. - utf8 += c; - ++p; - } - return false; -} - -/** - * @brief Reads a set-metadata field, tolerating null and non-string values. - * - * A set's metadata may carry null or non-string values in otherwise-valid - * payloads ("releaseDate": null, "type": 7). The token itself was already - * structurally validated by skipValue, so a non-string value is accepted and - * leaves @p out at its default (empty) — one bad set must not abort the - * import of every other set in the document. - */ -bool decodeStringMember(const char *&fs, const char *&fe, QString &out) -{ - if (fs >= fe) { - return false; - } - if (*fs != '"') { - return true; - } - return decodeString(fs, fe, out); -} - -bool matchLiteral(const char *&p, const char *end, const char *literal, int length) -{ - if (end - p < length || memcmp(p, literal, static_cast(length)) != 0) { - return false; - } - const char *after = p + length; - if (after < end && (QChar::isLetter(*after) || QChar::isDigit(*after) || *after == '_')) { - return false; - } - p = after; - return true; -} - -bool skipNumber(const char *&p, const char *end) -{ - // JSON number: -?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)? - if (p < end && *p == '-') { - ++p; - } - if (p < end && *p == '0') { - ++p; - } else if (p < end && *p >= '1' && *p <= '9') { - ++p; - while (p < end && QChar::isDigit(*p)) { - ++p; - } - } else { - return false; - } - if (p < end && *p == '.') { - ++p; - if (p >= end || !QChar::isDigit(*p)) { - return false; - } - while (p < end && QChar::isDigit(*p)) { - ++p; - } - } - if (p < end && (*p == 'e' || *p == 'E')) { - ++p; - if (p < end && (*p == '+' || *p == '-')) { - ++p; - } - if (p >= end || !QChar::isDigit(*p)) { - return false; - } - while (p < end && QChar::isDigit(*p)) { - ++p; - } - } - return true; -} - -bool skipValue(const char *&p, const char *end, int depth); -bool skipObject(const char *&p, const char *end, int depth); -bool skipArray(const char *&p, const char *end, int depth); - -bool skipPrimitive(const char *&p, const char *end) -{ - if (p >= end) { - return false; - } - const char c = *p; - if (c == '"') { - return skipString(p, end); - } - if (c == 't') { - return matchLiteral(p, end, "true", 4); - } - if (c == 'f') { - return matchLiteral(p, end, "false", 5); - } - if (c == 'n') { - return matchLiteral(p, end, "null", 4); - } - if (c == '-' || (c >= '0' && c <= '9')) { - return skipNumber(p, end); - } - return false; -} - -bool skipObject(const char *&p, const char *end, int depth) -{ - if (depth <= 0) { - return false; // nest deeper than the cap - } - ++p; // '{' - p = skipWhitespace(p, end); - if (p < end && *p == '}') { - ++p; - return true; - } - for (;;) { - p = skipWhitespace(p, end); - if (p >= end || *p != '"') { - return false; - } - if (!skipString(p, end)) { - return false; - } - p = skipWhitespace(p, end); - if (p >= end || *p != ':') { - return false; - } - ++p; - if (!skipValue(p, end, depth - 1)) { - return false; - } - p = skipWhitespace(p, end); - if (p >= end) { - return false; - } - if (*p == ',') { - ++p; - continue; - } - if (*p == '}') { - ++p; - return true; - } - return false; - } -} - -bool skipArray(const char *&p, const char *end, int depth) -{ - if (depth <= 0) { - return false; // nest deeper than the cap - } - ++p; // '[' - p = skipWhitespace(p, end); - if (p < end && *p == ']') { - ++p; - return true; - } - for (;;) { - if (!skipValue(p, end, depth - 1)) { - return false; - } - p = skipWhitespace(p, end); - if (p >= end) { - return false; - } - if (*p == ',') { - ++p; - continue; - } - if (*p == ']') { - ++p; - return true; - } - return false; - } -} - -bool skipValue(const char *&p, const char *end, int depth) -{ - p = skipWhitespace(p, end); - if (p >= end) { - return false; - } - const char c = *p; - if (c == '{') { - // pass depth through: skipObject consumes the single decrement for this level - return skipObject(p, end, depth); - } - if (c == '[') { - return skipArray(p, end, depth); - } - // a primitive is a leaf, so it never wastes a nesting level - return skipPrimitive(p, end); -} - -/** - * @brief Iterates the members of the object starting at @p p. - * - * For each member invokes @p memberCallback with the key and the byte range of - * its value. Advancing @p p is unaffected by the callback. - */ -template bool forEachObjectMember(const char *&p, const char *end, int depth, F &&memberCallback) -{ - if (depth <= 0) { - return false; // nest deeper than the cap - } - ++p; // '{' - p = skipWhitespace(p, end); - if (p < end && *p == '}') { - ++p; - return true; - } - for (;;) { - p = skipWhitespace(p, end); - if (p >= end || *p != '"') { - return false; - } - QString key; - if (!decodeString(p, end, key)) { - return false; - } - p = skipWhitespace(p, end); - if (p >= end || *p != ':') { - return false; - } - ++p; - const char *valueStart = skipWhitespace(p, end); - const char *valueEnd = valueStart; - if (!skipValue(valueEnd, end, depth - 1)) { - return false; - } - if (!memberCallback(key, valueStart, valueEnd)) { - return false; - } - p = valueEnd; - p = skipWhitespace(p, end); - if (p >= end) { - return false; - } - if (*p == ',') { - ++p; - continue; - } - if (*p == '}') { - ++p; - return true; - } - return false; - } -} - -// Counts the direct elements of an array value; returns -1 if the array is malformed. -int countArrayElements(const char *p, const char *end, int depth) -{ - if (depth <= 0) { - return -1; // nest deeper than the cap - } - ++p; // '[' - p = skipWhitespace(p, end); - int count = 0; - if (p < end && *p == ']') { - return 0; - } - for (;;) { - if (!skipValue(p, end, depth - 1)) { - return -1; - } - ++count; - p = skipWhitespace(p, end); - if (p >= end) { - return -1; - } - if (*p == ',') { - ++p; - continue; - } - if (*p == ']') { - return count; - } - return -1; - } -} - -} // namespace - -namespace RawJson -{ - -QList scanSetRanges(const QByteArray &json, ScanError *error) -{ - QList ranges; - if (error) { - *error = ScanError{}; - } - - const auto fail = [&](const QString &message) -> QList { - if (error) { - error->message = message; - } - return {}; - }; - - const char *begin = json.constData(); - const char *end = begin + json.size(); - if (begin >= end) { - return fail(QStringLiteral("empty JSON document")); - } - - const char *p = skipWhitespace(begin, end); - if (p >= end || *p != '{') { - return fail(QStringLiteral("top-level JSON must be an object")); - } - - bool foundData = false; - bool malformedSetData = false; - - const auto topLevelCallback = [&](const QString &key, const char *valueStart, const char *valueEnd) { - if (key == QStringLiteral("data")) { - foundData = true; - if (valueStart >= valueEnd || *valueStart != '{') { - malformedSetData = true; - return false; - } - const char *setP = valueStart; - const bool ok = forEachObjectMember(setP, valueEnd, kMaxNestingDepth - 1, - [&](const QString &setCode, const char *setStart, const char *setEnd) { - if (setStart >= setEnd || *setStart != '{') { - malformedSetData = true; - return false; - } - SetRange range; - range.dataRange.start = setStart - begin; - range.dataRange.length = setEnd - setStart; - range.code = setCode; - - const char *memberP = setStart; - const bool metaOk = forEachObjectMember( - memberP, setEnd, kMaxNestingDepth - 2, - [&](const QString &field, const char *fs, const char *fe) { - if (field == QStringLiteral("code")) { - return decodeStringMember(fs, fe, range.code); - } - if (field == QStringLiteral("name")) { - return decodeStringMember(fs, fe, range.name); - } - if (field == QStringLiteral("type")) { - return decodeStringMember(fs, fe, range.type); - } - if (field == QStringLiteral("releaseDate")) { - return decodeStringMember(fs, fe, range.releaseDate); - } - if (field == QStringLiteral("cards")) { - if (fs >= fe) { - return false; - } - if (*fs != '[') { - // e.g. "cards": null — treat as an empty array, - // matching Qt's tolerance. - return true; - } - range.dataRange.cardCount = - countArrayElements(fs, fe, kMaxNestingDepth - 2); - return range.dataRange.cardCount >= 0; - } - return true; - }); - if (!metaOk) { - malformedSetData = true; - return false; - } - ranges.append(range); - return true; - }); - if (!ok) { - malformedSetData = true; - return false; - } - } - return true; - }; - - if (!forEachObjectMember(p, end, kMaxNestingDepth, topLevelCallback)) { - return fail(malformedSetData ? QStringLiteral("malformed set data") : QStringLiteral("malformed JSON")); - } - p = skipWhitespace(p, end); - if (p != end) { - return fail(QStringLiteral("trailing content after top-level JSON object")); - } - if (!foundData) { - return fail(QStringLiteral("missing \"data\" object")); - } - if (ranges.isEmpty()) { - return fail(QStringLiteral("no sets found in \"data\"")); - } - return ranges; -} - -} // namespace RawJson \ No newline at end of file diff --git a/oracle/src/raw_json_scanner.h b/oracle/src/raw_json_scanner.h deleted file mode 100644 index f6e3a4647..000000000 --- a/oracle/src/raw_json_scanner.h +++ /dev/null @@ -1,76 +0,0 @@ -#ifndef RAW_JSON_SCANNER_H -#define RAW_JSON_SCANNER_H - -#include -#include -#include - -namespace RawJson -{ - -/** - * @brief The byte extent of a set's object inside the scanned document, plus - * the size of its cards array. This is the slice SetToDownload needs for lazy - * per-set parsing; the metadata strings live in SetRange alongside it. - */ -struct SetDataRange -{ - /** @brief Byte offset of the set's object within the scanned buffer. */ - qsizetype start = -1; - /** @brief Byte length of the set's object, including the surrounding braces. */ - qsizetype length = 0; - /** @brief Number of entries in the set's "cards" array. */ - int cardCount = 0; -}; - -struct SetRange -{ - /** @brief The byte slice of this set within the document. */ - SetDataRange dataRange; - QString code; - QString name; - QString type; - QString releaseDate; -}; - -struct ScanError -{ - bool isError() const - { - return !message.isEmpty(); - } - QString message; -}; - -/** - * @brief Scans a full MTGJSON document without materializing the JSON tree. - * - * Splits the top-level "data" object into per-set byte ranges and reads each - * set's metadata directly from the raw bytes. The oracle importer can then - * parse one set at a time during import, keeping peak memory far below a single - * QJsonDocument::fromJson() over the whole file. - * - * The whole document is structurally validated while scanning (strings, - * escapes, braces, and a trailing-content check) and nesting depth is capped at - * 1024 to match QJsonDocument, so pathologically deep documents fail shallowly - * instead of exhausting the stack. Verdicts agree with QJsonDocument::fromJson - * on structurally malformed input; unlike Qt, string metadata fields - * ("name", "type", "releaseDate", "code") tolerate null / non-string values by - * defaulting to empty rather than rejecting the whole document, so one broken - * set cannot abort the import of the rest. - * - * Following QJsonDocument::fromJson's convention, the parsed ranges are - * returned by value and any failure is reported through the @p error out - * parameter. - * - * @param json The raw MTGJSON document bytes. - * @param error Out parameter. Set to an error ScanError when the document - * cannot be parsed, otherwise left empty. Passing a null - * pointer disables error reporting. - * @return The detected per-set ranges, or an empty list on failure. - */ -QList scanSetRanges(const QByteArray &json, ScanError *error = nullptr); - -} // namespace RawJson - -#endif // RAW_JSON_SCANNER_H \ No newline at end of file diff --git a/servatrice/CMakeLists.txt b/servatrice/CMakeLists.txt index 5d8089ad1..aba63800c 100644 --- a/servatrice/CMakeLists.txt +++ b/servatrice/CMakeLists.txt @@ -95,8 +95,6 @@ set(DESKTOPDIR # Build servatrice binary and link it add_executable(servatrice MACOSX_BUNDLE ${servatrice_MOC_SRCS} ${servatrice_RESOURCES_RCC} ${servatrice_SOURCES}) -target_precompile_headers(servatrice PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") - if(CMAKE_HOST_SYSTEM MATCHES "FreeBSD") target_link_libraries( servatrice libcockatrice_deck_list libcockatrice_network_server_remote Threads::Threads ${SERVATRICE_QT_MODULES} diff --git a/servatrice/src/main.cpp b/servatrice/src/main.cpp index 13bf95a82..9e7fe38d9 100644 --- a/servatrice/src/main.cpp +++ b/servatrice/src/main.cpp @@ -33,7 +33,6 @@ #include #include #include -#include #include RNG_Abstract *rng; @@ -170,7 +169,7 @@ int main(int argc, char *argv[]) signalhandler = new SignalHandler(); - rng = new RNG_SFMT(CryptoUtil::randomUInt64()); + rng = new RNG_SFMT; std::cerr << "Servatrice " << VERSION_STRING << " starting." << std::endl; std::cerr << "-------------------------" << std::endl; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 18ab60d06..29caf257e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -16,7 +16,7 @@ add_test(NAME lag_monitor_test COMMAND lag_monitor_test) add_test(NAME latency_tracker_test COMMAND latency_tracker_test) add_test(NAME deck_hash_performance_test COMMAND deck_hash_performance_test) -set_tests_properties(deck_hash_performance_test PROPERTIES TIMEOUT 15) +set_tests_properties(deck_hash_performance_test PROPERTIES TIMEOUT 5) # Find GTest @@ -114,8 +114,6 @@ target_link_libraries( add_subdirectory(card_zone_algorithms) add_subdirectory(carddatabase) -add_subdirectory(deck_list_model) -add_subdirectory(deck_list_zones) add_subdirectory(loading_from_clipboard) add_subdirectory(movecard_tests) add_subdirectory(oracle) diff --git a/tests/deck_list_model/CMakeLists.txt b/tests/deck_list_model/CMakeLists.txt deleted file mode 100644 index e3096c559..000000000 --- a/tests/deck_list_model/CMakeLists.txt +++ /dev/null @@ -1,33 +0,0 @@ -add_executable(deck_list_model_custom_zones_test ${VERSION_STRING_CPP} deck_list_model_custom_zones_test.cpp) - -if(NOT GTEST_FOUND) - add_dependencies(deck_list_model_custom_zones_test gtest) -endif() - -target_link_libraries( - deck_list_model_custom_zones_test - libcockatrice_models - libcockatrice_card - libcockatrice_deck_list - Threads::Threads - ${GTEST_BOTH_LIBRARIES} - ${TEST_QT_MODULES} -) -add_test(NAME deck_list_model_custom_zones_test COMMAND deck_list_model_custom_zones_test) - -add_executable(deck_list_model_zone_integration_test ${VERSION_STRING_CPP} deck_list_model_zone_integration_test.cpp) - -if(NOT GTEST_FOUND) - add_dependencies(deck_list_model_zone_integration_test gtest) -endif() - -target_link_libraries( - deck_list_model_zone_integration_test - libcockatrice_models - libcockatrice_card - libcockatrice_deck_list - Threads::Threads - ${GTEST_BOTH_LIBRARIES} - ${TEST_QT_MODULES} -) -add_test(NAME deck_list_model_zone_integration_test COMMAND deck_list_model_zone_integration_test) diff --git a/tests/deck_list_model/deck_list_model_custom_zones_test.cpp b/tests/deck_list_model/deck_list_model_custom_zones_test.cpp deleted file mode 100644 index 7f4ab81e1..000000000 --- a/tests/deck_list_model/deck_list_model_custom_zones_test.cpp +++ /dev/null @@ -1,276 +0,0 @@ -/** - * @file deck_list_model_custom_zones_test.cpp - * @brief Tests for the deck list model's custom-zone shadow-tree helpers. - * - * DeckListModelCustomZones centralizes every "what is / where is a custom zone" - * decision for the model's shadow tree: type testing, mirroring from the deck - * tree, name lookup, and the sort-with-custom-zones-last ordering. These tests - * exercise that logic directly on hand-built shadow trees, independent of the - * full model and card database machinery. - */ - -#include -#include -#include -#include - -namespace -{ - -DecklistModelCardNode *cardNode(InnerDecklistNode *parent, const QString &name, int number) -{ - // The underlying data node is detached; only the model wrapper is attached to the shadow tree. - auto *data = new DecklistCardNode(name, number, nullptr); - return new DecklistModelCardNode(data, parent); -} - -QStringList childNames(const InnerDecklistNode *node) -{ - QStringList names; - for (int i = 0; i < node->size(); ++i) { - names.append(node->at(i)->getName()); - } - return names; -} - -} // namespace - -// ===================================================================================================================== -// isCustomZone -// ===================================================================================================================== - -TEST(DeckListModelCustomZones, IsCustomZoneDistinguishesZoneFromGroup) -{ - InnerDecklistNode root; - auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); - auto *group = new InnerDecklistNode("Creature", board); - auto *zone = new DecklistModelSubZoneNode("Removal", board); - - auto *card = cardNode(group, "A", 1); - - EXPECT_FALSE(DeckListModelCustomZones::isCustomZone(board)); - EXPECT_FALSE(DeckListModelCustomZones::isCustomZone(group)); - EXPECT_FALSE(DeckListModelCustomZones::isCustomZone(card)); - EXPECT_TRUE(DeckListModelCustomZones::isCustomZone(zone)); -} - -// ===================================================================================================================== -// findSubZoneByName -// ===================================================================================================================== - -TEST(DeckListModelCustomZones, FindSubZoneByNameFindsAcrossBoards) -{ - InnerDecklistNode root; - auto *main = new InnerDecklistNode(DECK_ZONE_MAIN, &root); - auto *side = new InnerDecklistNode(DECK_ZONE_SIDE, &root); - new DecklistModelSubZoneNode("Removal", main); - new DecklistModelSubZoneNode("Utility", side); - new InnerDecklistNode("Plain", main); // not a custom zone - - auto *removal = DeckListModelCustomZones::findSubZoneByName(&root, "Removal"); - ASSERT_NE(removal, nullptr); - EXPECT_EQ(removal->getName(), QString("Removal")); - - auto *utility = DeckListModelCustomZones::findSubZoneByName(&root, "Utility"); - ASSERT_NE(utility, nullptr); - EXPECT_EQ(utility->getName(), QString("Utility")); - - // Names are deck-unique; a plain group or built-in board is not matched. - EXPECT_EQ(DeckListModelCustomZones::findSubZoneByName(&root, "Plain"), nullptr); - EXPECT_EQ(DeckListModelCustomZones::findSubZoneByName(&root, DECK_ZONE_MAIN), nullptr); - EXPECT_EQ(DeckListModelCustomZones::findSubZoneByName(&root, "Missing"), nullptr); -} - -// ===================================================================================================================== -// mirrorCustomZones -// ===================================================================================================================== - -TEST(DeckListModelCustomZones, MirrorCustomZonesCopiesCardsFlat) -{ - // Deck-tree board zone: one direct card plus one nested custom zone. - auto *deckBoard = new InnerDecklistNode(DECK_ZONE_MAIN); - new DecklistCardNode("Direct", 2, deckBoard); - - auto *deckZone = new InnerDecklistNode("Removal", deckBoard); - auto *deckCard1 = new DecklistCardNode("Bolt", 3, deckZone); - auto *deckCard2 = new DecklistCardNode("Swords", 1, deckZone); - - InnerDecklistNode shadowRoot; - auto *shadowBoard = new InnerDecklistNode(DECK_ZONE_MAIN, &shadowRoot); - - DeckListModelCustomZones::mirrorCustomZones(deckBoard, shadowBoard); - - // Only the custom zone is mirrored as a sub-zone; the direct card is not. - ASSERT_EQ(shadowBoard->size(), 1); - auto *shadowZone = dynamic_cast(shadowBoard->at(0)); - ASSERT_NE(shadowZone, nullptr); - EXPECT_EQ(shadowZone->getName(), QString("Removal")); - - // Cards live flat (un-grouped) inside the mirrored zone, wrapping the same data nodes. - ASSERT_EQ(shadowZone->size(), 2); - auto *shadowCard1 = dynamic_cast(shadowZone->at(0)); - auto *shadowCard2 = dynamic_cast(shadowZone->at(1)); - ASSERT_NE(shadowCard1, nullptr); - ASSERT_NE(shadowCard2, nullptr); - EXPECT_EQ(shadowCard1->getDataNode(), deckCard1); - EXPECT_EQ(shadowCard2->getDataNode(), deckCard2); -} - -TEST(DeckListModelCustomZones, MirrorCustomZonesWithNoCustomZonesIsNoop) -{ - // A board zone with only direct cards has nothing to mirror. - auto *deckBoard = new InnerDecklistNode(DECK_ZONE_MAIN); - new DecklistCardNode("Direct", 2, deckBoard); - - InnerDecklistNode shadowRoot; - auto *shadowBoard = new InnerDecklistNode(DECK_ZONE_MAIN, &shadowRoot); - - DeckListModelCustomZones::mirrorCustomZones(deckBoard, shadowBoard); - EXPECT_EQ(shadowBoard->size(), 0); -} - -TEST(DeckListModelCustomZones, MirrorCustomZonesFlattensNestedSubzones) -{ - // Cards deeper than one level under a custom zone still get a model row. - auto *deckBoard = new InnerDecklistNode(DECK_ZONE_MAIN); - auto *deckZone = new InnerDecklistNode("Removal", deckBoard); - auto *deckCard1 = new DecklistCardNode("Bolt", 1, deckZone); - auto *deeper = new InnerDecklistNode("Deeper", deckZone); - auto *deckCard2 = new DecklistCardNode("Swords", 1, deeper); - - InnerDecklistNode shadowRoot; - auto *shadowBoard = new InnerDecklistNode(DECK_ZONE_MAIN, &shadowRoot); - - DeckListModelCustomZones::mirrorCustomZones(deckBoard, shadowBoard); - - ASSERT_EQ(shadowBoard->size(), 1); - auto *shadowZone = dynamic_cast(shadowBoard->at(0)); - ASSERT_NE(shadowZone, nullptr); - EXPECT_EQ(shadowZone->getName(), QString("Removal")); - - // Both cards are flattened into the mirrored zone, preserving order. - ASSERT_EQ(shadowZone->size(), 2); - auto *shadowCard1 = dynamic_cast(shadowZone->at(0)); - auto *shadowCard2 = dynamic_cast(shadowZone->at(1)); - ASSERT_NE(shadowCard1, nullptr); - ASSERT_NE(shadowCard2, nullptr); - EXPECT_EQ(shadowCard1->getDataNode(), deckCard1); - EXPECT_EQ(shadowCard2->getDataNode(), deckCard2); -} - -// ===================================================================================================================== -// findGroupChild -// ===================================================================================================================== - -TEST(DeckListModelCustomZones, FindGroupChildSkipsCustomZones) -{ - InnerDecklistNode root; - auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); - auto *group = new InnerDecklistNode("Creature", board); - new DecklistModelSubZoneNode("Creature", board); - - EXPECT_EQ(DeckListModelCustomZones::findGroupChild(board, "Creature"), group); - EXPECT_EQ(DeckListModelCustomZones::findGroupChild(board, "Missing"), nullptr); - EXPECT_EQ(DeckListModelCustomZones::findGroupChild(&root, DECK_ZONE_MAIN), board); -} - -// ===================================================================================================================== -// sortWithCustomZonesLast -// ===================================================================================================================== - -TEST(DeckListModelCustomZones, SortBoardKeepsCustomZonesAfterGroupsAscending) -{ - InnerDecklistNode root; - auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); - new DecklistModelSubZoneNode("Zebra", board); - new InnerDecklistNode("Creature", board); - new InnerDecklistNode("Instant", board); - new DecklistModelSubZoneNode("Alpha", board); - - root.setSortMethod(DeckSortMethod::ByName); - - auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::AscendingOrder); - - // Groups sort first (by name), then custom zones (by name), always after groups. - EXPECT_EQ(childNames(board), (QStringList{"Creature", "Instant", "Alpha", "Zebra"})); - - // Some non-identity movement occurred. - EXPECT_FALSE(mapping.isEmpty()); -} - -TEST(DeckListModelCustomZones, SortBoardKeepsCustomZonesAfterGroupsDescending) -{ - InnerDecklistNode root; - auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); - new DecklistModelSubZoneNode("Zebra", board); - new InnerDecklistNode("Creature", board); - new InnerDecklistNode("Instant", board); - new DecklistModelSubZoneNode("Alpha", board); - - root.setSortMethod(DeckSortMethod::ByName); - - (void)DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::DescendingOrder); - - // Groups still lead (descending), custom zones still last. - EXPECT_EQ(childNames(board), (QStringList{"Instant", "Creature", "Zebra", "Alpha"})); -} - -TEST(DeckListModelCustomZones, SortBoardMappingIsConsistent) -{ - InnerDecklistNode root; - auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); - - QList originalOrder; - auto *g0 = new InnerDecklistNode("Creature", board); - originalOrder.append(g0); - auto *z0 = new DecklistModelSubZoneNode("Zebra", board); - originalOrder.append(z0); - auto *g1 = new InnerDecklistNode("Instant", board); - originalOrder.append(g1); - auto *z1 = new DecklistModelSubZoneNode("Alpha", board); - originalOrder.append(z1); - - root.setSortMethod(DeckSortMethod::ByName); - - auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::AscendingOrder); - - // The mapping reports, for each final row, the original row of the node now sitting there. - ASSERT_EQ(mapping.size(), board->size()); - for (const auto &move : mapping) { - const int preSortRow = move.first; - const int finalRow = move.second; - ASSERT_GE(preSortRow, 0); - ASSERT_LT(preSortRow, originalOrder.size()); - EXPECT_EQ(board->at(finalRow), originalOrder[preSortRow]) << "row " << finalRow; - } - - // Final order sanity: groups first in name order, then custom zones. - EXPECT_EQ(childNames(board), (QStringList{"Creature", "Instant", "Alpha", "Zebra"})); -} - -TEST(DeckListModelCustomZones, SortPlainNodeDoesNotReorderCustomZones) -{ - // A non-board node (e.g. a group whose children are cards) is sorted plainly; - // custom zones are not a special case there. Cards sort by name. - InnerDecklistNode root; - auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); - auto *group = new InnerDecklistNode("Creature", board); - cardNode(group, "Swords", 1); - cardNode(group, "Bolt", 3); - - root.setSortMethod(DeckSortMethod::ByName); - - auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, group, Qt::AscendingOrder); - EXPECT_EQ(childNames(group), (QStringList{"Bolt", "Swords"})); - ASSERT_EQ(mapping.size(), 2); - EXPECT_EQ(mapping[0].first, 1); // "Bolt" was originally at row 1 - EXPECT_EQ(mapping[0].second, 0); - EXPECT_EQ(mapping[1].first, 0); - EXPECT_EQ(mapping[1].second, 1); -} - -int main(int argc, char **argv) -{ - ::testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); -} diff --git a/tests/deck_list_model/deck_list_model_zone_integration_test.cpp b/tests/deck_list_model/deck_list_model_zone_integration_test.cpp deleted file mode 100644 index a4562c95d..000000000 --- a/tests/deck_list_model/deck_list_model_zone_integration_test.cpp +++ /dev/null @@ -1,283 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace -{ - -int totalCustomZoneRows(const DeckListModel &model) -{ - int count = 0; - const int rootRows = model.rowCount(QModelIndex()); - for (int r = 0; r < rootRows; ++r) { - const QModelIndex board = model.index(r, 0, QModelIndex()); - const int childRows = model.rowCount(board); - for (int c = 0; c < childRows; ++c) { - const QModelIndex child = model.index(c, 0, board); - if (child.data(DeckRoles::IsCustomZoneRole).toBool()) { - ++count; - } - } - } - return count; -} - -QModelIndex findBoardIndex(const DeckListModel &model, const QString &boardName) -{ - for (int r = 0; r < model.rowCount(QModelIndex()); ++r) { - const QModelIndex idx = model.index(r, 0, QModelIndex()); - if (idx.data(DeckRoles::IsCardRole).toBool()) { - continue; - } - const QString name = idx.sibling(idx.row(), DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); - if (name == boardName) { - return idx; - } - } - return {}; -} - -QModelIndex findZoneRow(const DeckListModel &model, const QModelIndex &board) -{ - for (int r = 0; r < model.rowCount(board); ++r) { - const QModelIndex child = model.index(r, 0, board); - if (child.data(DeckRoles::IsCustomZoneRole).toBool()) { - return child; - } - } - return {}; -} - -} // namespace - -// The "Add to Zone" combobox/submenu lists getCustomZoneNames(), which reads the -// deck tree. These verify the source data a freshly-created zone populates. - -TEST(DeckListModelZoneIntegration, CreateZoneThenReadCustomZoneNames) -{ - QSharedPointer deck(new DeckList()); - DeckListModel model(nullptr, deck); - auto *tree = deck->getTree(); - - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); - EXPECT_EQ(model.getCustomZoneNames(DECK_ZONE_MAIN), (QStringList{"Removal"})); -} - -TEST(DeckListModelZoneIntegration, CreateTwoZonesThenReadBoth) -{ - QSharedPointer deck(new DeckList()); - DeckListModel model(nullptr, deck); - auto *tree = deck->getTree(); - - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Utility"), nullptr); - EXPECT_EQ(model.getCustomZoneNames(DECK_ZONE_MAIN), (QStringList{"Removal", "Utility"})); -} - -// Mirroring regression: rebuildTree must mirror each custom zone exactly once. -TEST(DeckListModelZoneIntegration, RebuildTreeMirrorsEachZoneOnce) -{ - QSharedPointer deck(new DeckList()); - DeckListModel model(nullptr, deck); - auto *tree = deck->getTree(); - - // One direct mainboard card plus two nested custom zones. - tree->addCard("Lightning Bolt", 2, DECK_ZONE_MAIN, -1); - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); - tree->addCard("Swords to Plowshares", 1, "Removal", -1); - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Utility"), nullptr); - - model.rebuildTree(); - - EXPECT_EQ(model.getCustomZoneNames(DECK_ZONE_MAIN), (QStringList{"Removal", "Utility"})); - EXPECT_EQ(totalCustomZoneRows(model), 2); -} - -// ===================================================================================================================== -// Model behaviour: addCard routing, findCard lookup, removeRows guard, empty-zone survival. -// ===================================================================================================================== - -TEST(DeckListModelZoneIntegration, AddCardRoutesIntoMirroredCustomZone) -{ - QSharedPointer deck(new DeckList()); - DeckListModel model(nullptr, deck); - auto *tree = deck->getTree(); - - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); - model.rebuildTree(); - - QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), "Removal"); - ASSERT_TRUE(added.isValid()); - - // The card is a direct child of the mirrored custom zone, not a new top-level zone. - const QModelIndex zoneParent = added.parent(); - ASSERT_TRUE(zoneParent.isValid()); - EXPECT_TRUE(zoneParent.data(DeckRoles::IsCustomZoneRole).toBool()); - EXPECT_EQ(zoneParent.sibling(zoneParent.row(), DeckListModelColumns::CARD_NAME).data(Qt::DisplayRole).toString(), - QString("Removal")); - - // No "Removal" top-level zone appeared in the deck tree. - auto *listRoot = tree->getRoot(); - bool topLevelRemoval = false; - for (int i = 0; i < listRoot->size(); ++i) { - if (auto *zone = dynamic_cast(listRoot->at(i))) { - topLevelRemoval |= zone->getName() == "Removal"; - } - } - EXPECT_FALSE(topLevelRemoval); -} - -TEST(DeckListModelZoneIntegration, AddCardToUnmirroredCustomZoneRebuildsNotCreatesTopLevel) -{ - QSharedPointer deck(new DeckList()); - DeckListModel model(nullptr, deck); - auto *tree = deck->getTree(); - - // The zone exists on the deck tree but the shadow tree has never mirrored it. - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); - - QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), "Removal"); - ASSERT_TRUE(added.isValid()); - - const QModelIndex zoneParent = added.parent(); - ASSERT_TRUE(zoneParent.isValid()); - EXPECT_TRUE(zoneParent.data(DeckRoles::IsCustomZoneRole).toBool()); - EXPECT_EQ(zoneParent.sibling(zoneParent.row(), DeckListModelColumns::CARD_NAME).data(Qt::DisplayRole).toString(), - QString("Removal")); -} - -TEST(DeckListModelZoneIntegration, AddCardCreatesGroupSeparatelyFromSameNamedZone) -{ - QSharedPointer deck(new DeckList()); - DeckListModel model(nullptr, deck); - auto *tree = deck->getTree(); - - // A custom zone named exactly like a grouping criterion. - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Creature"), nullptr); - model.rebuildTree(); - - CardInfoPtr bear = CardInfo::newInstance("Grizzly Bears"); - bear->setProperty(Mtg::MainCardType, "Creature"); - - QModelIndex added = model.addCard(ExactCard(bear), DECK_ZONE_MAIN); - ASSERT_TRUE(added.isValid()); - - // The card lands in a *group* node called "Creature", not swallowed by the custom zone. - const QModelIndex groupParent = added.parent(); - ASSERT_TRUE(groupParent.isValid()); - EXPECT_FALSE(groupParent.data(DeckRoles::IsCustomZoneRole).toBool()); - EXPECT_EQ(groupParent.sibling(groupParent.row(), DeckListModelColumns::CARD_NAME).data(Qt::DisplayRole).toString(), - QString("Creature")); - - // The board keeps both rows: the "Creature" group and the "Creature" custom zone. - const QModelIndex boardIndex = groupParent.parent(); - ASSERT_TRUE(boardIndex.isValid()); - EXPECT_EQ(model.rowCount(boardIndex), 2); -} - -TEST(DeckListModelZoneIntegration, FindCardResolvesCardInsideCustomZone) -{ - QSharedPointer deck(new DeckList()); - DeckListModel model(nullptr, deck); - auto *tree = deck->getTree(); - - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); - model.rebuildTree(); - - // findCard resolves through the card database; register the card we add. - const QString cardName = "Swords to Plowshares"; - CardInfoPtr info = CardInfo::newInstance(cardName); - CardDatabaseManager::getInstance()->addCard(info); - - QModelIndex added = model.addCard(ExactCard(info), "Removal"); - ASSERT_TRUE(added.isValid()); - - QModelIndex found = model.findCard(cardName, "Removal"); - EXPECT_TRUE(found.isValid()); - EXPECT_EQ(found, added); -} - -TEST(DeckListModelZoneIntegration, RemoveRowsRefusesCustomZoneRow) -{ - QSharedPointer deck(new DeckList()); - DeckListModel model(nullptr, deck); - auto *tree = deck->getTree(); - - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); - tree->addCard("Lightning Bolt", 2, DECK_ZONE_MAIN, -1); - model.rebuildTree(); - - const QModelIndex mainIndex = findBoardIndex(model, DECK_ZONE_MAIN); - ASSERT_TRUE(mainIndex.isValid()); - const QModelIndex zoneRow = findZoneRow(model, mainIndex); - ASSERT_TRUE(zoneRow.isValid()); - - EXPECT_FALSE(model.removeRow(zoneRow.row(), zoneRow.parent())); - EXPECT_EQ(model.rowCount(mainIndex), 2); // the zone survives, alongside the card group -} - -TEST(DeckListModelZoneIntegration, EmptyCustomZoneSurvivesMirrorAndPruning) -{ - QSharedPointer deck(new DeckList()); - DeckListModel model(nullptr, deck); - auto *tree = deck->getTree(); - - // An empty custom zone must be mirrored (the stack deliberately keeps it alive). - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); - model.rebuildTree(); - - const QModelIndex mainIndex = findBoardIndex(model, DECK_ZONE_MAIN); - ASSERT_TRUE(mainIndex.isValid()); - EXPECT_EQ(model.rowCount(mainIndex), 1); - EXPECT_TRUE(findZoneRow(model, mainIndex).isValid()); -} - -// Regression: a board card named like the requested zone must not be mistaken for -// a zone. Previously `findChild` matched any child by name, so a mainboard card -// called "Lightning Bolt" made addCard believe a "Lightning Bolt" zone existed and -// recurse through rebuildTree forever. -TEST(DeckListModelZoneIntegration, AddCardToCardNamedZoneDoesNotRecurse) -{ - QSharedPointer deck(new DeckList()); - DeckListModel model(nullptr, deck); - auto *tree = deck->getTree(); - - tree->addCard("Lightning Bolt", 2, DECK_ZONE_MAIN, -1); - - QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Swords to Plowshares")), "Lightning Bolt"); - ASSERT_TRUE(added.isValid()); -} - -// Regression: adding to a custom zone that holds a nested sub-zone mirrored the -// nested cards as flattened shadow rows, so the sorted shadow row index pointed -// past the deck zone's direct children. The card must be appended to the deck -// zone instead of being written out of range. -TEST(DeckListModelZoneIntegration, AddCardToCustomZoneWithNestedSubZoneAppends) -{ - QSharedPointer deck(new DeckList()); - DeckListModel model(nullptr, deck); - auto *tree = deck->getTree(); - - auto *removal = tree->addCustomZone(DECK_ZONE_MAIN, "Removal"); - ASSERT_NE(removal, nullptr); - auto *deeper = new InnerDecklistNode("Deeper", removal); - new DecklistCardNode("Lightning Bolt", 2, deeper, -1); - model.rebuildTree(); - - QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Swords to Plowshares")), "Removal"); - ASSERT_TRUE(added.isValid()); - ASSERT_TRUE(added.parent().data(DeckRoles::IsCustomZoneRole).toBool()); -} - -int main(int argc, char **argv) -{ - ::testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); -} diff --git a/tests/deck_list_zones/CMakeLists.txt b/tests/deck_list_zones/CMakeLists.txt deleted file mode 100644 index 0710be94d..000000000 --- a/tests/deck_list_zones/CMakeLists.txt +++ /dev/null @@ -1,10 +0,0 @@ -add_executable(deck_list_zones_test deck_list_zones_test.cpp) - -if(NOT GTEST_FOUND) - add_dependencies(deck_list_zones_test gtest) -endif() - -target_link_libraries( - deck_list_zones_test libcockatrice_deck_list Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} -) -add_test(NAME deck_list_zones_test COMMAND deck_list_zones_test) diff --git a/tests/deck_list_zones/deck_list_zones_test.cpp b/tests/deck_list_zones/deck_list_zones_test.cpp deleted file mode 100644 index 801f226a9..000000000 --- a/tests/deck_list_zones/deck_list_zones_test.cpp +++ /dev/null @@ -1,392 +0,0 @@ -/** - * @file deck_list_zones_test.cpp - * @brief Tests for custom deck zones (deck-unique zones nested under a board zone). - * - * Custom zones allow players to organize cards within a board (e.g. "Removal" under - * the mainboard) without changing the board semantics: cards in a custom zone under - * "main" are still mainboard cards for hashing, sideboard size, legality and export. - */ - -#include -#include -#include -#include -#include -#include - -namespace -{ - -/** - * @brief Collects (board zone name, card node) pairs via forEachCard. - */ -struct BoardCardPair -{ - QString boardZone; - QString cardName; - int amount; -}; - -QList collectBoardCardPairs(const DeckList &deck) -{ - QList result; - deck.forEachCard([&result](InnerDecklistNode *boardZone, DecklistCardNode *card) { - result.append({boardZone->getName(), card->getName(), card->getNumber()}); - }); - return result; -} - -bool hasPair(const QList &pairs, const QString &boardZone, const QString &cardName) -{ - for (const auto &pair : pairs) { - if (pair.boardZone == boardZone && pair.cardName == cardName) { - return true; - } - } - return false; -} - -int totalCards(const QList &pairs) -{ - int total = 0; - for (const auto &pair : pairs) { - total += pair.amount; - } - return total; -} - -} // namespace - -// ===================================================================================================================== -// Zone creation -// ===================================================================================================================== - -TEST(DeckListZones, AddCustomZoneNestsUnderBoard) -{ - DeckList deck; - auto *tree = deck.getTree(); - - auto *zone = tree->addCustomZone(DECK_ZONE_MAIN, "Removal"); - ASSERT_NE(zone, nullptr); - EXPECT_EQ(zone->getName(), QString("Removal")); - ASSERT_NE(zone->getParent(), nullptr); - EXPECT_EQ(zone->getParent()->getName(), QString(DECK_ZONE_MAIN)); - - // The custom zone is nested, not a new top-level zone. - auto topLevelZones = tree->getZoneNodes(); - QStringList topLevelNames; - for (auto *node : topLevelZones) { - topLevelNames.append(node->getName()); - } - EXPECT_FALSE(topLevelNames.contains("Removal")); - - // It is discoverable through the board zone. - auto customZones = tree->getCustomZones(DECK_ZONE_MAIN); - ASSERT_EQ(customZones.size(), 1); - EXPECT_EQ(customZones.first()->getName(), QString("Removal")); -} - -TEST(DeckListZones, CustomZoneNamesAreDeckUnique) -{ - DeckList deck; - auto *tree = deck.getTree(); - - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); - // Same name on a different board is rejected. - EXPECT_EQ(tree->addCustomZone(DECK_ZONE_SIDE, "Removal"), nullptr); - // A name that collides with a built-in board zone is rejected. - EXPECT_EQ(tree->addCustomZone(DECK_ZONE_MAIN, DECK_ZONE_MAIN), nullptr); - EXPECT_EQ(tree->addCustomZone(DECK_ZONE_MAIN, DECK_ZONE_SIDE), nullptr); - EXPECT_EQ(tree->addCustomZone(DECK_ZONE_MAIN, DECK_ZONE_MAYBEBOARD), nullptr); - EXPECT_EQ(tree->addCustomZone(DECK_ZONE_MAIN, DECK_ZONE_TOKENS), nullptr); -} - -TEST(DeckListZones, AddCustomZoneUnknownBoardFails) -{ - DeckList deck; - auto *tree = deck.getTree(); - - EXPECT_EQ(tree->addCustomZone("not_a_board", "Removal"), nullptr); -} - -TEST(DeckListZones, MaybeboardIsLazilyCreated) -{ - DeckList deck; - auto *tree = deck.getTree(); - - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAYBEBOARD, "Candidates"), nullptr); - - // The maybeboard board zone now exists, with the custom zone nested inside. - auto customZones = tree->getCustomZones(DECK_ZONE_MAYBEBOARD); - ASSERT_EQ(customZones.size(), 1); - EXPECT_EQ(customZones.first()->getName(), QString("Candidates")); -} - -// ===================================================================================================================== -// Card placement -// ===================================================================================================================== - -TEST(DeckListZones, AddCardToCustomZoneKeepsBoardSemantics) -{ - DeckList deck; - auto *tree = deck.getTree(); - - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); - tree->addCard("Lightning Bolt", 4, "Removal", -1); - - // The card is reported as a mainboard card. - auto pairs = collectBoardCardPairs(deck); - EXPECT_TRUE(hasPair(pairs, DECK_ZONE_MAIN, "Lightning Bolt")); - EXPECT_FALSE(hasPair(pairs, DECK_ZONE_SIDE, "Lightning Bolt")); - - // It is physically nested inside the custom zone. - auto customZones = tree->getCustomZones(DECK_ZONE_MAIN); - ASSERT_EQ(customZones.size(), 1); - ASSERT_EQ(customZones.first()->size(), 1); - auto *card = dynamic_cast(customZones.first()->at(0)); - ASSERT_NE(card, nullptr); - EXPECT_EQ(card->getName(), QString("Lightning Bolt")); - EXPECT_EQ(card->getNumber(), 4); - - // Zone-scoped queries include it. - EXPECT_TRUE(deck.getCardList({DECK_ZONE_MAIN}).contains("Lightning Bolt")); - EXPECT_FALSE(deck.getCardList({DECK_ZONE_SIDE}).contains("Lightning Bolt")); - EXPECT_EQ(deck.getCardNodes({DECK_ZONE_MAIN}).size(), 1); -} - -TEST(DeckListZones, LegacyTopLevelZoneStillWorks) -{ - DeckList deck; - auto *tree = deck.getTree(); - - // Unknown zone names create a legacy top-level zone (backwards compatibility). - tree->addCard("Legacy Card", 2, "custom_legacy_zone", -1); - - auto pairs = collectBoardCardPairs(deck); - EXPECT_TRUE(hasPair(pairs, "custom_legacy_zone", "Legacy Card")); - EXPECT_EQ(deck.getCardList({}).count("Legacy Card"), 1); -} - -// ===================================================================================================================== -// Zone management -// ===================================================================================================================== - -TEST(DeckListZones, RenameCustomZone) -{ - DeckList deck; - auto *tree = deck.getTree(); - - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); - tree->addCard("Lightning Bolt", 2, "Removal", -1); - - EXPECT_TRUE(tree->renameCustomZone("Removal", "Bolt Zone")); - EXPECT_TRUE(hasPair(collectBoardCardPairs(deck), DECK_ZONE_MAIN, "Lightning Bolt")); - EXPECT_EQ(tree->getCustomZones(DECK_ZONE_MAIN).size(), 1); - EXPECT_EQ(tree->getCustomZones(DECK_ZONE_MAIN).first()->getName(), QString("Bolt Zone")); - - // Renaming to a taken name fails. - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Other"), nullptr); - EXPECT_FALSE(tree->renameCustomZone("Bolt Zone", "Other")); - // Renaming a nonexistent zone fails. - EXPECT_FALSE(tree->renameCustomZone("Ghost Zone", "Whatever")); -} - -TEST(DeckListZones, MoveCustomZoneMovesCards) -{ - DeckList deck; - auto *tree = deck.getTree(); - - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); - tree->addCard("Lightning Bolt", 2, "Removal", -1); - - EXPECT_TRUE(tree->moveCustomZone("Removal", DECK_ZONE_SIDE)); - - auto pairs = collectBoardCardPairs(deck); - EXPECT_TRUE(hasPair(pairs, DECK_ZONE_SIDE, "Lightning Bolt")); - EXPECT_FALSE(hasPair(pairs, DECK_ZONE_MAIN, "Lightning Bolt")); - - // The custom zone is now nested under side. - EXPECT_EQ(tree->getCustomZones(DECK_ZONE_MAIN).size(), 0); - EXPECT_EQ(tree->getCustomZones(DECK_ZONE_SIDE).size(), 1); - - // Moving to an unknown board fails. - EXPECT_FALSE(tree->moveCustomZone("Removal", "not_a_board")); -} - -TEST(DeckListZones, MoveCustomZoneFailsForUnknownBoard) -{ - DeckList deck; - auto *tree = deck.getTree(); - - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); - tree->addCard("Lightning Bolt", 2, "Removal", -1); - - EXPECT_FALSE(tree->moveCustomZone("Removal", "not_a_board")); - - // The zone is still under main. - EXPECT_EQ(tree->getCustomZones(DECK_ZONE_MAIN).size(), 1); -} - -// Regression: findCustomZoneByName walks every top-level zone, so a custom zone -// an imported deck carries under a non-standard board (tokens) is still found and -// movable. The pre-fix manager-level moveCustomZone only scanned the standard -// boards and returned false for these with no feedback. -TEST(DeckListZones, MoveCustomZoneNestedUnderTokensBoard) -{ - DeckList deck; - auto *tree = deck.getTree(); - auto *root = tree->getRoot(); - - auto *tokens = new InnerDecklistNode(DECK_ZONE_TOKENS, root); - auto *removal = new InnerDecklistNode("Removal", tokens); - new DecklistCardNode("Lightning Bolt", 2, removal, -1); - - EXPECT_TRUE(tree->findCustomZoneByName("Removal")); - EXPECT_TRUE(tree->moveCustomZone("Removal", DECK_ZONE_SIDE)); - - auto pairs = collectBoardCardPairs(deck); - EXPECT_FALSE(hasPair(pairs, DECK_ZONE_TOKENS, "Lightning Bolt")); - EXPECT_TRUE(hasPair(pairs, DECK_ZONE_SIDE, "Lightning Bolt")); -} - -TEST(DeckListZones, RemoveCustomZoneRemovesCards) -{ - DeckList deck; - auto *tree = deck.getTree(); - - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); - tree->addCard("Lightning Bolt", 2, "Removal", -1); - - EXPECT_TRUE(tree->removeCustomZone("Removal")); - EXPECT_EQ(tree->getCustomZones(DECK_ZONE_MAIN).size(), 0); - EXPECT_TRUE(deck.getCardList({DECK_ZONE_MAIN}).isEmpty()); - EXPECT_FALSE(tree->removeCustomZone("Removal")); -} - -TEST(DeckListZones, EmptyCustomZoneIsKeptOnCardDeletion) -{ - DeckList deck; - auto *tree = deck.getTree(); - - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); - auto *card = tree->addCard("Lightning Bolt", 1, "Removal", -1); - - // Deleting the last card must not delete the empty custom zone. - EXPECT_TRUE(tree->deleteNode(card)); - EXPECT_EQ(tree->getCustomZones(DECK_ZONE_MAIN).size(), 1); -} - -// ===================================================================================================================== -// Deck-wide behavior -// ===================================================================================================================== - -TEST(DeckListZones, HashCountsCustomZoneCardsByBoard) -{ - // Deck A: cards directly in main and side. - DeckList direct; - direct.addCard("Mountain", DECK_ZONE_MAIN); - direct.addCard("Lightning Bolt", DECK_ZONE_MAIN); - direct.addCard("Island", DECK_ZONE_SIDE); - - // Deck B: identical, but organized in custom zones. - DeckList organized; - auto *tree = organized.getTree(); - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Lands"), nullptr); - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); - ASSERT_NE(tree->addCustomZone(DECK_ZONE_SIDE, "Side Tech"), nullptr); - tree->addCard("Mountain", 1, "Lands", -1); - tree->addCard("Lightning Bolt", 1, "Removal", -1); - tree->addCard("Island", 1, "Side Tech", -1); - - EXPECT_EQ(direct.getDeckHash(), organized.getDeckHash()); -} - -TEST(DeckListZones, SideboardSizeCountsCustomZoneCards) -{ - DeckList deck; - auto *tree = deck.getTree(); - - ASSERT_NE(tree->addCustomZone(DECK_ZONE_SIDE, "Side Tech"), nullptr); - tree->addCard("Island", 3, "Side Tech", -1); - tree->addCard("Forest", 2, DECK_ZONE_SIDE, -1); - - EXPECT_EQ(deck.getSideboardSize(), 5); -} - -TEST(DeckListZones, PlainExportIncludesMainAndSideCustomZones) -{ - DeckList deck; - auto *tree = deck.getTree(); - - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); - ASSERT_NE(tree->addCustomZone(DECK_ZONE_SIDE, "Side Tech"), nullptr); - tree->addCard("Lightning Bolt", 2, "Removal", -1); - tree->addCard("Island", 1, "Side Tech", -1); - - const QString plain = deck.writeToString_Plain(false, false); - EXPECT_TRUE(plain.contains("2 Lightning Bolt")); - EXPECT_TRUE(plain.contains("1 Island")); -} - -TEST(DeckListZones, PlainExportSkipsMaybeboardCustomZones) -{ - DeckList deck; - auto *tree = deck.getTree(); - - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAYBEBOARD, "Candidates"), nullptr); - tree->addCard("Wish Card", 4, "Candidates", -1); - tree->addCard("Mountain", 1, DECK_ZONE_MAIN, -1); - - const QString plain = deck.writeToString_Plain(false, false); - EXPECT_FALSE(plain.contains("Wish Card")); - EXPECT_TRUE(plain.contains("1 Mountain")); -} - -TEST(DeckListZones, NativeRoundTripPreservesCustomZones) -{ - DeckList deck; - auto *tree = deck.getTree(); - - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); - ASSERT_NE(tree->addCustomZone(DECK_ZONE_SIDE, "Side Tech"), nullptr); - tree->addCard("Lightning Bolt", 2, "Removal", -1); - tree->addCard("Island", 3, "Side Tech", -1); - tree->addCard("Mountain", 1, DECK_ZONE_MAIN, -1); - - // Round-trip through the native format. - DeckList restored(deck.writeToString_Native()); - auto *restoredTree = restored.getTree(); - - EXPECT_EQ(restored.getDeckHash(), deck.getDeckHash()); - EXPECT_EQ(restoredTree->getCustomZones(DECK_ZONE_MAIN).size(), 1); - EXPECT_EQ(restoredTree->getCustomZones(DECK_ZONE_MAIN).first()->getName(), QString("Removal")); - EXPECT_EQ(restoredTree->getCustomZones(DECK_ZONE_SIDE).size(), 1); - - auto pairs = collectBoardCardPairs(restored); - EXPECT_TRUE(hasPair(pairs, DECK_ZONE_MAIN, "Lightning Bolt")); - EXPECT_TRUE(hasPair(pairs, DECK_ZONE_SIDE, "Island")); - EXPECT_TRUE(hasPair(pairs, DECK_ZONE_MAIN, "Mountain")); - EXPECT_EQ(totalCards(pairs), 6); -} - -TEST(DeckListZones, MaybeboardCustomZoneCardsAreExcludedFromHash) -{ - // Maybeboard cards are editor-only and must never affect the deck hash. - DeckList deck; - auto *tree = deck.getTree(); - - ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAYBEBOARD, "Candidates"), nullptr); - tree->addCard("Wish Card", 4, "Candidates", -1); - tree->addCard("Mountain", 1, DECK_ZONE_MAIN, -1); - - DeckList expected; - expected.addCard("Mountain", DECK_ZONE_MAIN); - - EXPECT_EQ(deck.getDeckHash(), expected.getDeckHash()); -} - -int main(int argc, char **argv) -{ - ::testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); -} diff --git a/tests/oracle/CMakeLists.txt b/tests/oracle/CMakeLists.txt index 9bc5ee5be..c5c1e9097 100644 --- a/tests/oracle/CMakeLists.txt +++ b/tests/oracle/CMakeLists.txt @@ -7,63 +7,3 @@ endif() target_link_libraries(parse_cipt_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}) add_test(NAME parse_cipt_test COMMAND parse_cipt_test) - -# Oracle importer unit tests -add_executable( - oracle_importer_test ${VERSION_STRING_CPP} ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp - ../../oracle/src/raw_json_scanner.cpp oracle_importer_test.cpp -) - -if(NOT GTEST_FOUND) - add_dependencies(oracle_importer_test gtest) -endif() - -target_link_libraries( - oracle_importer_test libcockatrice_card libcockatrice_interfaces Threads::Threads ${GTEST_BOTH_LIBRARIES} - ${TEST_QT_MODULES} -) - -add_test(NAME oracle_importer_test COMMAND oracle_importer_test) - -# Oracle importer benchmark tests (manual, not run in CI, incl. RAM benchmark) -# Optional compression libs, mirrored from oracle/CMakeLists.txt, so the benchmark -# can download and decompress whatever AllPrintings format the default URL selects. -find_package(ZLIB) -if(ZLIB_FOUND) - add_definitions("-DHAS_ZLIB") - set(_ORACLE_BENCH_EXTRA_SOURCES ../../oracle/src/zip/unzip.cpp ../../oracle/src/zip/zipglobal.cpp) - set(_ORACLE_BENCH_EXTRA_LIBRARIES ${ZLIB_LIBRARIES}) - include_directories(${ZLIB_INCLUDE_DIRS}) -else() - message(STATUS "Oracle tests: zlib not found; zip download benchmark disabled") -endif() - -find_package(LibLZMA) -if(LIBLZMA_FOUND) - add_definitions("-DHAS_LZMA") - list(APPEND _ORACLE_BENCH_EXTRA_SOURCES ../../oracle/src/lzma/decompress.cpp) - list(APPEND _ORACLE_BENCH_EXTRA_LIBRARIES ${LIBLZMA_LIBRARIES}) - include_directories(${LIBLZMA_INCLUDE_DIRS}) -else() - message(STATUS "Oracle tests: LibLZMA not found; xz download benchmark disabled") -endif() - -add_executable( - oracle_importer_benchmark_test - ${VERSION_STRING_CPP} ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp - ../../oracle/src/raw_json_scanner.cpp oracle_importer_benchmark_test.cpp ${_ORACLE_BENCH_EXTRA_SOURCES} -) - -if(NOT GTEST_FOUND) - add_dependencies(oracle_importer_benchmark_test gtest) -endif() - -target_link_libraries( - oracle_importer_benchmark_test - libcockatrice_card - libcockatrice_interfaces - Threads::Threads - ${GTEST_BOTH_LIBRARIES} - ${TEST_QT_MODULES} - ${_ORACLE_BENCH_EXTRA_LIBRARIES} -) diff --git a/tests/oracle/oracle_importer_benchmark_test.cpp b/tests/oracle/oracle_importer_benchmark_test.cpp deleted file mode 100644 index 633c9e40a..000000000 --- a/tests/oracle/oracle_importer_benchmark_test.cpp +++ /dev/null @@ -1,578 +0,0 @@ -#include "../../oracle/src/oracleimporter.h" - -#include "gtest/gtest.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#if defined(HAS_LZMA) -#include "../../oracle/src/lzma/decompress.h" -#endif -#if defined(HAS_ZLIB) -#include "../../oracle/src/zip/unzip.h" -#endif -#if defined(Q_OS_MACOS) -#include -#include -#endif - -// Helper: build a synthetic MTGJSON-style JSON with the given number of sets and cards per set -static QByteArray buildSyntheticData(int numSets, int cardsPerSet) -{ - QJsonObject dataObj; - for (int s = 0; s < numSets; ++s) { - QJsonArray cardsArray; - for (int c = 0; c < cardsPerSet; ++c) { - QJsonObject card; - card["name"] = QString("Card %1").arg(s * cardsPerSet + c); - card["text"] = "This is a test card with some rules text."; - card["layout"] = "normal"; - card["manaCost"] = "{W}"; - card["type"] = "Creature — Human"; - card["power"] = "2"; - card["toughness"] = "2"; - card["colors"] = QJsonArray{"W"}; - card["colorIdentity"] = QJsonArray{"W"}; - card["types"] = QJsonArray{"Creature"}; - // Real MTGJSON types: floats and booleans, not strings. This - // exercises the QVariant coercion in the property reader. - card["convertedManaCost"] = 1.0; - card["manaValue"] = 1.0; - card["isOnlineOnly"] = false; - card["isRebalanced"] = false; - - QJsonObject legalities; - legalities["standard"] = "legal"; - legalities["modern"] = "legal"; - legalities["legacy"] = "legal"; - legalities["vintage"] = "legal"; - legalities["commander"] = "legal"; - card["legalities"] = legalities; - - QJsonObject identifiers; - identifiers["scryfallId"] = QString("id-%1-%2").arg(s).arg(c); - card["identifiers"] = identifiers; - - // In AllPrintings, number and rarity are flat fields on the card - // object, exactly as set below. - card["number"] = QString::number(c + 1); - card["rarity"] = "common"; - - cardsArray.append(card); - } - - QJsonObject setObj; - setObj["code"] = QString("T%1").arg(s, 2, 10, QChar('0')); - setObj["name"] = QString("Test Set %1").arg(s); - setObj["type"] = "expansion"; - setObj["releaseDate"] = "2024-01-01"; - setObj["cards"] = cardsArray; - - dataObj[QString("T%1").arg(s, 2, 10, QChar('0'))] = setObj; - } - - QJsonObject root; - root["data"] = dataObj; - return QJsonDocument(root).toJson(QJsonDocument::Compact); -} - -// ============================================================================ -// Import throughput benchmark -// ============================================================================ - -TEST(OracleBenchmark, ImportThroughput) -{ - static constexpr int numSets = 10; - static constexpr int cardsPerSet = 500; - - QByteArray data = buildSyntheticData(numSets, cardsPerSet); - - OracleImporter importer; - - // Phase 1: Parse JSON - QElapsedTimer timer; - timer.start(); - bool ok = importer.readSetsFromByteArray(data); - ASSERT_TRUE(ok); - qint64 parseMs = timer.elapsed(); - - // Phase 2: Import cards - timer.restart(); - int importedSets = importer.startImport(); - qint64 importMs = timer.elapsed(); - - int totalImported = 0; - for (const auto &card : importer.getCardList()) { - Q_UNUSED(card); - totalImported++; - } - - // The fixture generates globally unique card names, so the expected - // counts are exact: a regression here means cards were dropped. - ASSERT_EQ(importedSets, numSets); - ASSERT_EQ(totalImported, numSets * cardsPerSet); - // Real-data probe: numeric convertedManaCost must be coerced to text - // (regression for the QJsonValue::toString() reader in #7214). - auto probeCard = importer.getCardList().value("Card 0"); - ASSERT_FALSE(probeCard.isNull()); - ASSERT_EQ(probeCard->getProperty("cmc"), "1"); - - qDebug().noquote() - << QString("Oracle Import Benchmark: %1 sets, %2 unique cards").arg(importedSets).arg(totalImported); - qDebug().noquote() << QString(" JSON parse: %1 ms").arg(parseMs); - qDebug().noquote() << QString(" Card import: %1 ms").arg(importMs); - qDebug().noquote() << QString(" Total: %1 ms").arg(parseMs + importMs); - if (importMs > 0) { - qDebug().noquote() << QString(" Throughput: %1 cards/sec") - .arg(static_cast(totalImported) / importMs * 1000.0, 0, 'f', 0); - } -} - -// ============================================================================ -// readSetsFromByteArray benchmark -// ============================================================================ - -TEST(OracleBenchmark, ParseJsonThroughput) -{ - static constexpr int numSets = 20; - static constexpr int cardsPerSet = 1000; - - QByteArray data = buildSyntheticData(numSets, cardsPerSet); - - // Run 5 iterations and report average - static constexpr int iterations = 5; - qint64 totalMs = 0; - - for (int i = 0; i < iterations; ++i) { - OracleImporter importer; - QByteArray source = data; - QElapsedTimer timer; - timer.start(); - bool ok = importer.readSetsFromByteArray(std::move(source)); - ASSERT_TRUE(ok); - totalMs += timer.elapsed(); - } - - qint64 avgMs = totalMs / iterations; - qDebug().noquote() << QString("Parse Benchmark (%1 iterations): avg %2 ms for %3 sets x %4 cards") - .arg(iterations) - .arg(avgMs) - .arg(numSets) - .arg(cardsPerSet); -} - -// ============================================================================ -// Split card merging benchmark -// ============================================================================ - -TEST(OracleBenchmark, SplitCardMerging) -{ - static constexpr int numSplitCards = 1000; - - QJsonArray cardsList; - for (int i = 0; i < numSplitCards; ++i) { - QJsonObject face1; - face1["name"] = QString("Fire %1 // Ice %1").arg(i); - face1["text"] = "Fire side text."; - face1["layout"] = "split"; - face1["side"] = "a"; - face1["faceName"] = QString("Fire %1").arg(i); - face1["colors"] = QJsonArray{"R"}; - face1["colorIdentity"] = QJsonArray{"R"}; - face1["types"] = QJsonArray{"Instant"}; - face1["manaCost"] = "{R}"; - face1["legalities"] = QJsonObject{{"standard", "not_legal"}}; - face1["identifiers"] = QJsonObject{{"scryfallId", QString("f-%1").arg(i)}}; - face1["number"] = QString::number(i + 1); - face1["rarity"] = "uncommon"; - - QJsonObject face2; - face2["name"] = QString("Fire %1 // Ice %1").arg(i); - face2["text"] = "Ice side text."; - face2["layout"] = "split"; - face2["side"] = "b"; - face2["faceName"] = QString("Ice %1").arg(i); - face2["colors"] = QJsonArray{"U"}; - face2["colorIdentity"] = QJsonArray{"U"}; - face2["types"] = QJsonArray{"Instant"}; - face2["manaCost"] = "{U}"; - face2["legalities"] = QJsonObject{{"standard", "not_legal"}}; - face2["identifiers"] = QJsonObject{{"scryfallId", QString("i-%1").arg(i)}}; - face2["number"] = QString::number(i + 1); - face2["rarity"] = "uncommon"; - - cardsList.append(face1); - cardsList.append(face2); - } - - NoopCardSetPriorityController controller; - OracleImporter importer; - CardSetPtr set = CardSet::newInstance(&controller, "TST", "Split Test"); - - QElapsedTimer timer; - timer.start(); - int count = importer.importCardsFromSet(set, cardsList); - qint64 ms = timer.elapsed(); - - ASSERT_EQ(count, numSplitCards); - qDebug().noquote() << QString("Split Card Merge Benchmark: %1 cards in %2 ms (%3 cards/sec)") - .arg(count) - .arg(ms) - .arg(ms > 0 ? static_cast(count) / ms * 1000.0 : 0.0, 0, 'f', 0); -} - -// ============================================================================ -// sortAndReduceColors microbenchmark -// ============================================================================ - -// We can't call sortAndReduceColors directly (it's static), so we benchmark -// through importCardsFromSet with color properties. - -TEST(OracleBenchmark, ImportCardsWithColors) -{ - static constexpr int numCards = 10000; - - NoopCardSetPriorityController controller; - OracleImporter importer; - CardSetPtr set = CardSet::newInstance(&controller, "TST", "Color Test"); - - QJsonArray cardsList; - for (int i = 0; i < numCards; ++i) { - QJsonObject card; - card["name"] = QString("Color Card %1").arg(i); - card["text"] = "Rules text."; - card["layout"] = "normal"; - card["manaCost"] = "{W}"; - card["type"] = "Creature — Human"; - card["types"] = QJsonArray{"Creature"}; - card["colors"] = QJsonArray{"B", "R", "G", "W", "U"}; - card["colorIdentity"] = QJsonArray{"B", "R", "G", "W", "U"}; - card["number"] = QString::number(i + 1); - card["rarity"] = "common"; - card["legalities"] = QJsonObject{{"standard", "legal"}}; - card["identifiers"] = QJsonObject{{"scryfallId", QString("c-%1").arg(i)}}; - cardsList.append(card); - } - - QElapsedTimer timer; - timer.start(); - int count = importer.importCardsFromSet(set, cardsList); - qint64 ms = timer.elapsed(); - - ASSERT_EQ(count, numCards); - qDebug().noquote() << QString("Import with Colors Benchmark: %1 cards in %2 ms (%3 cards/sec)") - .arg(count) - .arg(ms) - .arg(ms > 0 ? static_cast(count) / ms * 1000.0 : 0.0, 0, 'f', 0); -} - -// ============================================================================ -// RAM usage measurement -// ============================================================================ - -// Mirrors the default AllPrintings URL selection in oracle/src/pages.cpp. -#if defined(HAS_LZMA) -static const QUrl kDefaultAllPrintingsUrl("https://www.mtgjson.com/api/v5/AllPrintings.json.xz"); -#elif defined(HAS_ZLIB) -static const QUrl kDefaultAllPrintingsUrl("https://www.mtgjson.com/api/v5/AllPrintings.json.zip"); -#else -static const QUrl kDefaultAllPrintingsUrl("https://www.mtgjson.com/api/v5/AllPrintings.json"); -#endif - -// Magic bytes also from oracle/src/pages.cpp -static const QByteArray kXzSignature("\xFD\x37\x7A\x58\x5A", 6); -static const QByteArray kZipSignature("PK"); - -struct MemorySnapshot -{ - qint64 peakRssKb = -1; // process high-water mark (VmHWM on Linux, ru_maxrss on macOS) - qint64 rssKb = -1; // current resident set size - bool available = false; - - static MemorySnapshot current() - { - MemorySnapshot snap; -#if defined(Q_OS_LINUX) - QFile statusFile("/proc/self/status"); - if (statusFile.open(QIODevice::ReadOnly | QIODevice::Text)) { - // /proc files report size() == 0, so atEnd() is immediately true: read everything first. - const QList lines = statusFile.readAll().split('\n'); - for (const QByteArray &line : lines) { - if (line.startsWith("VmHWM:")) { - snap.peakRssKb = line.mid(6).trimmed().split(' ').value(0).toLongLong(); - } else if (line.startsWith("VmRSS:")) { - snap.rssKb = line.mid(6).trimmed().split(' ').value(0).toLongLong(); - } - } - snap.available = snap.peakRssKb >= 0; - } -#elif defined(Q_OS_MACOS) - struct rusage usage; - if (getrusage(RUSAGE_SELF, &usage) == 0) { - snap.peakRssKb = usage.ru_maxrss / 1024; // bytes -> kB - snap.available = snap.peakRssKb >= 0; - } - // getrusage has no current-RSS equivalent; task_info's resident_size - // is the closest macOS analog to Linux VmRSS. - mach_task_basic_info info = {}; - mach_msg_type_number_t count = MACH_TASK_BASIC_INFO_COUNT; - if (task_info(mach_task_self(), MACH_TASK_BASIC_INFO, reinterpret_cast(&info), &count) == - KERN_SUCCESS) { - snap.rssKb = info.resident_size / 1024; - } -#endif - return snap; - } -}; - -static QString formatKb(qint64 kb) -{ - if (kb < 0) { - return "N/A"; - } - return QString("%1 MB").arg(kb / 1024.0, 0, 'f', 1); -} - -static void logRamPhase(const QString &phase, const MemorySnapshot &baseline, const MemorySnapshot ¤t) -{ - if (!baseline.available || !current.available) { - qDebug().noquote() << QString(" %1: memory stats unavailable on this platform").arg(phase); - return; - } - // VmHWM / ru_maxrss are monotonically non-decreasing high-water marks, so a - // peak-based delta between phases is ~0.0 MB by construction once the - // fixture build has set the process peak. The live signals are current RSS - // and the process peak; the delta is meaningful only where the baseline was - // taken immediately before the phase it measures (e.g. the import phase, - // which compares afterParse against afterImport). - QString rssDelta = "N/A"; - if (current.rssKb >= 0 && baseline.rssKb >= 0) { - rssDelta = formatKb(current.rssKb - baseline.rssKb); - } - qDebug().noquote() << QString(" %1: current RSS %2 | delta vs baseline %3 | process peak %4") - .arg(phase) - .arg(formatKb(current.rssKb)) - .arg(rssDelta) - .arg(formatKb(current.peakRssKb)); -} - -// Decompresses the download payload when the default URL is a compressed build, -// mirroring the wizard's magic-byte handling in oracle/src/pages.cpp. -static QByteArray decompressSetsData(const QByteArray &payload) -{ - if (payload.startsWith(kXzSignature)) { -#if defined(HAS_LZMA) - QBuffer inBuffer(const_cast(&payload)); - QByteArray out; - QBuffer outBuffer(&out); - inBuffer.open(QIODevice::ReadOnly); - outBuffer.open(QIODevice::WriteOnly); - XzDecompressor xz; - if (!xz.decompress(&inBuffer, &outBuffer)) { - qDebug() << "RAM benchmark: xz decompression failed"; - return {}; - } - return out; -#else - qDebug() << "RAM benchmark: download is xz-compressed but this build has no LZMA support"; - return {}; -#endif - } - if (payload.startsWith(kZipSignature)) { -#if defined(HAS_ZLIB) - QBuffer inBuffer(const_cast(&payload)); - inBuffer.open(QIODevice::ReadOnly); - UnZip unzip; - if (unzip.openArchive(&inBuffer) != UnZip::Ok) { - qDebug() << "RAM benchmark: zip archive open failed"; - return {}; - } - if (unzip.fileList().size() != 1) { - qDebug() << "RAM benchmark: zip archive doesn't contain exactly one file"; - return {}; - } - QByteArray out; - QBuffer outBuffer(&out); - outBuffer.open(QIODevice::WriteOnly); - const auto errorCode = unzip.extractFile(unzip.fileList().value(0), &outBuffer); - unzip.closeArchive(); - if (errorCode != UnZip::Ok) { - qDebug() << "RAM benchmark: zip extraction failed"; - return {}; - } - return out; -#else - qDebug() << "RAM benchmark: download is zip-compressed but this build has no zlib support"; - return {}; -#endif - } - return payload; -} - -TEST(OracleBenchmark, ImportRamUsage) -{ - static constexpr int numSets = 30; - static constexpr int cardsPerSet = 2000; // ~60k cards, roughly AllPrintings scale - - // Baseline must precede the fixture build: a high-water mark set while - // generating the synthetic JSON would otherwise mask the importer phases. - // Where memory stats are unavailable (Windows), skip before doing the - // 60k-card fixture build, which would otherwise be pure wasted work. - const MemorySnapshot baseline = MemorySnapshot::current(); - if (!baseline.available) { - GTEST_SKIP() << "Memory stats unavailable on this platform"; - } - - const QByteArray data = buildSyntheticData(numSets, cardsPerSet); - - // The fixture build leaves freed-but-unreturned arenas behind (current RSS - // rarely falls once glibc allocates). Baseline immediately after it so the - // parse phase measures only the importer's own growth (~40 MB) rather than - // swallowing the fixture builder's spike. - const MemorySnapshot afterFixture = MemorySnapshot::current(); - logRamPhase("fixture build", baseline, afterFixture); - - NoopCardSetPriorityController controller; - OracleImporter importer; - - QElapsedTimer timer; - timer.start(); - ASSERT_TRUE(importer.readSetsFromByteArray(std::move(data))); - const qint64 parseMs = timer.elapsed(); - const MemorySnapshot afterParse = MemorySnapshot::current(); - - timer.restart(); - const int importedSets = importer.startImport(); - const qint64 importMs = timer.elapsed(); - const MemorySnapshot afterImport = MemorySnapshot::current(); - - importer.releaseSetData(); - const MemorySnapshot afterRelease = MemorySnapshot::current(); - - const int totalCards = importer.getCardList().size(); - qDebug().noquote() << QString("Oracle RAM Benchmark (synthetic): %1 sets, %2 cards, %3 MB JSON") - .arg(importedSets) - .arg(totalCards) - .arg(data.size() / (1024.0 * 1024.0), 0, 'f', 1); - qDebug().noquote() << QString(" JSON parse: %1 ms").arg(parseMs); - qDebug().noquote() << QString(" Card import: %1 ms").arg(importMs); - logRamPhase("parse", afterFixture, afterParse); - logRamPhase("import", afterParse, afterImport); - logRamPhase("after releaseSetData()", afterImport, afterRelease); - - // Freeing the parsed tree rarely moves current RSS (allocator reuse), so the - // meaningful signal that release actually dropped the buffers is emptiness, - // not an RSS delta. - ASSERT_TRUE(importer.getSets().isEmpty()); -} - -TEST(OracleBenchmark, ImportRamUsageAllPrintings) -{ - // Only "1" enables the download: unset (the default and the CI setup) and - // an explicit "0" both disable it. - bool envOk = false; - const int enabled = qEnvironmentVariableIntValue("COCKATRICE_ORACLE_RAM_BENCHMARK", &envOk); - if (!envOk || enabled == 0) { - GTEST_SKIP() << "Set COCKATRICE_ORACLE_RAM_BENCHMARK=1 to download the real AllPrintings dataset for this " - "RAM benchmark. Default URL: " - << kDefaultAllPrintingsUrl.toDisplayString().toStdString(); - } - - // Baseline must precede the request so the phase covers the download + - // decompress step, including the payload materialized by readAll(). - const MemorySnapshot baseline = MemorySnapshot::current(); - if (!baseline.available) { - GTEST_SKIP() << "Memory stats unavailable on this platform"; - } - - QNetworkAccessManager nam; - QNetworkRequest request(kDefaultAllPrintingsUrl); - request.setHeader(QNetworkRequest::UserAgentHeader, "Cockatrice Oracle RAM benchmark"); - QNetworkReply *reply = nam.get(request); - - QEventLoop loop; - QTimer timeoutTimer; - timeoutTimer.setSingleShot(true); - bool timedOut = false; - QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); - QObject::connect(&timeoutTimer, &QTimer::timeout, &loop, [&] { - timedOut = true; - reply->abort(); - }); - timeoutTimer.start(10 * 60 * 1000); - loop.exec(); - timeoutTimer.stop(); - - // abort() leaves reply->error() as OperationCanceledError, so a timed-out - // download takes the same GTEST_SKIP path as any other network error - // instead of reading a truncated body and failing the parse below. - if (timedOut || reply->error() != QNetworkReply::NoError) { - GTEST_SKIP() << "Download failed: " << reply->errorString().toStdString(); - } - const QByteArray payload = reply->readAll(); - reply->deleteLater(); - - // mtgjson can answer 200 with an HTML page (mirrors the wizard's '<' check - // in pages.cpp); reject it before trying to decompress/parse. - if (payload.startsWith("<")) { - GTEST_SKIP() << "Download returned a non-JSON body (HTML page instead of data), skipping"; - } - - const QByteArray setsData = decompressSetsData(payload); - const MemorySnapshot afterDownload = MemorySnapshot::current(); - if (setsData.isEmpty()) { - GTEST_SKIP() << "No data to import (download or decompression failed)"; - } - - NoopCardSetPriorityController controller; - OracleImporter importer; - - QElapsedTimer timer; - timer.start(); - ASSERT_TRUE(importer.readSetsFromByteArray(std::move(setsData))); - const qint64 parseMs = timer.elapsed(); - const MemorySnapshot afterParse = MemorySnapshot::current(); - - timer.restart(); - const int importedSets = importer.startImport(); - const qint64 importMs = timer.elapsed(); - const MemorySnapshot afterImport = MemorySnapshot::current(); - - importer.releaseSetData(); - const MemorySnapshot afterRelease = MemorySnapshot::current(); - - const int totalCards = importer.getCardList().size(); - qDebug().noquote() << QString("Oracle RAM Benchmark (real AllPrintings): %1 sets, %2 unique cards") - .arg(importedSets) - .arg(totalCards); - qDebug().noquote() << QString(" URL: %1").arg(kDefaultAllPrintingsUrl.toDisplayString()); - qDebug().noquote() << QString(" Downloaded: %1 MB, decompressed: %2 MB") - .arg(payload.size() / (1024.0 * 1024.0), 0, 'f', 1) - .arg(setsData.size() / (1024.0 * 1024.0), 0, 'f', 1); - qDebug().noquote() << QString(" JSON parse: %1 ms").arg(parseMs); - qDebug().noquote() << QString(" Card import: %1 ms").arg(importMs); - logRamPhase("download+decompress", baseline, afterDownload); - logRamPhase("parse", afterDownload, afterParse); - logRamPhase("import", afterParse, afterImport); - logRamPhase("after releaseSetData()", afterImport, afterRelease); -} - -int main(int argc, char **argv) -{ - // Required for the event loop used by the real-AllPrintings download benchmark - QCoreApplication app(argc, argv); - ::testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); -} diff --git a/tests/oracle/oracle_importer_test.cpp b/tests/oracle/oracle_importer_test.cpp deleted file mode 100644 index 3834be9ed..000000000 --- a/tests/oracle/oracle_importer_test.cpp +++ /dev/null @@ -1,748 +0,0 @@ -#include "../../oracle/src/oracleimporter.h" - -#include "gtest/gtest.h" -#include -#include -#include -#include -#include -#include -#include - -class OracleImporterTest : public ::testing::Test -{ -protected: - void SetUp() override - { - controller = new NoopCardSetPriorityController(); - importer = new OracleImporter(); - set = CardSet::newInstance(controller, "TST", "Test Set"); - } - - void TearDown() override - { - delete importer; - delete controller; - } - - // Helper: build a minimal card JSON object - QJsonObject makeCard(const QString &name, - const QString &colors = "", - const QString &colorIdentity = "", - const QVariantMap &legalities = {}) - { - QJsonObject card; - card["name"] = name; - card["text"] = "Rules text."; - card["layout"] = "normal"; - card["manaCost"] = "{W}"; - card["type"] = "Creature — Human"; - card["types"] = QJsonArray{"Creature"}; - card["number"] = "1"; - card["rarity"] = "common"; - - if (!colors.isEmpty()) { - QJsonArray arr; - for (const QChar &c : colors) { - arr.append(QString(c)); - } - card["colors"] = arr; - } - if (!colorIdentity.isEmpty()) { - QJsonArray arr; - for (const QChar &c : colorIdentity) { - arr.append(QString(c)); - } - card["colorIdentity"] = arr; - } - if (!legalities.isEmpty()) { - QJsonObject legalObj; - for (auto it = legalities.constBegin(); it != legalities.constEnd(); ++it) { - legalObj[it.key()] = it.value().toString(); - } - card["legalities"] = legalObj; - } - - QJsonObject identifiers; - identifiers["scryfallId"] = QUuid::createUuid().toString(QUuid::WithoutBraces); - card["identifiers"] = identifiers; - - return card; - } - - NoopCardSetPriorityController *controller; - OracleImporter *importer; - CardSetPtr set; -}; - -// ============================================================================ -// sortAndReduceColors tests (tested via importCardsFromSet) -// ============================================================================ - -TEST_F(OracleImporterTest, SortAndReduceColorsSingleColor) -{ - QJsonArray cards{makeCard("Red Card", "R", "R")}; - importer->importCardsFromSet(set, cards); - - auto card = importer->getCardList().value("Red Card"); - ASSERT_FALSE(card.isNull()); - ASSERT_EQ(card->getProperty("colors"), "R"); -} - -TEST_F(OracleImporterTest, SortAndReduceColorsDeduplicates) -{ - QJsonArray cards{makeCard("Dedup Card", "WWUUB", "WU")}; - importer->importCardsFromSet(set, cards); - - auto card = importer->getCardList().value("Dedup Card"); - ASSERT_FALSE(card.isNull()); - ASSERT_EQ(card->getProperty("colors"), "WUB"); -} - -TEST_F(OracleImporterTest, SortAndReduceColorsSortsWUBRG) -{ - QJsonArray cards{makeCard("Sort Card", "RGW", "RGW")}; - importer->importCardsFromSet(set, cards); - - auto card = importer->getCardList().value("Sort Card"); - ASSERT_FALSE(card.isNull()); - ASSERT_EQ(card->getProperty("colors"), "WRG"); -} - -TEST_F(OracleImporterTest, SortAndReduceColorsAllFive) -{ - QJsonArray cards{makeCard("Five Color", "BRGWU", "BRGWU")}; - importer->importCardsFromSet(set, cards); - - auto card = importer->getCardList().value("Five Color"); - ASSERT_FALSE(card.isNull()); - ASSERT_EQ(card->getProperty("colors"), "WUBRG"); -} - -TEST_F(OracleImporterTest, SortAndReduceColorIdentity) -{ - QJsonArray cards{makeCard("Color Id Card", "W", "GWR")}; - importer->importCardsFromSet(set, cards); - - auto card = importer->getCardList().value("Color Id Card"); - ASSERT_FALSE(card.isNull()); - ASSERT_EQ(card->getProperty("coloridentity"), "WRG"); -} - -TEST_F(OracleImporterTest, SingleColorNotSorted) -{ - QJsonArray cards{makeCard("Single Card", "B", "B")}; - importer->importCardsFromSet(set, cards); - - auto card = importer->getCardList().value("Single Card"); - ASSERT_FALSE(card.isNull()); - ASSERT_EQ(card->getProperty("colors"), "B"); -} - -// ============================================================================ -// Legality guard tests -// ============================================================================ - -TEST_F(OracleImporterTest, NewCardKeepsLegalityProperties) -{ - // Verifies that format-* properties survive addCard on a fresh card - // (not the combineLegalities guard, which only runs on existing printings). - QVariantMap leg; - leg["standard"] = "legal"; - leg["modern"] = "legal"; - QJsonArray cards{makeCard("Legal Card", "", "", leg)}; - - importer->importCardsFromSet(set, cards); - auto card = importer->getCardList().value("Legal Card"); - ASSERT_FALSE(card.isNull()); - ASSERT_EQ(card->getProperty("format-standard"), "legal"); - ASSERT_EQ(card->getProperty("format-modern"), "legal"); -} - -TEST_F(OracleImporterTest, LegalityMergeAllowedWhenCardHasNoLegalities) -{ - // First printing carries no legalities at all, so the guard's - // `properties.filter(formatRegex).empty()` predicate is true and the - // second printing's legalities must be merged in. - QJsonArray cards1{makeCard("Unmerged Card")}; - importer->importCardsFromSet(set, cards1); - - CardSetPtr set2 = CardSet::newInstance(controller, "TS2", "Second Set"); - QVariantMap leg; - leg["standard"] = "legal"; - QJsonArray cards2{makeCard("Unmerged Card", "", "", leg)}; - importer->importCardsFromSet(set2, cards2); - - auto card = importer->getCardList().value("Unmerged Card"); - ASSERT_FALSE(card.isNull()); - ASSERT_EQ(card->getProperty("format-standard"), "legal"); -} - -TEST_F(OracleImporterTest, LegalityGuardPreservesFirstPrinting) -{ - // First printing: standard=legal, modern=legal - QVariantMap leg1; - leg1["standard"] = "legal"; - leg1["modern"] = "legal"; - QJsonArray cards1{makeCard("Guarded Card", "", "", leg1)}; - importer->importCardsFromSet(set, cards1); - - // Second printing: standard=banned, modern=not_legal - CardSetPtr set2 = CardSet::newInstance(controller, "TS2", "Second Set"); - QVariantMap leg2; - leg2["standard"] = "banned"; - leg2["modern"] = "not_legal"; - QJsonArray cards2{makeCard("Guarded Card", "", "", leg2)}; - importer->importCardsFromSet(set2, cards2); - - auto card = importer->getCardList().value("Guarded Card"); - ASSERT_FALSE(card.isNull()); - // Guard should preserve first printing's legalities - ASSERT_EQ(card->getProperty("format-standard"), "legal"); - ASSERT_EQ(card->getProperty("format-modern"), "legal"); -} - -// ============================================================================ -// createDefaultMagicFormats tests -// ============================================================================ - -TEST_F(OracleImporterTest, CreateDefaultMagicFormatsContainsExpectedFormats) -{ - auto formats = importer->createDefaultMagicFormats(); - ASSERT_TRUE(formats.contains("standard")); - ASSERT_TRUE(formats.contains("modern")); - ASSERT_TRUE(formats.contains("legacy")); - ASSERT_TRUE(formats.contains("vintage")); - ASSERT_TRUE(formats.contains("commander")); - ASSERT_TRUE(formats.contains("pauper")); - ASSERT_TRUE(formats.contains("pioneer")); - ASSERT_TRUE(formats.contains("brawl")); - ASSERT_TRUE(formats.contains("historic")); - ASSERT_TRUE(formats.contains("timeless")); - ASSERT_TRUE(formats.contains("duel")); - ASSERT_TRUE(formats.contains("oathbreaker")); -} - -TEST_F(OracleImporterTest, CreateDefaultMagicFormatsSingletonDeckSizes) -{ - auto formats = importer->createDefaultMagicFormats(); - auto commander = formats.value("commander"); - ASSERT_FALSE(commander.isNull()); - ASSERT_EQ(commander->minDeckSize, 100); - ASSERT_EQ(commander->maxDeckSize, 100); - ASSERT_EQ(commander->maxSideboardSize, 15); - - auto brawl = formats.value("brawl"); - ASSERT_FALSE(brawl.isNull()); - ASSERT_EQ(brawl->minDeckSize, 60); - ASSERT_EQ(brawl->maxDeckSize, 60); -} - -TEST_F(OracleImporterTest, CreateDefaultMagicFormatsVintageHasRestricted) -{ - auto formats = importer->createDefaultMagicFormats(); - auto vintage = formats.value("vintage"); - ASSERT_FALSE(vintage.isNull()); - bool hasRestricted = false; - for (const auto &ac : vintage->allowedCounts) { - if (ac.label == "restricted") { - hasRestricted = true; - ASSERT_EQ(ac.max, 1); - } - } - ASSERT_TRUE(hasRestricted); -} - -TEST_F(OracleImporterTest, CreateDefaultMagicFormatsRegexMatchesBasicLands) -{ - auto formats = importer->createDefaultMagicFormats(); - auto standard = formats.value("standard"); - ASSERT_FALSE(standard.isNull()); - ASSERT_FALSE(standard->exceptions.isEmpty()); - - auto &basicLandsException = standard->exceptions.first(); - ASSERT_FALSE(basicLandsException.conditions.isEmpty()); - - auto &condition = basicLandsException.conditions.first(); - ASSERT_EQ(condition.field, "type"); - ASSERT_EQ(condition.matchType, "regex"); - - // Verify the regex actually works (was broken before: \b = backspace, not word boundary) - QRegularExpression regex(condition.value); - ASSERT_TRUE(regex.isValid()); - ASSERT_TRUE(regex.match("Basic Land — Forest").hasMatch()); - ASSERT_TRUE(regex.match("Basic Snow Land — Mountain").hasMatch()); - ASSERT_FALSE(regex.match("Creature — Elf Warrior").hasMatch()); -} - -TEST_F(OracleImporterTest, CreateDefaultMagicFormatsCaching) -{ - // The memoized map returns the same FormatRulesPtr instances, so the - // shared pointers must be identical across calls. This is the only - // observable effect of the cache: contents would match either way. - auto first = importer->createDefaultMagicFormats(); - auto second = importer->createDefaultMagicFormats(); - ASSERT_EQ(first.value("standard").data(), second.value("standard").data()); -} - -// ============================================================================ -// readSetsFromByteArray tests -// ============================================================================ - -TEST_F(OracleImporterTest, ReadSetsFromByteArrayValidJson) -{ - QJsonObject setObj; - setObj["code"] = "tst"; - setObj["name"] = "Test Set"; - setObj["type"] = "expansion"; - setObj["releaseDate"] = "2024-01-01"; - setObj["cards"] = QJsonArray(); - - QJsonObject root; - root["data"] = QJsonObject{{"TST", setObj}}; - - QByteArray data = QJsonDocument(root).toJson(); - ASSERT_TRUE(importer->readSetsFromByteArray(data)); - ASSERT_EQ(importer->getSets().size(), 1); - ASSERT_EQ(importer->getSets().first().getShortName(), "TST"); -} - -TEST_F(OracleImporterTest, ReadSetsFromByteArrayInvalidJson) -{ - QByteArray data = "not valid json"; - ASSERT_FALSE(importer->readSetsFromByteArray(data)); -} - -TEST_F(OracleImporterTest, ReadSetsFromByteArrayEmptyData) -{ - QJsonObject root; - root["data"] = QJsonObject(); - - QByteArray data = QJsonDocument(root).toJson(); - ASSERT_FALSE(importer->readSetsFromByteArray(data)); -} - -TEST_F(OracleImporterTest, ReadSetsFromByteArrayCapitalizesSetType) -{ - QJsonObject setObj; - setObj["code"] = "ftv"; - setObj["name"] = "From The Vault"; - setObj["type"] = "from_the_vault"; - setObj["releaseDate"] = "2024-01-01"; - setObj["cards"] = QJsonArray(); - - QJsonObject root; - root["data"] = QJsonObject{{"FTV", setObj}}; - - QByteArray data = QJsonDocument(root).toJson(); - ASSERT_TRUE(importer->readSetsFromByteArray(data)); - ASSERT_EQ(importer->getSets().first().getSetType(), "From the Vault"); -} - -TEST_F(OracleImporterTest, ReadSetsFromByteArraySortsSetsByName) -{ - // QJsonObject iterates keys in lexicographic order ("AAA" before "ZZZ"), - // so leaving the natural order matching the alphabetical sort makes the - // assertion pass trivially. Inverting it keeps the sort meaningful: - // iteration yields "AAA" (Zeta Set) first, then the sort by name must - // promote "ZZZ" (Alpha Set) to the front. - QJsonObject setA; - setA["code"] = "aaa"; - setA["name"] = "Zeta Set"; - setA["type"] = "expansion"; - setA["releaseDate"] = "2024-01-01"; - setA["cards"] = QJsonArray(); - - QJsonObject setB; - setB["code"] = "zzz"; - setB["name"] = "Alpha Set"; - setB["type"] = "expansion"; - setB["releaseDate"] = "2024-01-01"; - setB["cards"] = QJsonArray(); - - QJsonObject root; - root["data"] = QJsonObject{{"AAA", setA}, {"ZZZ", setB}}; - - QByteArray data = QJsonDocument(root).toJson(); - ASSERT_TRUE(importer->readSetsFromByteArray(data)); - auto sets = importer->getSets(); - ASSERT_GE(sets.size(), 2); - ASSERT_EQ(sets.first().getShortName(), "ZZZ"); -} - -// ============================================================================ -// Split card coloridentity tests -// ============================================================================ - -TEST_F(OracleImporterTest, SplitCardColorIdentityConcatenated) -{ - QJsonObject leg{{"standard", "not_legal"}}; - - QJsonObject face1; - face1["name"] = "Fire // Ice"; - face1["text"] = "Fire deals 2 damage."; - face1["layout"] = "split"; - face1["side"] = "a"; - face1["faceName"] = "Fire"; - face1["colors"] = QJsonArray{"R"}; - face1["colorIdentity"] = QJsonArray{"R"}; - face1["types"] = QJsonArray{"Instant"}; - face1["manaCost"] = "{R}"; - face1["legalities"] = leg; - face1["identifiers"] = QJsonObject{{"scryfallId", "aaa"}}; - face1["number"] = "1"; - face1["rarity"] = "uncommon"; - - QJsonObject face2; - face2["name"] = "Fire // Ice"; - face2["text"] = "Ice taps target artifact."; - face2["layout"] = "split"; - face2["side"] = "b"; - face2["faceName"] = "Ice"; - face2["colors"] = QJsonArray{"U"}; - face2["colorIdentity"] = QJsonArray{"U"}; - face2["types"] = QJsonArray{"Instant"}; - face2["manaCost"] = "{U}"; - face2["legalities"] = leg; - face2["identifiers"] = QJsonObject{{"scryfallId", "bbb"}}; - face2["number"] = "1"; - face2["rarity"] = "uncommon"; - - QJsonArray cardsList{face1, face2}; - int count = importer->importCardsFromSet(set, cardsList); - ASSERT_EQ(count, 1); - - auto card = importer->getCardList().value("Fire // Ice"); - ASSERT_FALSE(card.isNull()); - - // coloridentity should be "RU" (concatenated), then sorted to "UR" - // by sortAndReduceColors when it reaches addCard - ASSERT_EQ(card->getProperty("coloridentity"), "UR"); -} - -TEST_F(OracleImporterTest, SplitCardColorsConcatenated) -{ - QJsonObject leg{{"standard", "not_legal"}}; - - QJsonObject face1; - face1["name"] = "Fire // Ice"; - face1["text"] = "Fire deals 2 damage."; - face1["layout"] = "split"; - face1["side"] = "a"; - face1["faceName"] = "Fire"; - face1["colors"] = QJsonArray{"R"}; - face1["colorIdentity"] = QJsonArray{"R"}; - face1["types"] = QJsonArray{"Instant"}; - face1["manaCost"] = "{R}"; - face1["legalities"] = leg; - face1["identifiers"] = QJsonObject{{"scryfallId", "aaa"}}; - face1["number"] = "1"; - face1["rarity"] = "uncommon"; - - QJsonObject face2; - face2["name"] = "Fire // Ice"; - face2["text"] = "Ice taps target artifact."; - face2["layout"] = "split"; - face2["side"] = "b"; - face2["faceName"] = "Ice"; - face2["colors"] = QJsonArray{"U"}; - face2["colorIdentity"] = QJsonArray{"U"}; - face2["types"] = QJsonArray{"Instant"}; - face2["manaCost"] = "{U}"; - face2["legalities"] = leg; - face2["identifiers"] = QJsonObject{{"scryfallId", "bbb"}}; - face2["number"] = "1"; - face2["rarity"] = "uncommon"; - - QJsonArray cardsList{face1, face2}; - importer->importCardsFromSet(set, cardsList); - - auto card = importer->getCardList().value("Fire // Ice"); - ASSERT_FALSE(card.isNull()); - - QString colors = card->getProperty("colors"); - ASSERT_FALSE(colors.contains("//")) << "colors should not contain '//', got: " << colors.toStdString(); - ASSERT_TRUE(colors.contains("R")); - ASSERT_TRUE(colors.contains("U")); -} - -// ============================================================================ -// Mana cost formatting tests -// ============================================================================ - -TEST_F(OracleImporterTest, ManaCostStripsBraces) -{ - QJsonObject card = makeCard("Mana Card"); - card["manaCost"] = "{2}{W}{B}"; - QJsonArray cards{card}; - - importer->importCardsFromSet(set, cards); - auto result = importer->getCardList().value("Mana Card"); - ASSERT_FALSE(result.isNull()); - ASSERT_EQ(result->getProperty("manacost"), "2WB"); -} - -// cmc comes through as a JSON number ("convertedManaCost"/"manaValue" are -// floats in AllPrintings), so this pins the number-to-text coercion that -// QJsonValue::toString() dropped in #7214. -TEST_F(OracleImporterTest, NumericManaValueCoercedToCmc) -{ - QJsonObject card = makeCard("Cmc Card"); - card["manaValue"] = 3; - QJsonArray cards{card}; - - importer->importCardsFromSet(set, cards); - auto result = importer->getCardList().value("Cmc Card"); - ASSERT_FALSE(result.isNull()); - ASSERT_EQ(result->getProperty("cmc"), "3"); -} - -TEST_F(OracleImporterTest, LegacyConvertedManaCostCoercedToCmc) -{ - QJsonObject card = makeCard("Legacy Cmc Card"); - card["convertedManaCost"] = 3.0; - QJsonArray cards{card}; - - importer->importCardsFromSet(set, cards); - auto result = importer->getCardList().value("Legacy Cmc Card"); - ASSERT_FALSE(result.isNull()); - ASSERT_EQ(result->getProperty("cmc"), "3"); -} - -// ============================================================================ -// Card deduplication tests -// ============================================================================ - -TEST_F(OracleImporterTest, DuplicateCardNameReturnsExisting) -{ - QJsonArray cards{makeCard("Dupe Card")}; - importer->importCardsFromSet(set, cards); - - CardSetPtr set2 = CardSet::newInstance(controller, "TS2", "Second Set"); - QJsonArray cards2{makeCard("Dupe Card")}; - importer->importCardsFromSet(set2, cards2); - - ASSERT_EQ(importer->getCardList().size(), 1); -} - -TEST_F(OracleImporterTest, AELigatureReplaced) -{ - QJsonObject card = makeCard(QString::fromUtf8("\xC3\x86ther Vial")); // Æther Vial - QJsonArray cards{card}; - - importer->importCardsFromSet(set, cards); - // Æ is replaced with AE, resulting in "AEther Vial" - ASSERT_FALSE(importer->getCardList().contains(QString::fromUtf8("\xC3\x86ther Vial"))); - ASSERT_TRUE(importer->getCardList().contains("AEther Vial")); -} - -TEST_F(OracleImporterTest, ApostropheNormalized) -{ - QJsonObject card = makeCard(QString::fromUtf8("Jace\u2019s Ingenuity")); - QJsonArray cards{card}; - - importer->importCardsFromSet(set, cards); - ASSERT_TRUE(importer->getCardList().contains("Jace's Ingenuity")); -} - -// ============================================================================ -// RawJson scanner tests -// ============================================================================ - -TEST_F(OracleImporterTest, ScanSetRangesMatchFullJsonParse) -{ - QJsonObject root; - QJsonObject data; - data["AAA"] = makeCard("Alpha Card"); - data["BBB"] = makeCard("Beta Card"); - root["data"] = data; - - const QByteArray bytes = QJsonDocument(root).toJson(QJsonDocument::Compact); - - RawJson::ScanError error; - const QList ranges = RawJson::scanSetRanges(bytes, &error); - ASSERT_FALSE(error.isError()) << error.message.toStdString(); - ASSERT_EQ(ranges.size(), 2); - - const QJsonObject wholeData = QJsonDocument::fromJson(bytes).object().value("data").toObject(); - for (const RawJson::SetRange &range : ranges) { - QJsonParseError parseError; - const QJsonDocument sliceDoc = QJsonDocument::fromJson( - QByteArray(bytes.constData() + range.dataRange.start, range.dataRange.length), &parseError); - ASSERT_EQ(parseError.error, QJsonParseError::NoError) - << range.code.toStdString() << ": " << parseError.errorString().toStdString(); - ASSERT_EQ(sliceDoc.object(), wholeData.value(range.code).toObject()) << "set " << range.code.toStdString(); - } -} - -TEST_F(OracleImporterTest, ScanSetRangesDecodesEscapesAndCountsCards) -{ - const QByteArray json = "{\"data\":{\"KEY\":{\"code\":\"zzz\",\"name\":\"\\u00c9tude \\ud83d\\ude00\"," - "\"type\":\"expansion\",\"releaseDate\":\"2024-01-05\"," - "\"cards\":[{\"name\":\"a\"},{\"name\":\"b\"},{\"name\":\"c\"}]}}}"; - - RawJson::ScanError error; - const QList ranges = RawJson::scanSetRanges(json, &error); - ASSERT_FALSE(error.isError()); - ASSERT_EQ(ranges.size(), 1); - - const RawJson::SetRange &range = ranges.first(); - ASSERT_EQ(range.code, "zzz"); // inner "code" wins over the object key - const QString expectedName = QString::fromUtf8("\xC3\x89tude ") + QChar(0xD83D) + QChar(0xDE00); - ASSERT_EQ(range.name, expectedName); - ASSERT_EQ(range.type, "expansion"); - ASSERT_EQ(range.releaseDate, "2024-01-05"); - ASSERT_EQ(range.dataRange.cardCount, 3); - - QJsonParseError parseError; - const QJsonDocument sliceDoc = QJsonDocument::fromJson( - QByteArray(json.constData() + range.dataRange.start, range.dataRange.length), &parseError); - ASSERT_EQ(parseError.error, QJsonParseError::NoError); - ASSERT_EQ(sliceDoc.object().value("name").toString(), expectedName); - ASSERT_EQ(sliceDoc.object().value("cards").toArray().size(), 3); -} - -TEST_F(OracleImporterTest, ScanSetRangesRejectsInvalidJson) -{ - const QList invalid = {"not json", - "[]", - "{\"data\":[]}", - "{\"data\":{}}", - "{\"other\":{}}", - "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"type\":\"x\"," - "\"releaseDate\":\"2024-01-01\",\"cards\":[]}}} trailing", - "{\"data\":{\"A\":{\"cards\":[{\"name\":\"\\uZZZZ\"}]}}}", - "{\"data\":{\"A\":{\"cards\":[{\"name\":\"bad \\q escape\"}]}}}", - "{\"data\":{\"A\":{\"cards\":[{\"name\":\"\\ud800\"}]}}}"}; - - for (const QByteArray &json : invalid) { - RawJson::ScanError error; - RawJson::scanSetRanges(json, &error); - EXPECT_TRUE(error.isError()) << "expected failure for: " << json.constData(); - } -} - -TEST_F(OracleImporterTest, ScanSetRangesMatchesFullJsonParseVerdicts) -{ - // Verdicts must agree with QJsonDocument::fromJson for the inputs below — - // including the metadata quirks ("name": null, "type": 7, "releaseDate": null, - // "cards": null) that used to make the scanner reject sets Qt accepts. - const QList inputs = { - "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"type\":\"x\",\"releaseDate\":\"2024-01-01\",\"cards\":[{" - "\"n\":1}]}}}", - "{\"data\":{\"A\":{\"code\":\"a\",\"name\":null,\"type\":\"x\",\"releaseDate\":\"2024-01-01\",\"cards\":[]}}}", - "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"type\":null,\"releaseDate\":\"2024-01-01\",\"cards\":[]}}}", - "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"type\":7,\"releaseDate\":\"2024-01-01\",\"cards\":null}}}", - "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"releaseDate\":\"2024-01-01\",\"cards\":[1,2,3]}}}", - // unescaped control character inside a string: QJsonDocument and - // skipString both accept it, so the scanner must not reject the whole doc - "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"N\tX\",\"releaseDate\":\"2024-01-01\",\"cards\":[{\"n\":1}]}}}", - // structurally invalid JSON (both parsers must reject) - "not json", - "{\"data\":{\"A\":{\"name\":\"unterminated}}", - }; - - for (const QByteArray &input : inputs) { - QJsonParseError qtError; - QJsonDocument::fromJson(input, &qtError); - const bool qtOk = qtError.error == QJsonParseError::NoError; - - RawJson::ScanError scanError; - const QList ranges = RawJson::scanSetRanges(input, &scanError); - EXPECT_EQ(qtOk, !scanError.isError()) << "verdict mismatch for: " << input.constData(); - if (scanError.isError()) { - continue; - } - for (const RawJson::SetRange &range : ranges) { - QJsonParseError sliceError; - QJsonDocument::fromJson(QByteArray(input.constData() + range.dataRange.start, range.dataRange.length), - &sliceError); - EXPECT_EQ(sliceError.error, QJsonParseError::NoError) << "bad range slice for: " << input.constData(); - } - } -} - -TEST_F(OracleImporterTest, ScanSetRangesRejectsDeepNesting) -{ - // Far beyond the shared 1024 container cap: Qt reports DeepNesting and the - // scanner must reject too, without overflowing the stack through its - // recursive skipValue walk. - QString nesting; - nesting.reserve(10000); - for (int i = 0; i < 5000; ++i) { - nesting += '['; - } - for (int i = 0; i < 5000; ++i) { - nesting += ']'; - } - const QByteArray json = ("{\"data\":{\"A\":{\"code\":\"a\",\"cards\":" + nesting + "}}}").toUtf8(); - - QJsonParseError qtError; - QJsonDocument::fromJson(json, &qtError); - ASSERT_NE(qtError.error, QJsonParseError::NoError) << "expected Qt to reject deep nesting"; - - RawJson::ScanError scanError; - RawJson::scanSetRanges(json, &scanError); - ASSERT_TRUE(scanError.isError()) << "scanner accepted a document Qt rejects as too deeply nested"; -} - -TEST_F(OracleImporterTest, ScanSetRangesAcceptsQtMaxNesting) -{ - // Pins the boundary rather than only the far-past case: a depth Qt still - // accepts must be accepted by the scanner too. Before the fix the scanner's - // cap was roughly half of Qt's (each level cost two decrements), so a - // depth of 1000 here was rejected even though QJsonDocument parses it. - constexpr int depth = 1000; - QString nesting; - nesting.reserve(2 * depth); - for (int i = 0; i < depth; ++i) { - nesting += '['; - } - for (int i = 0; i < depth; ++i) { - nesting += ']'; - } - const QByteArray json = ("{\"data\":{\"A\":{\"code\":\"a\",\"cards\":" + nesting + "}}}").toUtf8(); - - QJsonParseError qtError; - QJsonDocument::fromJson(json, &qtError); - ASSERT_EQ(qtError.error, QJsonParseError::NoError) << "expected Qt to accept depth " << depth; - - RawJson::ScanError scanError; - RawJson::scanSetRanges(json, &scanError); - ASSERT_FALSE(scanError.isError()) << "scanner rejected a document Qt accepts at depth " << depth; -} - -// ============================================================================ -// Lazy per-set parsing tests -// ============================================================================ - -TEST_F(OracleImporterTest, StartImportParsesSetsLazily) -{ - QJsonObject setObj = makeCard("Lazy Import Card"); - QJsonArray cards; - cards.append(setObj); - QJsonObject dataSet; - dataSet["code"] = "tst"; - dataSet["name"] = "Test Set"; - dataSet["type"] = "expansion"; - dataSet["releaseDate"] = "2024-01-01"; - dataSet["cards"] = cards; - - QJsonObject root; - root["data"] = QJsonObject{{"TST", dataSet}}; - - const QByteArray data = QJsonDocument(root).toJson(QJsonDocument::Compact); - ASSERT_TRUE(importer->readSetsFromByteArray(data)); - ASSERT_FALSE(importer->getRawSetsData().isEmpty()); - - const int importedSets = importer->startImport(); - ASSERT_EQ(importedSets, 1); - ASSERT_EQ(importer->getCardList().size(), 1); - ASSERT_FALSE(importer->getCardList().value("Lazy Import Card").isNull()); -} - -int main(int argc, char **argv) -{ - ::testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); -} diff --git a/tests/password_hash_test.cpp b/tests/password_hash_test.cpp index 2b8f8bdb7..38d9b6315 100644 --- a/tests/password_hash_test.cpp +++ b/tests/password_hash_test.cpp @@ -1,9 +1,25 @@ #include "gtest/gtest.h" -#include +#include +#include #include +RNG_Abstract *rng; + namespace { +class PasswordHashTest : public ::testing::Test +{ +protected: + void SetUp() override + { + rng = new RNG_SFMT; + } + + void TearDown() override + { + delete rng; + } +}; TEST(PasswordHashTest, RegressionTest) { @@ -13,29 +29,6 @@ TEST(PasswordHashTest, RegressionTest) QString hash = PasswordHasher::computeHash(password, salt); ASSERT_EQ(hash, salt + expected) << "The computed hash value remains the same"; } - -TEST(PasswordHashTest, SaltUsesAlphanumericCharset) -{ - static const char alphanum[] = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; - const QString salt = PasswordHasher::generateRandomSalt(); - ASSERT_EQ(salt.size(), 16); - for (const QChar &c : salt) { - ASSERT_NE(strchr(alphanum, c.toLatin1()), nullptr); - } -} - -TEST(PasswordHashTest, SaltsAreUnique) -{ - const QString salt1 = PasswordHasher::generateRandomSalt(); - const QString salt2 = PasswordHasher::generateRandomSalt(); - ASSERT_NE(salt1, salt2); -} - -TEST(PasswordHashTest, TokenHasExpectedLength) -{ - const QString token = PasswordHasher::generateActivationToken(); - ASSERT_EQ(token.size(), 16); -} } // namespace int main(int argc, char **argv) diff --git a/tests/settings/settings_defaults_test.cpp b/tests/settings/settings_defaults_test.cpp index 139656f27..6c79d5227 100644 --- a/tests/settings/settings_defaults_test.cpp +++ b/tests/settings/settings_defaults_test.cpp @@ -238,12 +238,6 @@ TEST_F(SettingsDefaultsTest, Tabs_ModerationOpen_Default) ASSERT_EQ(s.getTabModerationOpen(), false); } -TEST_F(SettingsDefaultsTest, Tabs_CardArtRulesOpen_Default) -{ - TabsSettings s(settingsPath, nullptr); - ASSERT_EQ(s.getTabCardArtRulesOpen(), false); -} - // --- ChatSettings --- TEST_F(SettingsDefaultsTest, Chat_Mention_Default)