diff --git a/.ci/Arch/Dockerfile b/.ci/Arch/Dockerfile index 36cf5c4ae..b08e568f3 100644 --- a/.ci/Arch/Dockerfile +++ b/.ci/Arch/Dockerfile @@ -8,10 +8,13 @@ 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 202405b84..e3df94ab5 100644 --- a/.ci/Debian12/Dockerfile +++ b/.ci/Debian12/Dockerfile @@ -15,12 +15,15 @@ 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 d7ab6ac86..60e490c98 100644 --- a/.ci/Debian13/Dockerfile +++ b/.ci/Debian13/Dockerfile @@ -16,12 +16,15 @@ 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 27570cf99..4005bbf67 100644 --- a/.ci/Fedora43/Dockerfile +++ b/.ci/Fedora43/Dockerfile @@ -7,8 +7,9 @@ RUN dnf install -y \ git \ mariadb-devel \ ninja-build \ + openssl-devel \ protobuf-devel \ - qt6-{qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ + qt6-{qtdeclarative,qtshadertools,qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ qt6-qtimageformats \ rpm-build \ xz-devel \ diff --git a/.ci/Fedora44/Dockerfile b/.ci/Fedora44/Dockerfile index e6c8da7f3..e0224cdc6 100644 --- a/.ci/Fedora44/Dockerfile +++ b/.ci/Fedora44/Dockerfile @@ -7,8 +7,9 @@ RUN dnf install -y \ git \ mariadb-devel \ ninja-build \ + openssl-devel \ protobuf-devel \ - qt6-{qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ + qt6-{qtdeclarative,qtshadertools,qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ qt6-qtimageformats \ rpm-build \ xz-devel \ diff --git a/.ci/Servatrice_Debian12/Dockerfile b/.ci/Servatrice_Debian12/Dockerfile index 21f6a036e..321aa7c0f 100644 --- a/.ci/Servatrice_Debian12/Dockerfile +++ b/.ci/Servatrice_Debian12/Dockerfile @@ -12,6 +12,7 @@ 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 809b2e43a..10adc5e64 100644 --- a/.ci/Ubuntu24.04/Dockerfile +++ b/.ci/Ubuntu24.04/Dockerfile @@ -15,12 +15,15 @@ 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 7b0cd389f..1b6cf825f 100644 --- a/.ci/Ubuntu26.04/Dockerfile +++ b/.ci/Ubuntu26.04/Dockerfile @@ -16,12 +16,15 @@ 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 8a16d3243..bd8c900c8 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -149,6 +149,9 @@ 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 58ca87573..75fbc59f1 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -40,7 +40,7 @@ jobs: steps: - name: "Checkout repository" - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: "Initialize CodeQL" uses: github/codeql-action/init@v4 @@ -68,7 +68,9 @@ 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 4c2cef4e4..e080bdc93 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -176,8 +176,12 @@ jobs: shell: bash run: | source .ci/docker.sh - RUN --server --debug --test --ccache "$CCACHE_SIZE" \ - --cmake-generator "$CMAKE_GENERATOR" + 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[@]}" - name: "Build release package" id: build @@ -269,7 +273,7 @@ jobs: override_target: 13 package_suffix: "-macOS13_Intel" qt_version: 6.11.1 - qt_modules: qtimageformats qtmultimedia qtwebsockets + qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools soc: Intel type: Release use_ccache: 1 @@ -285,7 +289,7 @@ jobs: override_target: 14 package_suffix: "-macOS14" qt_version: 6.11.1 - qt_modules: qtimageformats qtmultimedia qtwebsockets + qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools soc: Apple type: Release use_ccache: 1 @@ -301,7 +305,7 @@ jobs: override_target: 15 package_suffix: "-macOS15" qt_version: 6.11.1 - qt_modules: qtimageformats qtmultimedia qtwebsockets + qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools soc: Apple type: Release use_ccache: 1 @@ -314,7 +318,7 @@ jobs: ccache_eviction_age: 7d cmake_generator: Ninja qt_version: 6.11.1 - qt_modules: qtimageformats qtmultimedia qtwebsockets + qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools soc: Apple type: Debug use_ccache: 1 @@ -329,7 +333,7 @@ jobs: make_package: 1 package_suffix: "-Win10" qt_version: 6.11.1 - qt_modules: qtimageformats qtmultimedia qtwebsockets + qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools 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 e679132ac..967d94c58 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@v7 + uses: actions/download-artifact@v8 with: path: ${{ runner.temp }}/digests pattern: digest-* diff --git a/CMakeLists.txt b/CMakeLists.txt index 27fecc979..0da073464 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.. -# cmake 3.16 is required if using qt6 -cmake_minimum_required(VERSION 3.10) +# 3.16 required for Qt6 and target_precompile_headers() +cmake_minimum_required(VERSION 3.16) -# Early detect ccache +# Use compiler cache (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 servatrice -option(WITH_SERVER "build servatrice" OFF) -# Compile cockatrice -option(WITH_CLIENT "build cockatrice" ON) -# Compile oracle -option(WITH_ORACLE "build oracle" ON) +# 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 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,13 +39,24 @@ else() ) endif() -if(USE_CCACHE) +# 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) 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) @@ -184,6 +195,9 @@ 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") @@ -192,6 +206,9 @@ 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 @@ -239,11 +256,6 @@ 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 382309d47..7d3deb5fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,7 @@ RUN apt-get update \ libmariadb-dev-compat \ libprotobuf-dev \ libqt6sql6-mysql \ + libssl-dev \ qt6-websockets-dev \ protobuf-compiler \ qt6-tools-dev \ @@ -42,6 +43,7 @@ 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 8a3050813..0259d12e1 100644 --- a/cmake/FindQtRuntime.cmake +++ b/cmake/FindQtRuntime.cmake @@ -18,10 +18,13 @@ 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 new file mode 100644 index 000000000..cc3dd12ee --- /dev/null +++ b/cmake/pch/qtcore_pch.h @@ -0,0 +1,24 @@ +/** @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 new file mode 100644 index 000000000..2c63f450e --- /dev/null +++ b/cmake/pch/qtwidgets_pch.h @@ -0,0 +1,30 @@ +/** @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 1924d86bf..b6050a1bd 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -214,6 +214,7 @@ 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 @@ -230,6 +231,7 @@ 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 @@ -270,6 +272,7 @@ 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 @@ -397,6 +400,27 @@ 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 @@ -493,6 +517,30 @@ 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 9c34929b7..e21bdb0be 100644 --- a/cockatrice/cockatrice.qrc +++ b/cockatrice/cockatrice.qrc @@ -2,6 +2,7 @@ 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 new file mode 100644 index 000000000..b3b31077f --- /dev/null +++ b/cockatrice/resources/cockatrice-logo-white.svg @@ -0,0 +1,21 @@ + + + + + + + + + diff --git a/cockatrice/resources/cockatrice.svg b/cockatrice/resources/cockatrice.svg index d2e22da31..89ba62dcf 100644 --- a/cockatrice/resources/cockatrice.svg +++ b/cockatrice/resources/cockatrice.svg @@ -2,20 +2,20 @@ + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:dc="http://purl.org/dc/elements/1.1/" + sodipodi:docname="cockatrice.svg" + xmlns="http://www.w3.org/2000/svg"> + inkscape:current-layer="svg2" + inkscape:showpageshadow="0" + inkscape:pagecheckerboard="0" + inkscape:deskcolor="#505050"> + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + @@ -199,7 +159,7 @@ image/svg+xml - + @@ -213,170 +173,60 @@ 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 0c8bdb450..fd0a12507 100644 --- a/cockatrice/resources/help/search.md +++ b/cockatrice/resources/help/search.md @@ -52,6 +52,7 @@ 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 1278737a0..1acd02a75 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view.cpp @@ -10,7 +10,6 @@ #include #include #include -#include #include DeckViewCardDragItem::DeckViewCardDragItem(DeckViewCard *_item, @@ -381,12 +380,10 @@ void DeckViewScene::rebuildTree() addItem(container); } - for (int j = 0; j < currentZone->size(); j++) { - auto *currentCard = dynamic_cast(currentZone->at(j)); - if (!currentCard) { - continue; - } - + // 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 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 23ed4316d..bc07ac183 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp @@ -9,6 +9,7 @@ #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 cd2b12828..457f1b3f7 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -44,11 +44,16 @@ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent) GameScene::~GameScene() { - // 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); + // 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(); delete animationTimer; animationTimer = nullptr; @@ -777,8 +782,15 @@ void GameScene::registerAnimationItem(IAnimatedItem *item) if (!object) { return; } - if (!animatedItems.contains(object)) { - connect(object, &QObject::destroyed, this, &GameScene::removeAnimatedItem); + // 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)); } animatedItems.insert(object, item); if (animationTimer && !animationTimer->isActive()) { @@ -797,6 +809,7 @@ 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 c12696189..859d7a6eb 100644 --- a/cockatrice/src/game_graphics/game_scene.h +++ b/cockatrice/src/game_graphics/game_scene.h @@ -54,9 +54,11 @@ private: QPointer hoveredCard; ///< Currently hovered card QBasicTimer *animationTimer; ///< Timer for scene animations QHash animatedItems; ///< Items currently animating - int playerRotation; ///< Rotation offset for player layout - bool rearranging = false; ///< Guard against re-entrant rearrange - bool needsReArrange = false; ///< Pending rearrange requested during a pass + 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 /** * @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 7eb3945b3..08cb6cac9 100644 --- a/cockatrice/src/game_graphics/player/menu/tally_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/tally_menu.cpp @@ -12,11 +12,13 @@ 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(); } @@ -54,4 +56,5 @@ 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 acd1daf67..11802fd20 100644 --- a/cockatrice/src/game_graphics/player/menu/tally_menu.h +++ b/cockatrice/src/game_graphics/player/menu/tally_menu.h @@ -24,6 +24,7 @@ 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 e7a6621fa..7e05c3fb1 100644 --- a/cockatrice/src/game_graphics/tally/stats_tally.cpp +++ b/cockatrice/src/game_graphics/tally/stats_tally.cpp @@ -34,3 +34,31 @@ 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 4c3d93b56..e499587eb 100644 --- a/cockatrice/src/game_graphics/tally/stats_tally.h +++ b/cockatrice/src/game_graphics/tally/stats_tally.h @@ -16,6 +16,14 @@ 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 aa2cae024..21806ee84 100644 --- a/cockatrice/src/game_graphics/tally/tally.cpp +++ b/cockatrice/src/game_graphics/tally/tally.cpp @@ -21,6 +21,8 @@ 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 97406cddb..84c54918f 100644 --- a/cockatrice/src/game_graphics/tally/tally.h +++ b/cockatrice/src/game_graphics/tally/tally.h @@ -21,7 +21,8 @@ enum class TallyType None, Subtypes, TotalPower, - MaxValue = TotalPower // sentinel value + TotalToughness, + MaxValue = TotalToughness // 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 b52a4955a..1a8f7a910 100644 --- a/cockatrice/src/game_graphics/zones/hand_zone.cpp +++ b/cockatrice/src/game_graphics/zones/hand_zone.cpp @@ -41,7 +41,8 @@ void HandZone::handleDropEvent(const QList &dragItems, } } } else { - x = calcDropIndexFromY(dropPoint.y()); + bool sameZone = startZone == getLogic(); + x = calcDropIndexFromY(dropPoint.y(), !sameZone); } Command_MoveCard cmd; diff --git a/cockatrice/src/game_graphics/zones/select_zone.cpp b/cockatrice/src/game_graphics/zones/select_zone.cpp index c58c41b92..470c70fcf 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, qreal minOffset) const +int SelectZone::calcDropIndexFromY(qreal dropY, bool allowCountExpand, qreal minOffset) const { const auto &cards = getLogic()->getCards(); if (cards.isEmpty()) { @@ -94,7 +94,8 @@ int SelectZone::calcDropIndexFromY(qreal dropY, qreal minOffset) const if (effectiveOffset <= 0.0) { return 0; } - return qBound(0, qRound((dropY - start) / effectiveOffset), params.cardCount - 1); + int max = allowCountExpand ? params.cardCount : params.cardCount - 1; + return qBound(0, qRound((dropY - start) / effectiveOffset), max); } void SelectZone::restoreStaleEscapedCards() diff --git a/cockatrice/src/game_graphics/zones/select_zone.h b/cockatrice/src/game_graphics/zones/select_zone.h index 7408f29b6..b5d3ca37a 100644 --- a/cockatrice/src/game_graphics/zones/select_zone.h +++ b/cockatrice/src/game_graphics/zones/select_zone.h @@ -104,8 +104,12 @@ 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, qreal minOffset = 0.0) const; + int calcDropIndexFromY(qreal dropY, bool allowCountExpand, 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 e9b14f13d..ff62097c7 100644 --- a/cockatrice/src/game_graphics/zones/stack_zone.cpp +++ b/cockatrice/src/game_graphics/zones/stack_zone.cpp @@ -57,18 +57,14 @@ void StackZone::handleDropEvent(const QList &dragItems, return; } - const auto &cards = getLogic()->getCards(); - int index; - if (startZone == getLogic()) { - // Reordering within the zone: use drop position - index = calcDropIndexFromY(dropPoint.y(), MIN_CARD_VISIBLE); + bool sameZone = startZone == getLogic(); + int index = calcDropIndexFromY(dropPoint.y(), !sameZone, MIN_CARD_VISIBLE); + if (sameZone) { // 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 39a0c1071..f03339da8 100644 --- a/cockatrice/src/interface/deck_loader/deck_loader.cpp +++ b/cockatrice/src/interface/deck_loader/deck_loader.cpp @@ -375,15 +375,32 @@ void DeckLoader::saveToStream_DeckHeader(QTextStream &out, const DeckList &deckL void DeckLoader::saveToStream_DeckZone(QTextStream &out, const InnerDecklistNode *zoneNode, bool addComments, - bool addSetNameAndNumber) + bool addSetNameAndNumber, + const QString &boardZoneName) { + // 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"; @@ -411,25 +428,30 @@ void DeckLoader::saveToStream_DeckZone(QTextStream &out, QList cards = cardsByType.values(cardType); - saveToStream_DeckZoneCards(out, zoneNode, cards, addComments, addSetNameAndNumber); + saveToStream_DeckZoneCards(out, cards, addComments, addSetNameAndNumber, owningBoardZoneName); 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) + bool addSetNameAndNumber, + const QString &boardZoneName) { // QMultiMap sorts values in reverse order for (int i = cards.size() - 1; i >= 0; --i) { DecklistCardNode *card = cards[i]; - if (zoneNode->getName() == DECK_ZONE_SIDE && addComments) { + if (boardZoneName == DECK_ZONE_SIDE && addComments) { out << "SB: "; } @@ -510,9 +532,26 @@ bool DeckLoader::convertToCockatriceFormat(LoadedDeck &deck) void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode *node) { + if (!node || node->isEmpty()) { + return; + } + const int totalColumns = 2; - if (node->height() == 1) { + // 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()) { QTextBlockFormat blockFormat; QTextCharFormat charFormat; charFormat.setFontPointSize(11); @@ -523,9 +562,9 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode tableFormat.setCellPadding(0); tableFormat.setCellSpacing(0); tableFormat.setBorder(0); - QTextTable *table = cursor->insertTable(node->size() + 1, totalColumns, tableFormat); - for (int i = 0; i < node->size(); i++) { - auto *card = dynamic_cast(node->at(i)); + QTextTable *table = cursor->insertTable(cards.size() + 1, totalColumns, tableFormat); + for (int i = 0; i < cards.size(); i++) { + const AbstractDecklistCardNode *card = cards[i]; QTextCharFormat cellCharFormat; cellCharFormat.setFontPointSize(9); @@ -540,7 +579,13 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode cellCursor = cell.firstCursorPosition(); cellCursor.insertText(card->getName()); } - } else if (node->height() == 2) { + } + + for (const InnerDecklistNode *subZone : subZones) { + if (subZone->isEmpty()) { + continue; + } + QTextBlockFormat blockFormat; QTextCharFormat charFormat; charFormat.setFontPointSize(14); @@ -559,10 +604,8 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode tableFormat.setColumnWidthConstraints(constraints); QTextTable *table = cursor->insertTable(1, totalColumns, tableFormat); - 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))); - } + QTextCursor cellCursor = table->cellAt(0, 0).firstCursorPosition(); + printDeckListNode(&cellCursor, subZone); } 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 ac23e1ee0..b851c6895 100644 --- a/cockatrice/src/interface/deck_loader/deck_loader.h +++ b/cockatrice/src/interface/deck_loader/deck_loader.h @@ -159,12 +159,13 @@ private: static void saveToStream_DeckZone(QTextStream &out, const InnerDecklistNode *zoneNode, bool addComments = true, - bool addSetNameAndNumber = true); + bool addSetNameAndNumber = true, + const QString &boardZoneName = QString()); static void saveToStream_DeckZoneCards(QTextStream &out, - const InnerDecklistNode *zoneNode, QList cards, bool addComments = true, - bool addSetNameAndNumber = true); + bool addSetNameAndNumber = true, + const QString &boardZoneName = QString()); }; #endif diff --git a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp index adae6e152..9cde72c01 100644 --- a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp +++ b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp @@ -290,28 +290,42 @@ 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) { - const QString &scheme = it.key(); - if (it.value().colors == savedConfig.value(scheme).colors) { - continue; // unchanged — leave the on-disk file alone + if (it.key() == loadedScheme) { + continue; } - - if (!ThemeManager::savePaletteConfig(saveDir, scheme, it.value())) { + 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(scheme), saveDir)); + 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])) { + QMessageBox::warning(this, tr("Save failed"), + tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(loadedScheme), 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. for (auto it = workingConfig.begin(); it != workingConfig.end(); ++it) { 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 5bfba1c8a..9b8c4bcdc 100644 --- a/cockatrice/src/interface/pixel_map_generator.cpp +++ b/cockatrice/src/interface/pixel_map_generator.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -418,6 +419,57 @@ 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 22f44d8db..17720166a 100644 --- a/cockatrice/src/interface/pixel_map_generator.h +++ b/cockatrice/src/interface/pixel_map_generator.h @@ -7,6 +7,7 @@ #ifndef PIXMAPGENERATOR_H #define PIXMAPGENERATOR_H +#include #include #include #include @@ -125,6 +126,34 @@ 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 8986a9f00..e6b4b3c7f 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -272,6 +272,19 @@ 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 e3a40660b..79a1b6470 100644 --- a/cockatrice/src/interface/theme_manager.h +++ b/cockatrice/src/interface/theme_manager.h @@ -91,6 +91,10 @@ 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 3f0f30a27..1ea1bcb10 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,6 +41,11 @@ 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) { @@ -73,20 +78,33 @@ void ColorIdentityWidget::toggleUnusedVisibility() void ColorIdentityWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); - QList manaSymbols = findChildren(); - if (!manaSymbols.isEmpty()) { - int totalWidth = event->size().width(); - int totalHeight = totalWidth / 6; // Set height to 1/4 of the width - setFixedHeight(totalHeight); + const int totalWidth = event->size().width(); + if (totalWidth == lastWidth && lastIconSize != -1) { + return; + } + lastWidth = totalWidth; - 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 totalHeight = totalWidth / 6; // Set height to 1/4 of the width + setFixedHeight(totalHeight); - for (ManaSymbolWidget *manaSymbol : manaSymbols) { - manaSymbol->setFixedSize(iconSize, iconSize); + 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); } } } 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 f776d4c77..315ac07d6 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,6 +30,8 @@ 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 51247da7a..18011909f 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(_symbol), isActive(_isActive), mayBeToggled(_mayBeToggled) + : QLabel(parent), symbol(std::move(_symbol)), isActive(_isActive), mayBeToggled(_mayBeToggled) { - loadManaIcon(); - setPixmap(manaIcon.scaled(50, 50, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + setPixmap(ManaSymbolPixmapGenerator::generatePixmap(symbol, QSize(50, 50))); setMaximumWidth(50); // Initialize opacity effect @@ -64,16 +64,13 @@ void ManaSymbolWidget::mousePressEvent(QMouseEvent *event) void ManaSymbolWidget::resizeEvent(QResizeEvent *event) { QLabel::resizeEvent(event); - setPixmap(manaIcon.scaled(event->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation)); -} + const QSize newSize = event->size(); -void ManaSymbolWidget::loadManaIcon() -{ - QString filename = "theme:icons/mana/"; - - if (symbol == "W" || symbol == "U" || symbol == "B" || symbol == "R" || symbol == "G") { - filename += symbol; + // 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; } - manaIcon = QPixmap(filename); + setPixmap(ManaSymbolPixmapGenerator::generatePixmap(symbol, newSize)); } 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 0f2d7acd1..705873dff 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,8 +33,6 @@ public: return symbol[0]; } - void loadManaIcon(); - public slots: void resizeEvent(QResizeEvent *event) override; void mousePressEvent(QMouseEvent *event) override; @@ -44,7 +42,6 @@ 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 3f36e559c..bfbdd7e42 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,16 +174,18 @@ void CardGroupDisplayWidget::updateCardDisplays() QModelIndex sourceIndex = proxy.mapToSource(proxyIndex); // 4. persist the source index - QPersistentModelIndex persistent(sourceIndex); + addCardWidgets(QPersistentModelIndex(sourceIndex)); + } +} - // Get the card amount - int cardAmount = - sourceIndex.sibling(sourceIndex.row(), DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt(); +void CardGroupDisplayWidget::addCardWidgets(const QPersistentModelIndex &persistent) +{ + // Get the card amount + int cardAmount = persistent.sibling(persistent.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 2308ccf8d..a3bf70981 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,6 +35,7 @@ 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 eaf3a67b0..b00d9db1e 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,6 +5,7 @@ #include "libcockatrice/card/database/card_database_manager.h" #include +#include #include DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent, @@ -51,11 +52,6 @@ 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); @@ -95,12 +91,18 @@ 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, zoneName, categoryName, activeGroupCriteria, - activeSortCriteria, subBannerOpacity, cardSizeWidget); - connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardClicked, this, - &DeckCardZoneDisplayWidget::onClick); + cardGroupContainer, deckListModel, selectionModel, index, effectiveZoneName, categoryName, + activeGroupCriteria, activeSortCriteria, subBannerOpacity, cardSizeWidget); + connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardClicked, this, routeCardClick); connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardHovered, this, &DeckCardZoneDisplayWidget::onHover); connect(displayWidget, &CardGroupDisplayWidget::cleanupRequested, this, @@ -111,9 +113,9 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex indexToWidgetMap.insert(index, displayWidget); } else if (displayType == DisplayType::Flat) { auto *displayWidget = new FlatCardGroupDisplayWidget(cardGroupContainer, deckListModel, selectionModel, index, - zoneName, categoryName, activeGroupCriteria, + effectiveZoneName, categoryName, activeGroupCriteria, activeSortCriteria, subBannerOpacity, cardSizeWidget); - connect(displayWidget, &FlatCardGroupDisplayWidget::cardClicked, this, &DeckCardZoneDisplayWidget::onClick); + connect(displayWidget, &FlatCardGroupDisplayWidget::cardClicked, this, routeCardClick); connect(displayWidget, &FlatCardGroupDisplayWidget::cardHovered, this, &DeckCardZoneDisplayWidget::onHover); connect(displayWidget, &CardGroupDisplayWidget::cleanupRequested, this, &DeckCardZoneDisplayWidget::cleanupInvalidCardGroup); @@ -126,24 +128,18 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex void DeckCardZoneDisplayWidget::displayCards() { - QSortFilterProxyModel proxy; - proxy.setSourceModel(deckListModel); - proxy.setSortRole(Qt::EditRole); - proxy.sort(DeckListModelColumns::CARD_NAME, Qt::AscendingOrder); + if (!trackedIndex.isValid()) { + return; + } - // 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); + // 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))); + } + 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 b426fca30..53f3fa7cf 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,7 +42,6 @@ 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 7c782b074..00388a3cd 100644 --- a/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp @@ -90,6 +90,13 @@ 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()) { @@ -142,6 +149,50 @@ 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 175ec12b9..668444199 100644 --- a/cockatrice/src/interface/widgets/deck_editor/card_database_view.h +++ b/cockatrice/src/interface/widgets/deck_editor/card_database_view.h @@ -4,6 +4,7 @@ #include "../../key_signals.h" #include +#include #include class CardDatabaseModel; @@ -19,6 +20,13 @@ 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); @@ -33,6 +41,17 @@ 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 2a491de4f..6269f0323 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,5 +1,12 @@ #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"); @@ -15,6 +22,27 @@ 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 14defc8e9..e2175a358 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,15 +7,18 @@ #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 @@ -772,14 +775,213 @@ 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 a55056bda..1e5f4e677 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,8 +15,12 @@ #include "deck_list_history_manager_widget.h" #include "deck_list_style_proxy.h" +#include +#include #include #include +#include +#include #include #include #include @@ -99,6 +103,11 @@ 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 eda741728..e563729a4 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp @@ -2,6 +2,7 @@ #include #include +#include DeckStateManager::DeckStateManager(QObject *parent) : QObject(parent), deckList(QSharedPointer(new DeckList)), @@ -307,6 +308,170 @@ 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()) { @@ -367,6 +532,25 @@ 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 b9c99903e..2c8b34a39 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h @@ -5,6 +5,7 @@ #include "deck_list_model.h" #include +#include #include class DeckListHistoryManager; @@ -236,6 +237,68 @@ 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. @@ -257,6 +320,7 @@ 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 new file mode 100644 index 000000000..9a0be2570 --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.cpp @@ -0,0 +1,145 @@ +#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 new file mode 100644 index 000000000..6f55617a8 --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h @@ -0,0 +1,123 @@ +/** + * @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 198fa259b..a4a31d78d 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,9 +1,17 @@ #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) { @@ -38,3 +46,71 @@ 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 6642ad8c6..526582135 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,6 +13,9 @@ #include #include #include +#include + +class QWidget; class DialogConvertDeckToCodFormat : public QDialog { @@ -24,6 +27,21 @@ 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 fce99a1a7..6ae8c9adb 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp @@ -1,18 +1,62 @@ #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.")); @@ -321,26 +365,28 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) realnameEdit->setMaxLength(MAX_NAME_LENGTH); realnameLabel->setBuddy(realnameEdit); + // ── Layout ───────────────────────────────────────────────────────── auto *grid = new QGridLayout; - 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); + 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); auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgRegister::actOk); @@ -352,13 +398,115 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) setLayout(mainLayout); setWindowTitle(tr("Register to server")); - setFixedHeight(sizeHint().height()); - setMinimumWidth(300); + 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)); } 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; @@ -375,5 +523,29 @@ 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 abed9ff51..ce14eb427 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_register.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_register.h @@ -1,25 +1,24 @@ -/** - * @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 QCheckBox; +class QRadioButton; +class UserConnection_Information; class DlgRegister : public QDialog { Q_OBJECT public: explicit DlgRegister(QWidget *parent = nullptr); + ~DlgRegister() override; + [[nodiscard]] QString getHost() const { return hostEdit->text(); @@ -48,15 +47,35 @@ 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 +#endif // DLG_REGISTER_H diff --git a/cockatrice/src/interface/widgets/general/display/banner_widget.cpp b/cockatrice/src/interface/widgets/general/display/banner_widget.cpp index 5de5457ea..31f384cac 100644 --- a/cockatrice/src/interface/widgets/general/display/banner_widget.cpp +++ b/cockatrice/src/interface/widgets/general/display/banner_widget.cpp @@ -32,6 +32,7 @@ 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 new file mode 100644 index 000000000..1550b57e7 --- /dev/null +++ b/cockatrice/src/interface/widgets/general/home_tab_button_color.h @@ -0,0 +1,49 @@ +#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 91f0d12b5..10fcdcb43 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.cpp +++ b/cockatrice/src/interface/widgets/general/home_widget.cpp @@ -7,6 +7,7 @@ #include "../cards/art_crop_attribution.h" #include "background_sources.h" #include "home_styled_button.h" +#include "home_tab_button_color.h" #include #include @@ -25,7 +26,7 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor) backgroundSourceCard = new CardInfoPictureArtCropWidget(this); - gradientColors = extractDominantColors(background); + gradientColors = determineButtonColor(); layout->addWidget(createButtons(), 1, 1, Qt::AlignVCenter | Qt::AlignHCenter); @@ -55,6 +56,8 @@ 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() @@ -97,6 +100,34 @@ 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; @@ -171,7 +202,7 @@ void HomeWidget::updateBackgroundProperties() void HomeWidget::updateButtonsToBackgroundColor() { - gradientColors = extractDominantColors(background); + gradientColors = determineButtonColor(); for (HomeStyledButton *button : findChildren()) { button->updateStylesheet(gradientColors); button->update(); @@ -266,11 +297,6 @@ 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 90d003aa7..9df0d7b6a 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(); - QPair extractDominantColors(const QPixmap &pixmap); + static QPair extractDominantColors(const QPixmap &pixmap); public slots: void paintEvent(QPaintEvent *event) override; @@ -47,6 +47,7 @@ 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 new file mode 100644 index 000000000..32f3e89c0 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/banner_shader_config.h @@ -0,0 +1,250 @@ +#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 new file mode 100644 index 000000000..618ac6f26 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp @@ -0,0 +1,218 @@ +#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 new file mode 100644 index 000000000..2c186ef95 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h @@ -0,0 +1,71 @@ +#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 new file mode 100644 index 000000000..6da8958f2 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard_page.cpp @@ -0,0 +1 @@ +#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 new file mode 100644 index 000000000..bdcd123bd --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard_page.h @@ -0,0 +1,75 @@ +#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 new file mode 100644 index 000000000..2107ea8bf --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.cpp @@ -0,0 +1,56 @@ +#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 new file mode 100644 index 000000000..0d9b76699 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.h @@ -0,0 +1,38 @@ +#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 new file mode 100644 index 000000000..12116de7a --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.cpp @@ -0,0 +1,314 @@ +#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 new file mode 100644 index 000000000..0461d11d5 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.h @@ -0,0 +1,79 @@ +#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 new file mode 100644 index 000000000..4205fa532 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/finish_page.cpp @@ -0,0 +1,30 @@ +#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 new file mode 100644 index 000000000..40ebc6ed0 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/finish_page.h @@ -0,0 +1,22 @@ +#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 new file mode 100644 index 000000000..fceb34deb --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.cpp @@ -0,0 +1,173 @@ +#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 new file mode 100644 index 000000000..eed1b3aed --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.h @@ -0,0 +1,41 @@ +#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 new file mode 100644 index 000000000..3293b19ac --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.cpp @@ -0,0 +1,231 @@ +#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 new file mode 100644 index 000000000..d1f84c1b9 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.h @@ -0,0 +1,58 @@ +#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 new file mode 100644 index 000000000..16e0719d2 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.cpp @@ -0,0 +1,79 @@ +#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 new file mode 100644 index 000000000..93b24d83d --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.h @@ -0,0 +1,28 @@ +#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 new file mode 100644 index 000000000..f1a385cad --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/qml/BrandBanner.qml @@ -0,0 +1,62 @@ +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 new file mode 100644 index 000000000..fd1fb2a98 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp @@ -0,0 +1,195 @@ +#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 new file mode 100644 index 000000000..2e230ad7f --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.h @@ -0,0 +1,83 @@ +#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 new file mode 100644 index 000000000..508bd4bc4 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/shaders/brand_banner.frag @@ -0,0 +1,461 @@ +#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 new file mode 100644 index 000000000..d25e8544b --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/step_indicator_widget.cpp @@ -0,0 +1,84 @@ +#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 new file mode 100644 index 000000000..1b85be04f --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/step_indicator_widget.h @@ -0,0 +1,34 @@ +#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 2b9201e6c..0b77ca185 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,6 +1,7 @@ #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 646f2ee33..8d5d423f6 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 f6f34a6a5..fb610e814 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp @@ -525,6 +525,13 @@ 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 02cc2b44e..ed7320fba 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_popup.h +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.h @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -149,6 +150,17 @@ 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(); @@ -159,6 +171,7 @@ 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); @@ -200,6 +213,7 @@ 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 new file mode 100644 index 000000000..fc5b520ad --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_list_dialog.cpp @@ -0,0 +1,302 @@ +#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 new file mode 100644 index 000000000..fa7d5de92 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_list_dialog.h @@ -0,0 +1,79 @@ +#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 c4b5d6af6..a8c99c979 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -1,335 +1,21 @@ #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 { @@ -613,7 +299,12 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, userTree->hideColumn(3); connect(userTree, &QTreeWidget::itemActivated, this, &UserListWidget::userClicked); userTree->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - userTree->header()->setStretchLastSection(true); + // 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); // Always create timers so callers never segfault on a null deref; // showPopupForUser / hidePopup already guard against a null userInfoPopup. @@ -654,6 +345,11 @@ 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); @@ -930,13 +626,24 @@ 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 { - userTree->header()->setSectionResizeMode(QHeaderView::ResizeToContents); + // 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->showColumn(1); userTree->showColumn(2); userTree->hideColumn(3); @@ -960,6 +667,8 @@ 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 5fed54573..412271160 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.h +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.h @@ -16,9 +16,7 @@ #include "user_list_painter.h" #include -#include #include -#include #include #include #include @@ -31,80 +29,12 @@ 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 9272c36d9..c8494f095 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp @@ -5,6 +5,7 @@ #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" @@ -131,6 +132,14 @@ 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; @@ -139,10 +148,54 @@ 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); @@ -248,7 +301,6 @@ AppearanceSettingsPage::AppearanceSettingsPage() cardLayoutGroupBox->setLayout(cardLayoutGrid); // Card counter colors - auto *cardCounterColorsLayout = new QGridLayout; cardCounterColorsLayout->setColumnStretch(1, 1); cardCounterColorsLayout->setColumnStretch(3, 1); @@ -328,47 +380,6 @@ 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); @@ -497,6 +508,15 @@ 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")); @@ -540,9 +560,4 @@ 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 28abbd537..8db71ff8f 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h @@ -35,51 +35,64 @@ private: QLabel styleComboLabel; QComboBox styleCombo; QPushButton editPaletteButton; + QLabel homeTabBackgroundSourceLabel; QComboBox homeTabBackgroundSourceBox; QLabel homeTabBackgroundShuffleFrequencyLabel; QSpinBox homeTabBackgroundShuffleFrequencySpinBox; QCheckBox homeTabDisplayCardNameCheckBox; - QCheckBox styleUserListCheckBox; - QCheckBox showShortcutsCheckBox; - QCheckBox showGameSelectorFilterToolbarCheckBox; - QLabel minPlayersForMultiColumnLayoutLabel; - QLabel maxFontSizeForCardsLabel; - QCheckBox overrideAllCardArtWithPersonalPreferenceCheckBox; - QCheckBox bumpSetsWithCardsInDeckToTopCheckBox; - QCheckBox displayCardNamesCheckBox; - QCheckBox autoRotateSidewaysLayoutCardsCheckBox; - QCheckBox cardScalingCheckBox; - QCheckBox roundCardCornersCheckBox; - QLabel verticalCardOverlapPercentLabel; - QSpinBox verticalCardOverlapPercentBox; - QLabel cardViewInitialRowsMaxLabel; - QSpinBox cardViewInitialRowsMaxBox; - QLabel cardViewExpandedRowsMaxLabel; - QSpinBox cardViewExpandedRowsMaxBox; - QCheckBox horizontalHandCheckBox; - QCheckBox leftJustifiedHandCheckBox; - QCheckBox invertVerticalCoordinateCheckBox; + QLabel homeTabButtonColorSourceLabel; + QComboBox homeTabButtonColorSourceBox; + QLabel playmatVisibilityLabel; QComboBox playmatVisibilityCombo; QLabel playmatModeLabel; QComboBox playmatModeCombo; QLabel playmatDefaultLabel; QPushButton playmatDefaultEditButton; + + QCheckBox styleUserListCheckBox; + + QCheckBox showShortcutsCheckBox; + QCheckBox showGameSelectorFilterToolbarCheckBox; + + 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; + QGroupBox *themeGroupBox; QGroupBox *homeTabGroupBox; + QGroupBox *playmatGroupBox; QGroupBox *stylingGroupBox; QGroupBox *menuGroupBox; QGroupBox *printingsGroupBox; QGroupBox *cardsGroupBox; QGroupBox *cardLayoutGroupBox; - QGroupBox *handGroupBox; - QGroupBox *playmatGroupBox; - QGroupBox *tableGroupBox; QGroupBox *cardCountersGroupBox; - QList cardCounterNames; - QSpinBox minPlayersForMultiColumnLayoutEdit; - QSpinBox maxFontSizeForCardsEdit; + QGroupBox *handGroupBox; + QGroupBox *tableGroupBox; 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 a293660f9..62b06fb60 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp @@ -425,29 +425,28 @@ void GeneralSettingsPage::updateStartupServerControlsVisibility() void GeneralSettingsPage::retranslateUi() { + const auto &settings = SettingsCache::instance(); + languageGroupBox->setTitle(tr("Language settings")); languageLabel.setText(tr("Language:")); - - 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:")); + + versionGroupBox->setTitle(tr("Version settings")); 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, @@ -456,8 +455,13 @@ void GeneralSettingsPage::retranslateUi() tr("Always update in the background")); cardUpdateCheckIntervalLabel.setText(tr("Check for card database updates every")); cardUpdateCheckIntervalSpinBox.setSuffix(tr(" days")); - 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")); + + 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")); showTipsOnStartup.setText(tr("Show tips on startup")); startupTabLabel.setText(tr("Startup tab:")); startupTabSelector.setItemText(StartupTab::StartupTabHome, tr("Home")); @@ -473,21 +477,18 @@ void GeneralSettingsPage::retranslateUi() startupServerLabel.setText(tr("Server:")); startupRoomLabel.setText(tr("Room:")); startupRoomNameEdit->setPlaceholderText(tr("Room name")); - 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())); + if (settings.getIsPortableBuild()) { + pathsGroupBox->setTitle(tr("Paths (editing disabled in portable mode)")); + } else { + pathsGroupBox->setTitle(tr("Paths")); } - updateReleaseChannelBox.setCurrentIndex(oldIndex); -} \ No newline at end of file + 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")); +} 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 fbe70a5a4..e0c1a47bf 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h @@ -20,6 +20,9 @@ public: GeneralSettingsPage(); void retranslateUi() override; + static QStringList findQmFiles(); + static QString languageName(const QString &lang); + private slots: void deckPathButtonClicked(); void filtersPathButtonClicked(); @@ -33,15 +36,43 @@ 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; @@ -51,33 +82,6 @@ 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 182e75aac..2c6e062da 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,26 +20,7 @@ enum visualDeckStoragePromptForConversionIndex UserInterfaceSettingsPage::UserInterfaceSettingsPage() { - // 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); - + // general settings doubleClickToPlayCheckBox.setChecked(SettingsCache::instance().userInterface().getDoubleClickToPlay()); connect(&doubleClickToPlayCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), &InterfaceSettings::setDoubleClickToPlay); @@ -103,6 +84,26 @@ 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); @@ -355,6 +356,7 @@ 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")); @@ -362,6 +364,7 @@ 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")); @@ -397,8 +400,8 @@ void UserInterfaceSettingsPage::retranslateUi() 0, CommanderBracketNames::CommanderSpellbookBracketNames); commanderSpellbookIntegrationBracketNamingSelector.setItemText( 1, CommanderBracketNames::OfficialCommanderBracketNames); - 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 0dc4cf4e8..e8a30fb1f 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,9 +23,6 @@ private slots: void updateCommanderSpellbookUiState(); private: - QCheckBox notificationsEnabledCheckBox; - QCheckBox specNotificationsEnabledCheckBox; - QCheckBox buddyConnectNotificationsEnabledCheckBox; QCheckBox doubleClickToPlayCheckBox; QCheckBox clickPlaysAllSelectedCheckBox; QCheckBox playToStackCheckBox; @@ -37,12 +34,18 @@ 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; @@ -57,8 +60,10 @@ 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 e1f255199..a3cda2bfc 100644 --- a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.h +++ b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.h @@ -8,6 +8,7 @@ #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 f96c139b3..ed0ddaf06 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -116,9 +116,10 @@ void CloseButton::paintEvent(QPaintEvent * /*event*/) } TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget *parent) - : 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) + : 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) { setElideMode(Qt::ElideRight); setMovable(true); @@ -245,6 +246,7 @@ 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; @@ -256,6 +258,7 @@ 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()); @@ -520,7 +523,9 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo) if (SettingsCache::instance().tabs().getTabModerationOpen()) { openTabModeration(); } - openTabCardArtRules(); + if (SettingsCache::instance().tabs().getTabCardArtRulesOpen()) { + openTabCardArtRules(); + } } retranslateUi(); @@ -582,6 +587,9 @@ void TabSupervisor::stop() if (tabModeration) { tabModeration->close(); } + if (tabCardArtRules) { + tabCardArtRules->close(); + } } QList tabsToDelete; @@ -775,6 +783,7 @@ void TabSupervisor::openTabAdmin() void TabSupervisor::actTabCardArtRules(bool checked) { + SettingsCache::instance().tabs().setTabCardArtRulesOpen(checked); if (checked && !tabCardArtRules) { openTabCardArtRules(); setCurrentWidget(tabCardArtRules); @@ -1091,7 +1100,8 @@ 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 (TabGame *tab : gameTabs) { + for (auto it = gameTabs.cbegin(); it != gameTabs.cend(); ++it) { + TabGame *tab = it.value(); 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 209a30642..0f43893d3 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,6 +4,7 @@ #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" @@ -84,6 +85,7 @@ 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); @@ -269,6 +271,19 @@ 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 21335d2d0..fb09578c4 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,6 +165,12 @@ 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 6cf096cd4..29bf2263e 100644 --- a/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp +++ b/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp @@ -1,5 +1,6 @@ #include "card_completer_delegate.h" +#include "../../pixel_map_generator.h" #include "../cards/additional_info/mana_cost_widget.h" #include @@ -84,7 +85,6 @@ QColor CardCompleterDelegate::accentForColors(const QString &colors) CardCompleterDelegate::CardCompleterDelegate(QObject *parent) : QStyledItemDelegate(parent) { - symbolCache.setMaxCost(64); setCodeCache.setMaxCost(64); } @@ -108,36 +108,16 @@ 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 = cachedSymbolPixmap(symbol, radius * 2); + const QPixmap px = ManaSymbolPixmapGenerator::generatePixmap(symbol, QSize(radius * 2, radius * 2)); - if (px && !px->isNull()) { - p->drawPixmap(pip, *px); + if (!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 18661a21b..78667f56b 100644 --- a/cockatrice/src/interface/widgets/utility/card_completer_delegate.h +++ b/cockatrice/src/interface/widgets/utility/card_completer_delegate.h @@ -31,9 +31,6 @@ 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; @@ -47,9 +44,6 @@ 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 0cdf60d5d..76bbf344b 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,6 +21,7 @@ #include #include #include +#include #include #include @@ -89,6 +90,19 @@ 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()); @@ -195,6 +209,11 @@ 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 6e4d87876..d161ce362 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,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -46,6 +47,12 @@ 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; @@ -106,6 +113,7 @@ 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 f1dcf113f..fd529ff69 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 "deck_preview_widget.h" +#include "../visual_deck_storage_widget.h" -#include +#include DeckPreviewColorIdentityFilterWidget::DeckPreviewColorIdentityFilterWidget(VisualDeckStorageWidget *parent) : QWidget(parent), layout(new QHBoxLayout(this)) @@ -32,10 +32,6 @@ 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(); @@ -45,19 +41,33 @@ void DeckPreviewColorIdentityFilterWidget::retranslateUi() { // Set the toggle button text based on the current mode switch (filterMode) { - case ExactMatch: + case VisualDeckStorageSortFilterProxyModel::FilterMode::ExactMatch: toggleButton->setText(tr("Mode: Exact Match")); break; - case Includes: + case VisualDeckStorageSortFilterProxyModel::FilterMode::Includes: toggleButton->setText(tr("Mode: Includes")); break; - case Excludes: + case VisualDeckStorageSortFilterProxyModel::FilterMode::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; @@ -68,88 +78,17 @@ void DeckPreviewColorIdentityFilterWidget::updateFilterMode() { // Cycle through the modes switch (filterMode) { - case ExactMatch: - filterMode = Includes; + case VisualDeckStorageSortFilterProxyModel::FilterMode::ExactMatch: + filterMode = VisualDeckStorageSortFilterProxyModel::FilterMode::Includes; break; - case Includes: - filterMode = Excludes; + case VisualDeckStorageSortFilterProxyModel::FilterMode::Includes: + filterMode = VisualDeckStorageSortFilterProxyModel::FilterMode::Excludes; break; - case Excludes: - filterMode = ExactMatch; + case VisualDeckStorageSortFilterProxyModel::FilterMode::Excludes: + filterMode = VisualDeckStorageSortFilterProxyModel::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 8e60b16fb..def45de66 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_widget.h" +#include "../visual_deck_storage_sort_filter_proxy_model.h" #include +#include #include +#include #include -class DeckPreviewWidget; class VisualDeckStorageWidget; class DeckPreviewColorIdentityFilterWidget : public QWidget @@ -21,25 +21,34 @@ 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(); - void filterWidgets(QList widgets); + + /** + * @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; signals: - void filterModeChanged(FilterMode mode); + /** + * Emitted when the set of active colors changed due to user interaction. + */ 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(); @@ -48,7 +57,7 @@ private: QHBoxLayout *layout; QPushButton *toggleButton; QMap activeColors; - FilterMode filterMode = Includes; // Default to "includes" mode + VisualDeckStorageSortFilterProxyModel::FilterMode filterMode = VisualDeckStorageSortFilterProxyModel::Includes; }; #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 b45f61be7..41d87ccb1 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,19 +1,15 @@ #include "deck_preview_deck_tags_display_widget.h" #include "../../../../client/settings/cache_settings.h" -#include "../../../../interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h" -#include "../../../../interface/widgets/tabs/tab_deck_editor.h" +#include "../../../deck_loader/deck_loader.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) @@ -54,6 +50,16 @@ 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 */ @@ -92,93 +98,13 @@ static QStringList findAllKnownTags() void DeckPreviewDeckTagsDisplayWidget::openTagEditDlg() { - 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; + // The deck editor path has no conversion prompt; the VDS path registers one. + if (conversionPromptHandler && !conversionPromptHandler()) { + return; } - // 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; + const QStringList knownTags = knownTagsProvider ? knownTagsProvider() : findAllKnownTags(); + execTagDialog(knownTags); } void DeckPreviewDeckTagsDisplayWidget::execTagDialog(const QStringList &knownTags) @@ -191,4 +117,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 4bd7915cd..64bd5aa1a 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,41 +2,53 @@ * @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 "../../../deck_loader/deck_loader.h" -#include "deck_preview_widget.h" - +#include #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 ffe954308..876fbf6ad 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,47 +1,67 @@ #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), filePath(_filePath), - colorIdentityWidget(nullptr), deckTagsDisplayWidget(nullptr) + : QWidget(_parent), visualDeckStorageWidget(_visualDeckStorageWidget), model(_model), filePath(_filePath) { layout = new QVBoxLayout(this); setLayout(layout); - 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 = + auto *pictureWidget = new DeckPreviewCardPictureWidget(this, false, visualDeckStorageWidget->deckPreviewSelectionAnimationEnabled); - - connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageClicked, this, - &DeckPreviewWidget::imageClickedEvent); - connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageDoubleClicked, this, + pictureWidget->setFontSize(24); + connect(pictureWidget, &DeckPreviewCardPictureWidget::imageClicked, this, &DeckPreviewWidget::imageClickedEvent); + connect(pictureWidget, &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, @@ -56,6 +76,29 @@ 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() @@ -69,9 +112,15 @@ void DeckPreviewWidget::resizeEvent(QResizeEvent *event) if (bannerCardDisplayWidget == nullptr) { return; } - QList widgets = findChildren(); - for (QWidget *widget : widgets) { - widget->setMaximumWidth(bannerCardDisplayWidget->width()); + + const int width = bannerCardDisplayWidget->width(); + if (width == lastKnownBannerWidth) { + return; + } + lastKnownBannerWidth = width; + + for (QWidget *widget : fixedWidthChildren) { + widget->setMaximumWidth(width); } } @@ -79,83 +128,28 @@ void DeckPreviewWidget::enterEvent(QEnterEvent *event) { QWidget::enterEvent(event); - // don't do reloads until widgets have been created - if (bannerCardComboBox != nullptr) { - reloadIfModified(); + // 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 {}; } -} - -/** - * @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()); + return model->dataForRow(r).displayName; } /** @@ -163,33 +157,36 @@ void DeckPreviewWidget::resyncWidgets() */ void DeckPreviewWidget::reloadIfModified() { - QFileInfo fileInfo(filePath); - QDateTime newLastModifiedTime = fileInfo.lastModified(); - - if (!newLastModifiedTime.isValid() || newLastModifiedTime <= lastModifiedTime) { + const int r = row(); + if (r == -1 || !model->dataForRow(r).loadSucceeded) { return; } - bool success = deckLoader->reload(); - - if (success) { - fileInfo.refresh(); - lastModifiedTime = fileInfo.lastModified(); - resyncWidgets(); - } + model->reloadIfModified(r); } -void DeckPreviewWidget::updateVisibility() +/** + * @brief Syncs the contents of the child widgets with the current row's data. + */ +void DeckPreviewWidget::syncFromModel() { - setHidden(!checkVisibility()); -} - -bool DeckPreviewWidget::checkVisibility() const -{ - if (filteredBySearch || filteredByColor || filteredByTags) { - return false; + const int r = row(); + if (r == -1) { + return; } - return true; + + 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); } void DeckPreviewWidget::updateColorIdentityVisibility(bool visible) @@ -229,51 +226,6 @@ 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. @@ -310,11 +262,15 @@ void DeckPreviewWidget::updateBannerCardComboBox(const QString ¤tText) // Prepare the new items with deduplication QSet> bannerCardSet; - QList cardsInDeck = deckLoader->getDeck().deckList.getCardNodes(); + const int r = row(); + if (r != -1) { + const DeckList &deckList = model->dataForRow(r).deck.deckList; + const QList cardsInDeck = 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())); + } } } @@ -327,16 +283,16 @@ void DeckPreviewWidget::updateBannerCardComboBox(const QString ¤tText) // This is *slightly* more performant than using addItem in a loop. - QStandardItemModel *model = new QStandardItemModel(pairList.size(), 1, bannerCardComboBox); + QStandardItemModel *comboModel = 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); - model->setItem(row++, 0, item); + comboModel->setItem(row++, 0, item); } - bannerCardComboBox->setModel(model); + bannerCardComboBox->setModel(comboModel); // Try to restore the previous selection by finding the currentText int restoredIndex = bannerCardComboBox->findText(currentText); @@ -344,7 +300,9 @@ void DeckPreviewWidget::updateBannerCardComboBox(const QString ¤tText) bannerCardComboBox->setCurrentIndex(restoredIndex); } else { // Add a placeholder "-" and set it as the current selection - int bannerIndex = bannerCardComboBox->findText(deckLoader->getDeck().deckList.getBannerCard().name); + const QString currentBannerCardName = + r == -1 ? QString() : model->dataForRow(r).deck.deckList.getBannerCard().name; + int bannerIndex = bannerCardComboBox->findText(currentBannerCardName); if (bannerIndex != -1) { bannerCardComboBox->setCurrentIndex(bannerIndex); } else { @@ -362,8 +320,11 @@ void DeckPreviewWidget::setBannerCard(int /* changedIndex */) { auto [name, id] = bannerCardComboBox->currentData().value>(); CardRef cardRef = {name, id}; - deckLoader->getDeck().deckList.setBannerCard(cardRef); - writeDeckToFile(); + const int r = row(); + if (r == -1) { + return; + } + model->setBannerCard(r, cardRef); bannerCardDisplayWidget->setCard(CardDatabaseManager::query()->getCard(cardRef)); } @@ -385,17 +346,24 @@ void DeckPreviewWidget::imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewC void DeckPreviewWidget::setTags(const QStringList &tags) { - deckLoader->getDeck().deckList.setTags(tags); - writeDeckToFile(); + const int r = row(); + if (r != -1) { + model->setTags(r, tags); + } } 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] { emit openDeckEditor(deckLoader->getDeck()); }); + 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("Edit Tags")), &QAction::triggered, deckTagsDisplayWidget, &DeckPreviewDeckTagsDisplayWidget::openTagEditDlg); @@ -408,14 +376,26 @@ QMenu *DeckPreviewWidget::createRightClickMenu() auto saveToClipboardMenu = menu->addMenu(tr("Save Deck to Clipboard")); - 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); }); + 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); + } + }); menu->addSeparator(); @@ -450,57 +430,58 @@ void DeckPreviewWidget::addSetBannerCardMenu(QMenu *menu) void DeckPreviewWidget::actRenameDeck() { + const int r = row(); + if (r == -1) { + return; + } + // read input - const QString oldName = deckLoader->getDeck().deckList.getName(); + const QString oldName = model->dataForRow(r).deckName; bool ok; - QString newName = QInputDialog::getText(this, "Rename deck", tr("New name:"), QLineEdit::Normal, oldName, &ok); + QString newName = QInputDialog::getText(this, tr("Rename deck"), tr("New name:"), QLineEdit::Normal, oldName, &ok); if (!ok || oldName == newName) { return; } // write change - deckLoader->getDeck().deckList.setName(newName); - writeDeckToFile(); + model->renameDeck(r, newName); - // update VDS - refreshBannerCardText(); + // The banner card text updates via the model's dataChanged signal. } 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, "Rename file", tr("New name:"), QLineEdit::Normal, oldName, &ok); + QString newName = QInputDialog::getText(this, tr("Rename file"), tr("New name:"), QLineEdit::Normal, oldName, &ok); if (!ok || newName.isEmpty() || oldName == newName) { return; } - QString newFileName = newName; - if (!info.suffix().isEmpty()) { - newFileName += "." + info.suffix(); - } - // write change - const QString newFilePath = QFileInfo(info.dir(), newFileName).filePath(); - if (!QFile::rename(info.filePath(), newFilePath)) { + if (!model->renameFile(r, newName)) { QMessageBox::critical(this, tr("Error"), tr("Rename failed")); - return; } - deckLoader->getDeck().lastLoadInfo.fileName = newFilePath; - setFilePath(newFilePath); - - // update VDS - updateLastModifiedTime(); - refreshBannerCardText(); + // The file path and banner card text update via the model's signals. } 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); @@ -509,11 +490,22 @@ void DeckPreviewWidget::actDeleteFile() } // write change - if (!QFile::remove(QFileInfo(filePath).filePath())) { + if (!model->deleteFile(r)) { QMessageBox::critical(this, tr("Error"), tr("Delete failed")); - return; } - // update VDS - this->deleteLater(); + // 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; + }); } 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 de66c194b..7bb69f9b9 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,16 +2,12 @@ * @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_widget.h" -#include "deck_preview_deck_tags_display_widget.h" +#include "../visual_deck_storage_model.h" #include #include @@ -20,72 +16,82 @@ #include #include +class QEnterEvent; +class QLabel; class QMenu; -class VisualDeckStorageWidget; +class QMouseEvent; +class ColorIdentityWidget; +class DeckPreviewCardPictureWidget; class DeckPreviewDeckTagsDisplayWidget; +class VisualDeckStorageModel; +class VisualDeckStorageWidget; 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; - 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; + /** + * @brief The banner card picture; the parent widget wires its size to the card size setting. + */ + DeckPreviewCardPictureWidget *bannerCardDisplayWidget; signals: void deckLoadRequested(const QString &filePath); void openDeckEditor(const LoadedDeck &deck); public slots: - 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(); + /** + * @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 updateVisibility(); + void refreshBannerCardToolTip(); void updateColorIdentityVisibility(bool visible); void updateBannerCardComboBoxVisibility(bool visible); void updateTagsVisibility(bool visible); - void resizeEvent(QResizeEvent *event) override; + void setBannerCard(int); + void setTags(const QStringList &tags); protected: void enterEvent(QEnterEvent *event) override; + void resizeEvent(QResizeEvent *event) override; private: - void updateLastModifiedTime(); - void writeDeckToFile(); + [[nodiscard]] int row() const; + [[nodiscard]] QString getDisplayName() const; + void refreshBannerCardText(); + void updateBannerCardComboBox(const QString ¤tText); + bool promptFileConversionIfRequired(); QMenu *createRightClickMenu(); void addSetBannerCardMenu(QMenu *menu); - -private slots: - void setTags(const QStringList &tags); + void imageClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); + void imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); 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 cc7f07871..22d73b604 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,20 +1,27 @@ #include "visual_deck_storage_folder_display_widget.h" -#include "../../../client/settings/cache_settings.h" +#include "../cards/card_info_picture_widget.h" +#include "../general/display/banner_widget.h" +#include "../general/layout_containers/flow_widget.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, - QString _filePath, + const QString &_folderPath, bool canBeHidden, bool _showFolders) - : QWidget(parent), showFolders(_showFolders), visualDeckStorageWidget(_visualDeckStorageWidget), filePath(_filePath) + : QWidget(parent), showFolders(_showFolders), folderPath(_folderPath), + visualDeckStorageWidget(_visualDeckStorageWidget) { layout = new QVBoxLayout(this); setLayout(layout); @@ -22,6 +29,9 @@ 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); @@ -35,192 +45,283 @@ VisualDeckStorageFolderDisplayWidget::VisualDeckStorageFolderDisplayWidget( flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAlwaysOff); containerLayout->addWidget(flowWidget); - createWidgetsForFiles(); - createWidgetsForFolders(); + 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); - refreshUi(); + 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(); } -void VisualDeckStorageFolderDisplayWidget::refreshUi() +void VisualDeckStorageFolderDisplayWidget::scheduleReconcile() { - 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; + 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; } - header->setText(bannerText); + reconcileTimer->start(); } /** - * Gets all files in the directory that have an accepted decklist file extension - * - * @param filePath The directory to search through - * @param recursive Whether to search through subdirectories + * @brief Starts a new chunked scan of the source model, yielding to the event loop between chunks. */ -static QStringList getAllFiles(const QString &filePath, bool recursive) +void VisualDeckStorageFolderDisplayWidget::reconcile() { - 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; + beginDeckPass(); } -void VisualDeckStorageFolderDisplayWidget::createWidgetsForFiles() +void VisualDeckStorageFolderDisplayWidget::beginDeckPass() { - QList allDecks; - for (const QString &file : getAllFiles(filePath, !showFolders)) { - auto *display = new DeckPreviewWidget(flowWidget, visualDeckStorageWidget, file); + deckPassActive = true; + deckPassRestartRequested = false; + deckPassRow = 0; + visibleDeckCount = 0; + deckPassPresentPaths.clear(); - 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); + continueDeckPass(); +} + +void VisualDeckStorageFolderDisplayWidget::continueDeckPass() +{ + if (!deckPassActive) { + return; } - flowWidget->clearLayout(); // Clear existing widgets in the flow layout + QElapsedTimer passTimer; + passTimer.start(); - for (DeckPreviewWidget *deck : allDecks) { - flowWidget->addWidget(deck); + 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(); } /** - * Updates the visibility of this folder and all its DeckPreviewWidgets + * @brief Creates a deck preview widget and wires it up to the storage widget. * - * @param recursive Also update the visibility of all subfolders and their DeckPreviewWidgets. + * @param filePath The absolute path of the deck file to preview. */ -void VisualDeckStorageFolderDisplayWidget::updateVisibility(bool recursive) +DeckPreviewWidget *VisualDeckStorageFolderDisplayWidget::createDeckPreviewWidget(const QString &filePath) { - 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); - } + 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; } -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() +/** + * @brief Creates, removes and keeps in sync the subfolder widgets of this folder. + * + * Only direct child folders are created here; each child manages its own + * children, mirroring the folder tree on disk. + */ +void VisualDeckStorageFolderDisplayWidget::createSubFolderWidgets() { if (!showFolders) { return; } - for (const QString &dir : getAllSubFolders(filePath)) { - auto *display = new VisualDeckStorageFolderDisplayWidget(this, visualDeckStorageWidget, dir, true, showFolders); - containerLayout->addWidget(display); + 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); } } void VisualDeckStorageFolderDisplayWidget::updateShowFolders(bool enabled) { showFolders = enabled; + header->setHidden(!showFolders); if (!showFolders) { - flattenFolderStructure(); - } else { - // if setting was switched from disabled to enabled, we assume that there aren't any existing subfolders - createWidgetsForFiles(); - createWidgetsForFolders(); + for (auto it = subFolderWidgets.begin(); it != subFolderWidgets.end(); ++it) { + containerLayout->removeWidget(it.value()); + it.value()->deleteLater(); + } + subFolderWidgets.clear(); } - header->setHidden(!showFolders); + scheduleReconcile(); } /** - * Steals all DeckPreviewWidgets from this widget's nested subfolders, and deletes those subfolders + * @brief Hides the folder when it contains nothing visible, and reports the change upward. */ -void VisualDeckStorageFolderDisplayWidget::flattenFolderStructure() +void VisualDeckStorageFolderDisplayWidget::refreshVisibility() { - for (auto *subFolder : findChildren()) { - // steal all DeckPreviewWidgets from the subfolder - for (auto *deck : subFolder->getFlowWidget()->findChildren()) { - flowWidget->addWidget(deck); - } - - // delete the subfolder - subFolder->deleteLater(); + const bool shouldBeVisible = hasContent(); + if (isHidden() == !shouldBeVisible) { + return; } + setHidden(!shouldBeVisible); + emit contentVisibilityChanged(); } -QStringList VisualDeckStorageFolderDisplayWidget::gatherAllTagsFromFlowWidget() const +/** + * @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 allTags; + if (!initialPassCompleted || visibleDeckCount > 0) { + 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); + for (VisualDeckStorageFolderDisplayWidget *subFolderWidget : subFolderWidgets) { + if (subFolderWidget->hasContent()) { + return true; } } - // Remove duplicates by calling 'removeDuplicates' - allTags.removeDuplicates(); + return false; +} - return allTags; -} \ No newline at end of file +/** + * @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; +} 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 a5e3be212..257ce1778 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,49 +1,105 @@ /** * @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 "../general/display/banner_widget.h" -#include "../general/layout_containers/flow_widget.h" +#include +#include +#include +#include +class BannerWidget; +class DeckPreviewWidget; +class FlowWidget; +class QTimer; +class QVBoxLayout; class VisualDeckStorageWidget; + class VisualDeckStorageFolderDisplayWidget : public QWidget { Q_OBJECT public: VisualDeckStorageFolderDisplayWidget(QWidget *parent, - VisualDeckStorageWidget *_visualDeckStorageWidget, - QString _filePath, + VisualDeckStorageWidget *visualDeckStorageWidget, + const QString &folderPath, bool canBeHidden, - bool _showFolders); - void refreshUi(); - void createWidgetsForFiles(); - void createWidgetsForFolders(); - void flattenFolderStructure(); - [[nodiscard]] QStringList gatherAllTagsFromFlowWidget() const; - [[nodiscard]] FlowWidget *getFlowWidget() const - { - return flowWidget; - } + bool showFolders); public slots: - void updateVisibility(bool recursive = true); - bool checkVisibility(); + /** + * @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 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 bf2c49604..5d0006539 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,6 +4,7 @@ #include #include +#include #include #include #include @@ -32,8 +33,18 @@ 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. @@ -108,6 +119,8 @@ DeckScanResult scanDeckDirectory(const QString &deckPath) } } // namespace +static QString computeColorIdentity(const LoadedDeck &deck); + VisualDeckStorageModel::VisualDeckStorageModel(QObject *parent) : QAbstractListModel(parent) { } @@ -182,12 +195,22 @@ const LoadedDeck &VisualDeckStorageModel::deckForRow(int row) const int VisualDeckStorageModel::rowForFilePath(const QString &filePath) const { - for (int i = 0; i < decks.size(); ++i) { - if (decks.at(i).filePath == filePath) { - return i; + 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); } } - return -1; } void VisualDeckStorageModel::startScan() @@ -195,7 +218,9 @@ void VisualDeckStorageModel::startScan() ++scanGeneration; beginResetModel(); decks.clear(); + rowByFilePath.clear(); folderPaths.clear(); + pendingLoads.clear(); endResetModel(); if (deckPath.isEmpty()) { @@ -224,6 +249,7 @@ void VisualDeckStorageModel::startScan() beginInsertRows(QModelIndex(), 0, result.decks.size() - 1); decks = result.decks; + reindexFilePaths(); endInsertRows(); for (int row = 0; row < decks.size(); ++row) { @@ -257,26 +283,20 @@ void VisualDeckStorageModel::beginLoad(int row) return; // The deck list was re-scanned while this load was running; drop the stale result. } - const int row = rowForFilePath(filePath); - if (row == -1) { - return; + // 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); } - - 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); + pendingLoads.append(std::move(pending)); + schedulePendingLoadDrain(); }); watcher->setFuture(QtConcurrent::run([filePath, fmt]() -> std::optional { @@ -284,10 +304,69 @@ void VisualDeckStorageModel::beginLoad(int row) if (!deck) { return std::nullopt; } - return DeckLoadResult{*deck, QFileInfo(filePath).lastModified()}; + // 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}; })); } +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. */ @@ -325,7 +404,7 @@ static QString computeColorIdentity(const LoadedDeck &deck) /** * @brief Recomputes all derived metadata of a row from its loaded deck. */ -void VisualDeckStorageModel::recomputeDeckMetadata(DeckPreviewData &data) +void VisualDeckStorageModel::recomputeDeckMetadata(DeckPreviewData &data, bool recomputeColorIdentity) { const DeckList &deckList = data.deck.deckList; @@ -334,7 +413,9 @@ void VisualDeckStorageModel::recomputeDeckMetadata(DeckPreviewData &data) data.tags = deckList.getTags(); data.lastLoaded = QDateTime::fromString(deckList.getLastLoadedTimestamp()); data.bannerCard = deckList.getBannerCard(); - data.colorIdentity = computeColorIdentity(data.deck); + if (recomputeColorIdentity) { + data.colorIdentity = computeColorIdentity(data.deck); + } } void VisualDeckStorageModel::setFilePathForRow(int row, const QString &newFilePath) @@ -344,9 +425,13 @@ 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) @@ -410,7 +495,14 @@ 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 a44e7412d..330205356 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,6 +17,7 @@ #include #include +#include #include #include #include @@ -38,7 +39,14 @@ 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. */ + 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 }; } // namespace VisualDeckStorageRoles @@ -65,11 +73,25 @@ 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. + * VisualDeckStorageSortFilterProxyModel on top of this model. The proxy keeps + * every row and exposes each row's filter result through its FilterMatchRole. */ class VisualDeckStorageModel : public QAbstractListModel { @@ -144,13 +166,23 @@ signals: private: void startScan(); void beginLoad(int row); - static void recomputeDeckMetadata(DeckPreviewData &data); + static void recomputeDeckMetadata(DeckPreviewData &data, bool recomputeColorIdentity = true); + void reindexFilePaths(); + void schedulePendingLoadDrain(); + +private slots: + void drainPendingLoads(); + +private: void setFilePathForRow(int row, const QString &newFilePath); QString deckPath; QList decks; - QStringList folderPaths; ///< All subdirectories of the deck folder, sorted. - int scanGeneration = 0; ///< Bumped on every scan so stale results are ignored. + 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. }; #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 cce3ff6ce..478431703 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,6 +1,7 @@ #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 0580126c4..baa5e5792 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,23 +1,20 @@ #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 PrintingSelectorCardSearchWidget for searching cards by set name or set code. + * @brief Constructs a search bar for filtering decks in the Visual Deck Storage. * - * 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. + * 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. * - * @param parent The parent PrintingSelector widget that will handle the search results. + * @param parent The parent widget. */ -VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(VisualDeckStorageWidget *parent) : parent(parent) +VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(QWidget *parent) : QWidget(parent) { layout = new QHBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); @@ -40,47 +37,5 @@ VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(VisualDeckStorageWi searchDebounceTimer->start(300); // 300ms debounce }); - 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); - } + connect(searchDebounceTimer, &QTimer::timeout, this, [this] { emit searchTextChanged(searchBar->text()); }); } 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 2f3d81aeb..7769ea911 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,30 +2,32 @@ * @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 VisualDeckStorageWidget; +class QTimer; + class VisualDeckStorageSearchWidget : public QWidget { Q_OBJECT public: - explicit VisualDeckStorageSearchWidget(VisualDeckStorageWidget *parent); - QString getSearchText(); - void filterWidgets(QList widgets, const QString &searchText); + 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); 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 8968f6cb3..c05da1cb3 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,12 +104,21 @@ void VisualDeckStorageSortFilterProxyModel::resort() sort(0); } -bool VisualDeckStorageSortFilterProxyModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const +QVariant VisualDeckStorageSortFilterProxyModel::data(const QModelIndex &index, int role) const { - if (sourceParent.isValid()) { - return true; + if (role == VisualDeckStorageRoles::FilterMatchRole) { + if (!index.isValid()) { + return true; + } + const QModelIndex sourceIndex = mapToSource(index); + return rowMatches(sourceIndex.row()); } + 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()) { @@ -119,6 +128,14 @@ bool VisualDeckStorageSortFilterProxyModel::filterAcceptsRow(int sourceRow, cons 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 d2842a02f..7e771f6a9 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,6 +6,10 @@ * 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 @@ -47,6 +51,8 @@ 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) @@ -77,6 +83,7 @@ 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 e4eedb078..6289a4941 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,20 +1,11 @@ #include "visual_deck_storage_sort_widget.h" #include "../../../client/settings/cache_settings.h" +#include "visual_deck_storage_widget.h" -#include #include -/** - * @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) +VisualDeckStorageSortWidget::VisualDeckStorageSortWidget(VisualDeckStorageWidget *parent) : QWidget(parent) { layout = new QHBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); @@ -30,12 +21,10 @@ VisualDeckStorageSortWidget::VisualDeckStorageSortWidget(VisualDeckStorageWidget // Set the current sort order sortComboBox->setCurrentIndex(SettingsCache::instance().visualDeckStorage().getVisualDeckStorageSortingOrder()); - sortOrder = static_cast(sortComboBox->currentIndex()); - // Connect sorting change signal to refresh the file list + // Connect sorting change signal to persist the order and refresh the file list connect(sortComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, &VisualDeckStorageSortWidget::updateSortOrder); - connect(this, &VisualDeckStorageSortWidget::sortOrderChanged, parent, &VisualDeckStorageWidget::updateSortOrder); } void VisualDeckStorageSortWidget::retranslateUi() @@ -47,10 +36,13 @@ void VisualDeckStorageSortWidget::retranslateUi() // Clear and repopulate the ComboBox with translated items sortComboBox->clear(); - 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); + 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); // Restore the current index sortComboBox->setCurrentIndex(oldIndex); @@ -59,60 +51,13 @@ 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 24eddba33..633924d84 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,19 +2,17 @@ * @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_widget.h" +#include "visual_deck_storage_sort_filter_proxy_model.h" #include #include #include class VisualDeckStorageWidget; -class VisualDeckStorageFolderDisplayWidget; class VisualDeckStorageSortWidget : public QWidget { Q_OBJECT @@ -22,25 +20,23 @@ class VisualDeckStorageSortWidget : public QWidget public: explicit VisualDeckStorageSortWidget(VisualDeckStorageWidget *parent); void retranslateUi(); - void updateSortOrder(); - void sortFolder(VisualDeckStorageFolderDisplayWidget *folderWidget); - QString getSearchText(); - QList filterFiles(QList widgets); + + /** + * @brief The currently selected sort order. + */ + [[nodiscard]] VisualDeckStorageSortFilterProxyModel::SortOrder currentSortOrder() const; 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 c4c8d18a8..ba52cf8e9 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,6 +2,8 @@ #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 @@ -18,7 +20,7 @@ VisualDeckStorageTagFilterWidget::VisualDeckStorageTagFilterWidget(VisualDeckSto setFixedHeight(100); - auto *flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); + flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); layout->addWidget(flowWidget); } @@ -29,45 +31,26 @@ void VisualDeckStorageTagFilterWidget::showEvent(QShowEvent *event) refreshTags(); } -void VisualDeckStorageTagFilterWidget::filterDecksBySelectedTags(const QList &deckPreviews) const +/** + * @brief The tags of all decks currently accepted by the proxy model. + */ +QSet VisualDeckStorageTagFilterWidget::gatherAllTags() const { - QStringList selectedTags; - QStringList excludedTags; + QSet allTags; + auto *proxy = parent->proxyModel(); - // 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; + 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); } } - // 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); - } + return allTags; } void VisualDeckStorageTagFilterWidget::refreshTags() @@ -80,8 +63,6 @@ void VisualDeckStorageTagFilterWidget::refreshTags() void VisualDeckStorageTagFilterWidget::removeTagsNotInList(const QSet &tags) { - auto *flowWidget = findChild(); - for (DeckPreviewTagDisplayWidget *tagWidget : findChildren()) { const QString &tagName = tagWidget->getTagName(); @@ -116,20 +97,12 @@ 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(); @@ -147,19 +120,26 @@ void VisualDeckStorageTagFilterWidget::sortTags() } } -QSet VisualDeckStorageTagFilterWidget::gatherAllTags() const +QStringList VisualDeckStorageTagFilterWidget::selectedTags() const { - QSet allTags; - QList deckWidgets = parent->findChildren(); - - for (DeckPreviewWidget *widget : deckWidgets) { - if (widget->checkVisibility()) { - for (const QString &tag : widget->deckLoader->getDeck().deckList.getTags()) { - allTags.insert(tag); - } + QStringList selected; + for (DeckPreviewTagDisplayWidget *tagWidget : findChildren()) { + if (tagWidget->getState() == TagState::Selected) { + selected.append(tagWidget->getTagName()); } } - return allTags; + return selected; +} + +QStringList VisualDeckStorageTagFilterWidget::excludedTags() const +{ + QStringList excluded; + for (DeckPreviewTagDisplayWidget *tagWidget : findChildren()) { + if (tagWidget->getState() == TagState::Excluded) { + excluded.append(tagWidget->getTagName()); + } + } + return excluded; } 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 3290c9e9a..337c053c7 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,21 +2,22 @@ * @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 "deck_preview/deck_preview_widget.h" - +#include +#include #include +class FlowWidget; class VisualDeckStorageWidget; class VisualDeckStorageTagFilterWidget : public QWidget { Q_OBJECT VisualDeckStorageWidget *parent; + FlowWidget *flowWidget; [[nodiscard]] QSet gatherAllTags() const; void removeTagsNotInList(const QSet &tags); @@ -27,9 +28,21 @@ class VisualDeckStorageTagFilterWidget : public QWidget public: explicit VisualDeckStorageTagFilterWidget(VisualDeckStorageWidget *_parent); [[nodiscard]] QStringList getAllKnownTags() const; - void filterDecksBySelectedTags(const QList &deckPreviews) const; + + /** + * @brief The tags currently in "selected" state. + */ + [[nodiscard]] QStringList selectedTags() const; + + /** + * @brief The tags currently in "excluded" state. + */ + [[nodiscard]] QStringList excludedTags() 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 4b4dee55b..acb0dcab2 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,24 +2,29 @@ #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), folderWidget(nullptr) +VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(parent) { - deckListModel = new DeckListModel(this); - deckListModel->setObjectName("visualDeckModel"); + // 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); layout = new QVBoxLayout(this); layout->setSpacing(0); @@ -75,6 +80,33 @@ 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); @@ -123,6 +155,8 @@ void VisualDeckStorageWidget::retranslateUi() refreshButton->setToolTip(tr("Refresh loaded files")); quickSettingsWidget->setToolTip(tr("Visual Deck Storage Settings")); + + sortWidget->retranslateUi(); } /** @@ -134,72 +168,69 @@ const VisualDeckStorageQuickSettingsWidget *VisualDeckStorageWidget::settings() } /** - * Reapplies all sort and filter options by calling the appropriate update methods. + * Reapplies all sort and filter options by updating the proxy model. */ void VisualDeckStorageWidget::reapplySortAndFilters() { - updateSortOrder(); - updateTagFilter(); - updateColorFilter(); - updateSearchFilter(); + storageProxyModel->setSortOrder(sortWidget->currentSortOrder()); + storageProxyModel->reapplyFilters(); } +/** + * @brief Scans the deck folder and rebuilds the folder tree of deck previews. + */ void VisualDeckStorageWidget::createRootFolderWidget() { - folderWidget = new VisualDeckStorageFolderDisplayWidget(this, this, SettingsCache::instance().paths().getDeckPath(), - false, quickSettingsWidget->getShowFolders()); + storageModel->setDeckPath(SettingsCache::instance().paths().getDeckPath()); + + folderWidget = + new VisualDeckStorageFolderDisplayWidget(this, this, QString(), false, quickSettingsWidget->getShowFolders()); scrollArea->setWidget(folderWidget); // this automatically destroys the old folderWidget scrollArea->widget()->setMaximumWidth(scrollArea->viewport()->width()); scrollArea->widget()->adjustSize(); - /* 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); + // Sort and filter runs against the model data, so it is safe to apply immediately. + reapplySortAndFilters(); } void VisualDeckStorageWidget::updateShowFolders(bool enabled) { if (folderWidget) { folderWidget->updateShowFolders(enabled); - QTimer::singleShot(0, this, &VisualDeckStorageWidget::reapplySortAndFilters); } } void VisualDeckStorageWidget::updateSortOrder() { - if (folderWidget) { - sortWidget->sortFolder(folderWidget); - for (VisualDeckStorageFolderDisplayWidget *subFolderWidget : - folderWidget->findChildren()) { - sortWidget->sortFolder(subFolderWidget); - } - } + storageProxyModel->setSortOrder(sortWidget->currentSortOrder()); } void VisualDeckStorageWidget::updateTagFilter() { - if (folderWidget) { - tagFilterWidget->filterDecksBySelectedTags(folderWidget->findChildren()); - folderWidget->updateVisibility(); - } + 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(); } +/** + * Pushes the color identity filter widget's state into the proxy model. + */ void VisualDeckStorageWidget::updateColorFilter() { - if (folderWidget) { - deckPreviewColorIdentityFilterWidget->filterWidgets(folderWidget->findChildren()); - folderWidget->updateVisibility(); - } + storageProxyModel->setColorFilter(deckPreviewColorIdentityFilterWidget->getFilterMode(), + deckPreviewColorIdentityFilterWidget->getActiveColors()); } -void VisualDeckStorageWidget::updateSearchFilter() +/** + * Pushes the search bar's text into the proxy model. + */ +void VisualDeckStorageWidget::updateSearchFilter(const QString &text) { - if (folderWidget) { - searchWidget->filterWidgets(folderWidget->findChildren(), searchWidget->getSearchText()); - folderWidget->updateVisibility(); - } + storageProxyModel->setSearchText(text); } void VisualDeckStorageWidget::updateTagsVisibility(const bool visible) @@ -215,4 +246,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 c3c0ae91b..fe6389414 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,29 +2,30 @@ * @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 "../../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 "visual_deck_storage_model.h" +#include "visual_deck_storage_sort_filter_proxy_model.h" -#include +#include +#include +#include +#include +#include -class QSpinBox; +class QLabel; +class QResizeEvent; +class QShowEvent; +class QTimer; +class DeckPreviewColorIdentityFilterWidget; +class VisualDeckStorageFolderDisplayWidget; +class VisualDeckStorageQuickSettingsWidget; class VisualDeckStorageSearchWidget; class VisualDeckStorageSortWidget; class VisualDeckStorageTagFilterWidget; -class VisualDeckStorageFolderDisplayWidget; -class DeckPreviewColorIdentityFilterWidget; + class VisualDeckStorageWidget final : public QWidget { Q_OBJECT @@ -37,29 +38,43 @@ public: bool deckPreviewSelectionAnimationEnabled; [[nodiscard]] const VisualDeckStorageQuickSettingsWidget *settings() const; + [[nodiscard]] VisualDeckStorageModel *model() const + { + return storageModel; + } + [[nodiscard]] VisualDeckStorageSortFilterProxyModel *proxyModel() const + { + return storageProxyModel; + } public slots: - void createRootFolderWidget(); // Refresh the display of cards based on the current sorting option + /** + * @brief Starts scanning the deck folder and rebuilds the folder tree and previews. + */ + void createRootFolderWidget(); 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; -signals: - void bannerCardsRefreshed(); - void deckLoadRequested(const QString &filePath); - void openDeckEditor(const LoadedDeck &deck); +private: + void reapplySortAndFilters(); private: QVBoxLayout *layout; QWidget *searchAndSortContainer; QHBoxLayout *searchAndSortLayout; - DeckListModel *deckListModel; QLabel *databaseLoadIndicator; VisualDeckStorageSortWidget *sortWidget; VisualDeckStorageSearchWidget *searchWidget; @@ -67,9 +82,10 @@ private: QToolButton *refreshButton; VisualDeckStorageQuickSettingsWidget *quickSettingsWidget; QScrollArea *scrollArea; - VisualDeckStorageFolderDisplayWidget *folderWidget; - - void reapplySortAndFilters(); + VisualDeckStorageFolderDisplayWidget *folderWidget = nullptr; + VisualDeckStorageModel *storageModel = nullptr; + VisualDeckStorageSortFilterProxyModel *storageProxyModel = nullptr; + QTimer *refreshTimer = nullptr; ///< Coalesces the re-apply/refresh burst following a batch of deck loads. }; #endif // VISUAL_DECK_STORAGE_WIDGET_H diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 13c37473e..4567991c8 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -32,6 +32,7 @@ #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" @@ -350,6 +351,7 @@ 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")); @@ -411,6 +413,8 @@ 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); @@ -489,6 +493,8 @@ void MainWindow::createMenus() helpMenu->addAction(aStatusBar); helpMenu->addAction(aViewLog); helpMenu->addAction(aOpenSettingsFolder); + helpMenu->addSeparator(); + helpMenu->addAction(aFirstRunWizard); } MainWindow::MainWindow(QWidget *parent) @@ -585,9 +591,10 @@ 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); @@ -667,6 +674,21 @@ 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 @@ -1028,6 +1050,7 @@ 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; } @@ -1041,6 +1064,9 @@ void MainWindow::createCardUpdateProcess(bool background) void MainWindow::exitCardDatabaseUpdate() { + if (!cardUpdateProcess) { + return; + } cardUpdateProcess->deleteLater(); cardUpdateProcess = nullptr; statusBar()->clearMessage(); @@ -1078,14 +1104,17 @@ 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, QProcess::ExitStatus exitStatus) +void MainWindow::cardUpdateFinished(int exitCode, 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 baacd3096..920145552 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -64,6 +64,10 @@ 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(); @@ -125,6 +129,9 @@ private: void createTrayIcon(); int getNextCustomSetPrefix(QDir dataDir); + + void runFirstRunWizard(); + inline QString getCardUpdaterBinaryName() { return "oracle"; @@ -140,8 +147,8 @@ private: QAction *aConnect, *aDisconnect, *aRegister, *aForgotPassword, *aSinglePlayer, *aWatchReplay, *aFullScreen; QAction *aManageSets, *aEditTokens, *aOpenCustomFolder, *aOpenCustomsetsFolder, *aAddCustomSet, *aReloadCardDatabase; - QAction *aTips, *aUpdate, *aCheckCardUpdates, *aCheckCardUpdatesBackground, *aStatusBar, *aViewLog, - *aOpenSettingsFolder; + QAction *aTips, *aUpdate, *aCheckCardUpdates, *aCheckCardUpdatesBackground, *aFirstRunWizard, *aStatusBar, + *aViewLog, *aOpenSettingsFolder; TabSupervisor *tabSupervisor; IntentUrlParser *urlParser; diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index 814da9808..d8aa1cd08 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -53,6 +53,7 @@ #include #include #include +#include QTranslator *translator, *qtTranslator; RNG_Abstract *rng; @@ -292,7 +293,7 @@ int main(int argc, char *argv[]) } } - rng = new RNG_SFMT; + rng = new RNG_SFMT(CryptoUtil::randomUInt64()); themeManager = new ThemeManager; soundEngine = new SoundEngine; @@ -390,6 +391,7 @@ 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 d7e9d529d..468a28f8d 100644 --- a/doc/doxygen/extra-pages/user_documentation/index.md +++ b/doc/doxygen/extra-pages/user_documentation/index.md @@ -11,6 +11,11 @@ - @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 new file mode 100644 index 000000000..7e4196a50 --- /dev/null +++ b/doc/doxygen/extra-pages/user_documentation/troubleshooting/enabling_debug_logs.md @@ -0,0 +1,140 @@ +@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 new file mode 100644 index 000000000..78ba5586b --- /dev/null +++ b/doc/doxygen/extra-pages/user_documentation/troubleshooting/fixing_card_pictures.md @@ -0,0 +1,109 @@ +@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 3fa435be1..9e3a6069b 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 efe20595b..91fe1874b 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,6 +5,8 @@ #include #include +static constexpr int MAX_DECK_SIZE = 1e5; + DecklistNodeTree::DecklistNodeTree() : root(new InnerDecklistNode()) { } @@ -113,7 +115,7 @@ void DecklistNodeTree::readZoneElement(QXmlStreamReader *xml) { QString zoneName = xml->attributes().value("name").toString(); InnerDecklistNode *newZone = getZoneObjFromName(zoneName); - newZone->readElement(xml); + totalCards += newZone->readElement(xml, MAX_DECK_SIZE - totalCards); } DecklistCardNode *DecklistNodeTree::addCard(const QString &cardName, @@ -125,6 +127,8 @@ 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); @@ -145,7 +149,8 @@ bool DecklistNodeTree::deleteNode(AbstractDecklistNode *node, InnerDecklistNode if (index != -1) { delete rootNode->takeAt(index); - if (rootNode->empty()) { + // Empty custom zones are kept while empty board zones get pruned. + if (rootNode->empty() && rootNode->getParent() == root) { deleteNode(rootNode, rootNode->getParent()); } @@ -188,15 +193,157 @@ void DecklistNodeTree::forEachCard(const std::functionsize(); i++) { auto *node = dynamic_cast(root->at(i)); - if (node->getName() == zoneName) { + if (node && 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 eae20aa23..5d91cd233 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,6 +10,7 @@ class DecklistNodeTree { InnerDecklistNode *root; ///< Root of the deck tree (zones + cards). + int totalCards = 0; public: /** @brief Constructs an empty DecklistNodeTree. */ @@ -77,6 +78,62 @@ 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. * @@ -88,6 +145,8 @@ 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 705dfae4c..7200ede5f 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 } } -bool AbstractDecklistCardNode::readElement(QXmlStreamReader *xml) +int AbstractDecklistCardNode::readElement(QXmlStreamReader *xml, int /* limit */) { while (!xml->atEnd()) { xml->readNext(); if (xml->isEndElement() && xml->name().toString() == "card") { - return false; + return 0; } } - return true; + return 0; } 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 df903a168..52dd56529 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. */ - bool readElement(QXmlStreamReader *xml) override; + int readElement(QXmlStreamReader *xml, int limit) 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 a39f0e7b2..9c4290db0 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,6 +142,12 @@ 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.). @@ -177,7 +183,7 @@ public: * Cockatrice deck XML format. * @{ */ - virtual bool readElement(QXmlStreamReader *xml) = 0; + virtual int readElement(QXmlStreamReader *xml, int limit) = 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 1f470695d..d082b3cca 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,6 +43,13 @@ 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); @@ -87,6 +94,9 @@ AbstractDecklistNode *InnerDecklistNode::findCardChildByNameProviderIdAndNumber( int InnerDecklistNode::height() const { + if (isEmpty()) { + return 1; + } return at(0)->height() + 1; } @@ -141,27 +151,31 @@ bool InnerDecklistNode::compareName(AbstractDecklistNode *other) const } } -bool InnerDecklistNode::readElement(QXmlStreamReader *xml) +int InnerDecklistNode::readElement(QXmlStreamReader *xml, int limit) { + 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); - newZone->readElement(xml); + totalCards += newZone->readElement(xml, limit - totalCards); } else if (childName == "card") { - 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); + 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); } } else if (xml->isEndElement() && (childName == "zone")) { - return false; + return totalCards; } } - return true; + return totalCards; } void InnerDecklistNode::writeElement(QXmlStreamWriter *xml) @@ -201,4 +215,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 f8fdedf30..0d454c11e 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,6 +18,9 @@ #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. */ @@ -118,6 +121,13 @@ 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). @@ -216,9 +226,10 @@ public: /** * @brief Deserialize this node and its children from XML. * @param xml Reader positioned at this element. - * @return true if parsing succeeded. + * @param limit The maximum amount of cards to read + * @return the amount of cards found */ - bool readElement(QXmlStreamReader *xml) override; + int readElement(QXmlStreamReader *xml, int limit) 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 25e8e97db..aaf391c03 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include static peg::parser search(R"( @@ -19,7 +20,7 @@ SomewhatComplexQueryPart <- [(] QueryPartList [)] / QueryPart QueryPart <- NotQuery / SetQuery / RarityQuery / CMCQuery / FormatQuery / PowerQuery / ToughnessQuery / ColorQuery / TypeQuery / OracleQuery / FieldQuery / GenericQuery NotQuery <- ('NOT' ws/'-') SomewhatComplexQueryPart -SetQuery <- ('e'/'set') [:] FlexStringValue +SetQuery <- ('e'/'set') SetExpression / ([:] FlexStringValue) OracleQuery <- 'o' [:] MatcherString @@ -64,6 +65,8 @@ RegexMatcherString <- ('\\/' / !'/' .)+ FlexStringValue <- CompactStringSet / String / [(] StringList [)] CompactStringSet <- StringListString ([,+] StringListString)+ +SetExpression <- NumericOperator ws? String + NumericExpression <- NumericOperator ws? NumericValue NumericOperator <- [=:] / <[> NumericValue <- [0-9]+ @@ -101,12 +104,25 @@ static void setupParserRules() return [=](const CardData &x) -> bool { return matcher(x->getCardType()); }; }; search["SetQuery"] = [](const peg::SemanticValues &sv) -> Filter { - auto matcher = std::any_cast(sv[0]); - return [=](const CardData &x) -> bool { - QList sets = x->getSets().keys(); + 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 matchesSet = [&matcher](const QString &set) { return matcher(set); }; + return std::any_of(sets.begin(), sets.end(), matchesSet); + }; + } + + 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); + }); }; }; search["Rarity"] = [](const peg::SemanticValues &sv) -> QString { @@ -247,40 +263,54 @@ static void setupParserRules() return 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]); + search["NumericOperator"] = [](const peg::SemanticValues &sv) -> NumberComparer { + const auto op = QString::fromStdString(std::string(sv.sv())); if (op == ">") { - return [=](const int s) { return s > arg; }; + return [=](const int s, const int arg) { return s > arg; }; } if (op == ">=") { - return [=](const int s) { return s >= arg; }; + return [=](const int s, const int arg) { return s >= arg; }; } if (op == "<") { - return [=](const int s) { return s < arg; }; + return [=](const int s, const int arg) { return s < arg; }; } if (op == "<=") { - return [=](const int s) { return s <= arg; }; + return [=](const int s, const int arg) { return s <= arg; }; } if (op == "=") { - return [=](const int s) { return s == arg; }; + return [=](const int s, const int arg) { return s == arg; }; } if (op == ":") { - return [=](const int s) { return s == arg; }; + return [=](const int s, const int arg) { return s == arg; }; } if (op == "!=") { - return [=](const int s) { return s != arg; }; + return [=](const int s, const int arg) { return s != arg; }; } - return [](int) { return false; }; + return [](int, int) { return false; }; }; search["NumericValue"] = [](const peg::SemanticValues &sv) -> int { return QString::fromStdString(std::string(sv.sv())).toInt(); }; - search["NumericOperator"] = [](const peg::SemanticValues &sv) -> QString { - return QString::fromStdString(std::string(sv.sv())); + 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["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 71a99f7b5..a058f7d07 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.h @@ -22,6 +22,7 @@ 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 a81616cb0..054c4cd72 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h @@ -21,6 +21,7 @@ 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 d4aee3686..a6ab2a204 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt +++ b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt @@ -7,7 +7,8 @@ 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_sort_filter_proxy_model.cpp + libcockatrice_models_deck_list STATIC ${MOC_SOURCES} deck_list_model.cpp deck_list_model_custom_zones.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 9b43281c1..76afca0c4 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp @@ -66,7 +66,8 @@ void DeckListModel::rebuildTree() for (int j = 0; j < currentZone->size(); j++) { auto *currentCard = dynamic_cast(currentZone->at(j)); - //! \todo Better sanity checking. + // Non-card children are custom zones; they are mirrored in a single + // pass below so each is mirrored exactly once. if (currentCard == nullptr) { continue; } @@ -82,8 +83,19 @@ 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(); @@ -154,6 +166,9 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const case DeckRoles::IsLegalRole: return true; + case DeckRoles::IsCustomZoneRole: + return DeckListModelCustomZones::isCustomZone(group); + default: return {}; } @@ -190,6 +205,10 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const return card->getFormatLegality(); } + case DeckRoles::IsCustomZoneRole: { + return false; + } + default: { return {}; } @@ -327,6 +346,13 @@ 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); @@ -337,7 +363,8 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent) } endRemoveRows(); - if (node->empty() && (node != root)) { + // Empty criteria groups get pruned, but custom zones stay until explicitly deleted. + if (node->empty() && (node != root) && !DeckListModelCustomZones::isCustomZone(node)) { removeRows(parent.row(), 1, parent.parent()); } else { emitRecursiveUpdates(parent); @@ -351,7 +378,8 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent) InnerDecklistNode *DeckListModel::createNodeIfNeeded(const QString &name, InnerDecklistNode *parent) { - auto *newNode = dynamic_cast(parent->findChild(name)); + // Group lookups must not resolve a mirrored custom zone that shares the name. + auto *newNode = DeckListModelCustomZones::findGroupChild(parent, name); if (!newNode) { beginInsertRows(nodeToIndex(parent), parent->size(), parent->size()); newNode = new InnerDecklistNode(name, parent); @@ -365,24 +393,44 @@ 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; } - QString groupCriteria = extractGroupCriteriaValue(info, activeGroupCriteria); - InnerDecklistNode *groupNode = dynamic_cast(zoneNode->findChild(groupCriteria)); - if (!groupNode) { - 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; + } + } } - return dynamic_cast( - groupNode->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber)); + // 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; } QModelIndex DeckListModel::findCard(const QString &cardName, @@ -423,29 +471,95 @@ QModelIndex DeckListModel::addCard(const ExactCard &card, const QString &zoneNam return {}; } - InnerDecklistNode *zoneNode = createNodeIfNeeded(zoneName, root); - CardInfoPtr cardInfo = card.getCardPtr(); PrintingInfo printingInfo = card.getPrinting(); - QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria); - InnerDecklistNode *groupNode = createNodeIfNeeded(groupCriteria, zoneNode); + InnerDecklistNode *cardParent = nullptr; - const QModelIndex parentIndex = nodeToIndex(groupNode); - auto *cardNode = dynamic_cast(groupNode->findCardChildByNameProviderIdAndNumber( + 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( 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(groupNode, cardInfo); + int insertRow = findSortedInsertRow(cardParent, cardInfo); + int deckInsertRow = customZoneNeedsAppend ? -1 : insertRow; - auto *decklistCard = deckList->addCard(cardInfo->getName(), zoneName, insertRow, cardSetName, + auto *decklistCard = deckList->addCard(cardInfo->getName(), zoneName, deckInsertRow, cardSetName, printingInfo.getProperty("num"), printingInfo.getProperty("uuid")); beginInsertRows(parentIndex, insertRow, insertRow); - cardNode = new DecklistModelCardNode(decklistCard, groupNode, insertRow); + cardNode = new DecklistModelCardNode(decklistCard, cardParent, insertRow); endInsertRows(); cardNodeAdded = true; @@ -576,21 +690,41 @@ 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 of node and save the information needed to - // update the list of persistent indexes. - QVector> sortResult = node->sort(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); QModelIndexList from, to; int columns = columnCount(); - 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 (const auto &move : mapping) { + const int preSortRow = move.first; + const int finalRow = move.second; + AbstractDecklistNode *temp = node->at(finalRow); for (int j = 0; j < columns; ++j) { - from << createIndex(fromRow, j, temp); - to << createIndex(toRow, j, temp); + from << createIndex(preSortRow, j, temp); + to << createIndex(finalRow, j, temp); } } changePersistentIndexList(from, to); @@ -704,6 +838,15 @@ 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 209ec8c42..09600ca67 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h @@ -1,6 +1,8 @@ #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 @@ -30,7 +32,8 @@ 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. */ + IsLegalRole, /**< Whether the card is legal in the current deck format. */ + IsCustomZoneRole /**< Whether the item represents a custom zone nested under a board zone. */ }; } // namespace DeckRoles @@ -391,6 +394,14 @@ 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. */ @@ -427,6 +438,7 @@ 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 new file mode 100644 index 000000000..1dc745e63 --- /dev/null +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp @@ -0,0 +1,152 @@ +#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 new file mode 100644 index 000000000..518a9e1d2 --- /dev/null +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h @@ -0,0 +1,98 @@ +#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 3a193ae3c..f22828f46 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -90,7 +90,6 @@ 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 5b38deb3f..4c578b4e4 100644 --- a/libcockatrice_rng/libcockatrice/rng/rng_sfmt.cpp +++ b/libcockatrice_rng/libcockatrice/rng/rng_sfmt.cpp @@ -1,6 +1,5 @@ #include "rng_sfmt.h" -#include #include #include #include @@ -11,10 +10,11 @@ #define UINT64_MAX (~(uint64_t)0) #endif -RNG_SFMT::RNG_SFMT(QObject *parent) : RNG_Abstract(parent) +RNG_SFMT::RNG_SFMT(uint64_t seed, QObject *parent) : RNG_Abstract(parent) { - // initialize the random number generator with a 32bit integer seed (timestamp) - sfmt_init_gen_rand(&sfmt, QDateTime::currentDateTime().toSecsSinceEpoch()); + // 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); } /** diff --git a/libcockatrice_rng/libcockatrice/rng/rng_sfmt.h b/libcockatrice_rng/libcockatrice/rng/rng_sfmt.h index 7e9f53df3..a180dad99 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(QObject *parent = nullptr); + explicit RNG_SFMT(uint64_t seed, 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 45a02299e..2f19d6224 100644 --- a/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp @@ -69,3 +69,14 @@ 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 d9b326bee..3a63f0df0 100644 --- a/libcockatrice_settings/libcockatrice/settings/appearance_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/appearance_settings.h @@ -27,6 +27,8 @@ public: void setHomeTabBackgroundShuffleFrequency(int _frequency); [[nodiscard]] bool getHomeTabDisplayCardName() const; void setHomeTabDisplayCardName(bool _displayCardName); + [[nodiscard]] int getHomeTabButtonColorSourceIndex() const; + void setHomeTabButtonColorSourceIndex(int index); signals: void themeNameChanged(); @@ -34,6 +36,7 @@ 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 85a1424a6..cf5bfd81a 100644 --- a/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp @@ -106,6 +106,11 @@ 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"); @@ -150,3 +155,8 @@ 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 365d91af7..eb78d311b 100644 --- a/libcockatrice_settings/libcockatrice/settings/tabs_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/tabs_settings.h @@ -43,6 +43,7 @@ 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); @@ -57,6 +58,7 @@ 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 c6411ea76..db23f7951 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -6,13 +6,15 @@ set(CMAKE_AUTOUIC ON) set(CMAKE_AUTORCC ON) set(UTILITY_SOURCES - 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/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 ) 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 @@ -32,7 +34,9 @@ add_library(libcockatrice_utility STATIC ${UTILITY_SOURCES} ${UTILITY_HEADERS}) target_include_directories(libcockatrice_utility PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) -target_link_libraries(libcockatrice_utility PUBLIC libcockatrice_rng ${QT_CORE_MODULE}) +find_package(OpenSSL REQUIRED) + +target_link_libraries(libcockatrice_utility PUBLIC libcockatrice_rng OpenSSL::Crypto ${QT_CORE_MODULE}) set(ORACLE_LIBS) diff --git a/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp b/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp new file mode 100644 index 000000000..416ef261b --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp @@ -0,0 +1,25 @@ +#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 new file mode 100644 index 000000000..dba9dc37d --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/cryptoutil.h @@ -0,0 +1,13 @@ +#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 c40c5f94f..1c22fdcfa 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,12 +21,28 @@ 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; - int size = sizeof(alphanum) - 1; - + ret.reserve(len); + QByteArray random = CryptoUtil::randomBytes(len * 2); + int bytesUsed = 0; for (int i = 0; i < len; ++i) { - ret.append(alphanum[rng->rand(0, size)]); + 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]); } return ret; @@ -34,5 +50,5 @@ QString PasswordHasher::generateRandomSalt(const int len) QString PasswordHasher::generateActivationToken() { - return QCryptographicHash::hash(generateRandomSalt().toUtf8(), QCryptographicHash::Md5).toBase64().left(16); + return QString(CryptoUtil::randomBytes(16).toBase64().left(16)); } diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 0736db7f5..68c4709bb 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/qt-json/json.cpp + src/raw_json_scanner.cpp ../cockatrice/src/client/settings/cache_settings.cpp ../cockatrice/src/client/settings/card_counter_settings.cpp ../cockatrice/src/client/settings/shortcuts_settings.cpp @@ -113,6 +113,8 @@ 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 fdb32bb8d..d745b250c 100644 --- a/oracle/src/oracleimporter.cpp +++ b/oracle/src/oracleimporter.cpp @@ -3,10 +3,13 @@ #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 @@ -42,31 +45,29 @@ static CardSet::Priority getSetPriority(const QString &setType, const QString &s return priority; } -bool OracleImporter::readSetsFromByteArray(const QByteArray &data) +bool OracleImporter::readSetsFromByteArray(QByteArray data) { - bool ok; - auto setsMap = QtJson::Json::parse(QString(data), ok).toMap().value("data").toMap(); - if (!ok) { - qDebug() << "error: QtJson::Json::parse()"; + RawJson::ScanError error; + const QList ranges = RawJson::scanSetRanges(data, &error); + if (error.isError()) { + qDebug() << "error: RawJson::scanSetRanges():" << error.message; return false; } QList newSetList; + newSetList.reserve(ranges.size()); - 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(); + 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); 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" - const QStringList noCapitalize = {"the", "a", "an", "on", "to", "for", "of", "in", "and", "with", "or"}; + static const QStringList noCapitalize = {"the", "a", "an", "on", "to", "for", + "of", "in", "and", "with", "or"}; QStringList words = setType.split("_"); setType.clear(); bool first = false; @@ -74,13 +75,15 @@ bool OracleImporter::readSetsFromByteArray(const QByteArray &data) if (first && noCapitalize.contains(item)) { setType += item + QString(" "); } else { - setType += item[0].toUpper() + item.mid(1, -1) + QString(" "); + setType += item[0].toUpper() + item.mid(1) + QString(" "); first = true; } } setType = setType.trimmed(); } - newSetList.append(SetToDownload(shortName, longName, setCards, priority, setType, releaseDate)); + SetToDownload set(shortName, longName, priority, setType, releaseDate); + set.setRawRange(range.dataRange); + newSetList.append(set); } std::sort(newSetList.begin(), newSetList.end()); @@ -89,6 +92,7 @@ bool OracleImporter::readSetsFromByteArray(const QByteArray &data) return false; } allSets = newSetList; + rawSetsData = std::move(data); return true; } @@ -122,14 +126,8 @@ 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 - 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); - } - } + auto last = std::unique(colors.begin(), colors.end()); + colors.erase(last, colors.end()); } CardInfoPtr OracleImporter::addCard(QString name, @@ -142,9 +140,12 @@ CardInfoPtr OracleImporter::addCard(QString name, // Workaround for card name weirdness name = name.replace("Æ", "AE"); name = name.replace("’", "'"); - if (cards.contains(name)) { - CardInfoPtr card = cards.value(name); + auto existingIt = cards.constFind(name); + if (existingIt != cards.constEnd()) { + CardInfoPtr card = existingIt.value(); 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); } @@ -182,8 +183,9 @@ CardInfoPtr OracleImporter::addCard(QString name, // DETECT CARD POSITIONING INFO - bool landscapeOrientation = properties.value("maintype") == "Battle" || properties.value("layout") == "split" || - properties.value("layout") == "planar"; + QString layoutVal = properties.value("layout"); + bool landscapeOrientation = + properties.value("maintype") == "Battle" || layoutVal == "split" || layoutVal == "planar"; // cards that enter the field tapped bool cipt = parseCipt(name, text) || landscapeOrientation; @@ -222,12 +224,15 @@ CardInfoPtr OracleImporter::addCard(QString name, return newCard; } -static QString getStringPropertyFromMap(const QVariantMap &card, const QString &propertyName) +static QString getJsonString(const QJsonObject &obj, const QString &key) { - return card.contains(propertyName) ? card.value(propertyName).toString() : QString(""); + // 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(); } -int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList &cardsList) +int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJsonArray &cardsList) { // mtgjson name => xml name static const QMap cardProperties{ @@ -248,7 +253,7 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList static const QString ptSeparator = "/"; static constexpr bool isToken = false; - static const QList setsWithCardsWithSameNameButDifferentText = {"UST"}; + static const QSet setsWithCardsWithSameNameButDifferentText = {"UST"}; int numCards = 0; @@ -256,16 +261,16 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList QMap, QString>> splitCards; // Keeps track of all names encountered so far - QList allNameProps; + QSet allNameProps; - for (const QVariant &cardVar : cardsList) { - QVariantMap card = cardVar.toMap(); + for (const QJsonValue &cardVal : cardsList) { + QJsonObject card = cardVal.toObject(); /* Currently used layouts are: * augment, double_faced_token, flip, host, leveler, meld, normal, planar, * saga, scheme, split, token, transform, vanguard */ - QString layout = getStringPropertyFromMap(card, "layout"); + QString layout = getJsonString(card, "layout"); // don't import tokens from the json file if (layout == "token") { @@ -273,9 +278,9 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList } // normal cards handling - QString name = getStringPropertyFromMap(card, "name"); - QString text = getStringPropertyFromMap(card, "text"); - QString faceName = getStringPropertyFromMap(card, "faceName"); + QString name = getJsonString(card, "name"); + QString text = getJsonString(card, "text"); + QString faceName = getJsonString(card, "faceName"); if (faceName.isEmpty()) { faceName = name; } @@ -283,39 +288,34 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList // card properties QHash properties; for (auto i = cardProperties.cbegin(), end = cardProperties.cend(); i != end; ++i) { - QString mtgjsonProperty = i.key(); - QString xmlPropertyName = i.value(); - QString propertyValue = getStringPropertyFromMap(card, mtgjsonProperty); + QString propertyValue = getJsonString(card, i.key()); if (!propertyValue.isEmpty()) { - properties.insert(xmlPropertyName, propertyValue); + properties.insert(i.value(), propertyValue); } } // per-set properties QHash printingProps; for (auto i = setInfoProperties.cbegin(), end = setInfoProperties.cend(); i != end; ++i) { - QString mtgjsonProperty = i.key(); - QString xmlPropertyName = i.value(); - QString propertyValue = getStringPropertyFromMap(card, mtgjsonProperty); + QString propertyValue = getJsonString(card, i.key()); if (!propertyValue.isEmpty()) { - printingProps.insert(xmlPropertyName, propertyValue); + printingProps.insert(i.value(), propertyValue); } } // handle flavorNames specially due to double-faced cards - QString faceFlavorName = getStringPropertyFromMap(card, "faceFlavorName"); - QString flavorName = !faceFlavorName.isEmpty() ? faceFlavorName : getStringPropertyFromMap(card, "flavorName"); + QString faceFlavorName = getJsonString(card, "faceFlavorName"); + QString flavorName = !faceFlavorName.isEmpty() ? faceFlavorName : getJsonString(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 mtgjsonProperty = i.key(); - QString xmlPropertyName = i.value(); - QString propertyValue = getStringPropertyFromMap(card.value("identifiers").toMap(), mtgjsonProperty); + QString propertyValue = getJsonString(identifiers, i.key()); if (!propertyValue.isEmpty()) { - printingProps.insert(xmlPropertyName, propertyValue); + printingProps.insert(i.value(), propertyValue); } } @@ -331,21 +331,26 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList allNameProps.contains(faceName) && layout == "normal" && lastChar.isLetter()) { numComponent = " (" + QString(lastChar).toLower() + ")"; } - allNameProps.append(faceName); + allNameProps.insert(faceName); // special handling properties - QString colors = card.value("colors").toStringList().join(""); + QString colors; + for (const QJsonValue &color : card.value("colors").toArray()) { + colors += color.toString(); + } if (!colors.isEmpty()) { properties.insert("colors", colors); } - // special handling properties - QString colorIdentity = card.value("colorIdentity").toStringList().join(""); + QString colorIdentity; + for (const QJsonValue &color : card.value("colorIdentity").toArray()) { + colorIdentity += color.toString(); + } if (!colorIdentity.isEmpty()) { properties.insert("coloridentity", colorIdentity); } - const auto &mainCardType = getMainCardType(card.value("types").toStringList()); + const auto &mainCardType = getMainCardType(card.value("types").toVariant().toStringList()); if (mainCardType.isEmpty()) { qDebug() << "warning: no mainCardType for card:" << name; } else { @@ -354,22 +359,22 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList // 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 = getStringPropertyFromMap(card, "power"); - QString toughness = getStringPropertyFromMap(card, "toughness"); + QString power = getJsonString(card, "power"); + QString toughness = getJsonString(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").toMap(); - for (auto i = legalities.cbegin(), end = legalities.cend(); i != end; ++i) { + auto legalities = card.value("legalities").toObject(); + for (auto i = legalities.constBegin(), end = legalities.constEnd(); 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 = getStringPropertyFromMap(card, "faceName"); + auto _faceName = getJsonString(card, "faceName"); SplitCardPart split(_faceName, text, properties, printingInfo); auto found_iter = splitCards.find(name + numProperty); if (found_iter == splitCards.end()) { @@ -382,11 +387,11 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList QList relatedCards; // add other face for split cards as card relation - if (!getStringPropertyFromMap(card, "side").isEmpty()) { - auto faceManaValue = getStringPropertyFromMap(card, "faceManaValue"); + if (!getJsonString(card, "side").isEmpty()) { + auto faceManaValue = getJsonString(card, "faceManaValue"); if (faceManaValue.isEmpty()) { // check the old name for the property, for backwards compatibility purposes - faceManaValue = getStringPropertyFromMap(card, "faceConvertedManaCost"); + faceManaValue = getJsonString(card, "faceConvertedManaCost"); } properties["cmc"] = faceManaValue; @@ -406,20 +411,21 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList name = faceName; } - // mtgjon related cards - if (card.contains("relatedCards")) { - QVariantMap givenRelated = card.value("relatedCards").toMap(); + // mtgjson related cards + QJsonObject givenRelated = card.value("relatedCards").toObject(); + if (!givenRelated.isEmpty()) { // conjured cards from a spellbook - 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)); + 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)); } } } - CardInfoPtr newCard = addCard(name + numComponent, text, isToken, properties, relatedCards, printingInfo); + CardInfoPtr newCard = + addCard(name + numComponent, text, isToken, std::move(properties), relatedCards, printingInfo); numCards++; } } @@ -427,7 +433,6 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList // 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) { @@ -453,7 +458,7 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList 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") { // the card is both colors + } else if (prop == "colors" || prop == "coloridentity") { // the card is both colors properties.insert(prop, originalPropertyValue + thisCardPropertyValue); } else if (prop == "maintype") { // don't create maintypes with //es in them continue; @@ -465,20 +470,20 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList } } } - CardInfoPtr newCard = addCard(name, text, isToken, properties, noRelatedCards, printingInfo); + CardInfoPtr newCard = addCard(name, text, isToken, std::move(properties), {}, printingInfo); numCards++; } return numCards; } -FormatRulesNameMap OracleImporter::createDefaultMagicFormats() +static FormatRulesNameMap buildDefaultMagicFormats() { // Predefined common exceptions CardCondition superTypeIsBasic; superTypeIsBasic.field = "type"; superTypeIsBasic.matchType = "regex"; - superTypeIsBasic.value = "\bBasic\b[^—]+\bLand\b"; + superTypeIsBasic.value = R"(\bBasic\b[^—]+\bLand\b)"; ExceptionRule basicLands; basicLands.conditions.append(superTypeIsBasic); @@ -491,7 +496,6 @@ FormatRulesNameMap OracleImporter::createDefaultMagicFormats() ExceptionRule mayContainAnyNumber; mayContainAnyNumber.conditions.append(anyNumberAllowed); - // Map to store default rules FormatRulesNameMap defaultFormatRulesNameMap; // ----------------- Helper lambda to create format ----------------- @@ -537,10 +541,27 @@ FormatRulesNameMap OracleImporter::createDefaultMagicFormats() 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"); @@ -552,11 +573,44 @@ 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); } - int numCardsInSet = importCardsFromSet(newSet, curSetToParse.getCards()); + const QJsonArray setCards = setDoc.object().value("cards").toArray(); + int numCardsInSet = importCardsFromSet(newSet, setCards); ++setIndex; @@ -576,9 +630,16 @@ 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 99644f9ce..8cb30ca40 100644 --- a/oracle/src/oracleimporter.h +++ b/oracle/src/oracleimporter.h @@ -1,6 +1,11 @@ #ifndef ORACLEIMPORTER_H #define ORACLEIMPORTER_H +#include "raw_json_scanner.h" + +#include +#include +#include #include #include #include @@ -44,10 +49,12 @@ 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 @@ -58,10 +65,6 @@ public: { return longName; } - const QList &getCards() const - { - return cards; - } const QString &getSetType() const { return setType; @@ -74,16 +77,23 @@ 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)), cards(std::move(_cards)), - releaseDate(_releaseDate), setType(std::move(_setType)), priority(_priority) + : shortName(std::move(_shortName)), longName(std::move(_longName)), 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; @@ -139,6 +149,12 @@ 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, @@ -151,11 +167,18 @@ signals: public: explicit OracleImporter(QObject *parent = nullptr); - bool readSetsFromByteArray(const QByteArray &data); + /** + * 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); int startImport(); bool saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion); - int importCardsFromSet(const CardSetPtr ¤tSet, const QList &cardsList); - FormatRulesNameMap createDefaultMagicFormats(); + 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(); const CardNameMap &getCardList() const { return cards; @@ -164,6 +187,11 @@ 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 df4d1a98c..94e662ffe 100644 --- a/oracle/src/pages.cpp +++ b/oracle/src/pages.cpp @@ -560,6 +560,9 @@ 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 deleted file mode 100644 index 29a85929f..000000000 --- a/oracle/src/qt-json/AUTHORS +++ /dev/null @@ -1,3 +0,0 @@ -Eeli Reilin -Luis Gustavo S. Barreto -Stephen Kockentiedt diff --git a/oracle/src/qt-json/LICENSE b/oracle/src/qt-json/LICENSE deleted file mode 100644 index 3c42b515a..000000000 --- a/oracle/src/qt-json/LICENSE +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index b60c1599b..000000000 --- a/oracle/src/qt-json/README +++ /dev/null @@ -1,96 +0,0 @@ -######################################################################## -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 deleted file mode 100644 index ff739b49d..000000000 --- a/oracle/src/qt-json/json.cpp +++ /dev/null @@ -1,545 +0,0 @@ -/* 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 deleted file mode 100644 index cf0499d4e..000000000 --- a/oracle/src/qt-json/json.h +++ /dev/null @@ -1,204 +0,0 @@ -/* 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 new file mode 100644 index 000000000..db972491c --- /dev/null +++ b/oracle/src/raw_json_scanner.cpp @@ -0,0 +1,621 @@ +#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 new file mode 100644 index 000000000..f6e3a4647 --- /dev/null +++ b/oracle/src/raw_json_scanner.h @@ -0,0 +1,76 @@ +#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 aba63800c..5d8089ad1 100644 --- a/servatrice/CMakeLists.txt +++ b/servatrice/CMakeLists.txt @@ -95,6 +95,8 @@ 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 9e7fe38d9..13bf95a82 100644 --- a/servatrice/src/main.cpp +++ b/servatrice/src/main.cpp @@ -33,6 +33,7 @@ #include #include #include +#include #include RNG_Abstract *rng; @@ -169,7 +170,7 @@ int main(int argc, char *argv[]) signalhandler = new SignalHandler(); - rng = new RNG_SFMT; + rng = new RNG_SFMT(CryptoUtil::randomUInt64()); std::cerr << "Servatrice " << VERSION_STRING << " starting." << std::endl; std::cerr << "-------------------------" << std::endl; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 29caf257e..18ab60d06 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 5) +set_tests_properties(deck_hash_performance_test PROPERTIES TIMEOUT 15) # Find GTest @@ -114,6 +114,8 @@ 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 new file mode 100644 index 000000000..e3096c559 --- /dev/null +++ b/tests/deck_list_model/CMakeLists.txt @@ -0,0 +1,33 @@ +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 new file mode 100644 index 000000000..7f4ab81e1 --- /dev/null +++ b/tests/deck_list_model/deck_list_model_custom_zones_test.cpp @@ -0,0 +1,276 @@ +/** + * @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 new file mode 100644 index 000000000..a4562c95d --- /dev/null +++ b/tests/deck_list_model/deck_list_model_zone_integration_test.cpp @@ -0,0 +1,283 @@ +#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 new file mode 100644 index 000000000..0710be94d --- /dev/null +++ b/tests/deck_list_zones/CMakeLists.txt @@ -0,0 +1,10 @@ +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 new file mode 100644 index 000000000..801f226a9 --- /dev/null +++ b/tests/deck_list_zones/deck_list_zones_test.cpp @@ -0,0 +1,392 @@ +/** + * @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 c5c1e9097..9bc5ee5be 100644 --- a/tests/oracle/CMakeLists.txt +++ b/tests/oracle/CMakeLists.txt @@ -7,3 +7,63 @@ 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 new file mode 100644 index 000000000..633c9e40a --- /dev/null +++ b/tests/oracle/oracle_importer_benchmark_test.cpp @@ -0,0 +1,578 @@ +#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 new file mode 100644 index 000000000..3834be9ed --- /dev/null +++ b/tests/oracle/oracle_importer_test.cpp @@ -0,0 +1,748 @@ +#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 38d9b6315..2b8f8bdb7 100644 --- a/tests/password_hash_test.cpp +++ b/tests/password_hash_test.cpp @@ -1,25 +1,9 @@ #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) { @@ -29,6 +13,29 @@ 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 6c79d5227..139656f27 100644 --- a/tests/settings/settings_defaults_test.cpp +++ b/tests/settings/settings_defaults_test.cpp @@ -238,6 +238,12 @@ 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)