diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 926263c05..c45f57cb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,12 +35,13 @@ jobs: - name: Calculate skip cache keys id: set_cache run: | - job_cache_extra_deps=(); job_id=test_linux; job_name='Test (Linux)'; job_needs=(); job_os=Linux; job_platform=ubuntu-18.04; job_python=3.8; job_reqs=(reqs/dist.txt reqs/test.txt); job_skip_cache_name=skip_test_linux_py-3.8_ubuntu-18.04; job_skip_cache_path=.skip_cache_test_linux; job_skiplists=(job_test os_linux); job_type=test; job_variant=Linux; analyze_set_job_skip_cache_key - job_cache_extra_deps=(osx/deps.sh); job_id=test_macos; job_name='Test (macOS)'; job_needs=(); job_os=macOS; job_platform=macos-10.15; job_python=3.8; job_reqs=(reqs/dist.txt reqs/test.txt); job_skip_cache_name=skip_test_macos_py-3.8_macos-10.15; job_skip_cache_path=.skip_cache_test_macos; job_skiplists=(job_test os_macos); job_type=test; job_variant=macOS; analyze_set_job_skip_cache_key - job_cache_extra_deps=(); job_id=test_windows; job_name='Test (Windows)'; job_needs=(); job_os=Windows; job_platform=windows-2019; job_python=3.8; job_reqs=(reqs/dist.txt reqs/test.txt); job_skip_cache_name=skip_test_windows_py-3.8_windows-2019; job_skip_cache_path=.skip_cache_test_windows; job_skiplists=(job_test os_windows); job_type=test; job_variant=Windows; analyze_set_job_skip_cache_key - job_cache_extra_deps=(); job_id=test_python_36; job_name='Test (Python 3.6)'; job_needs=(); job_os=Linux; job_platform=ubuntu-latest; job_python=3.6; job_reqs=(reqs/dist.txt reqs/test.txt); job_skip_cache_name=skip_test_python_36_py-3.6_ubuntu-latest; job_skip_cache_path=.skip_cache_test_python_36; job_skiplists=(job_test os_linux os_macos os_windows); job_type=test; job_variant='Python 3.6'; analyze_set_job_skip_cache_key - job_cache_extra_deps=(); job_id=test_python_37; job_name='Test (Python 3.7)'; job_needs=(); job_os=Linux; job_platform=ubuntu-latest; job_python=3.7; job_reqs=(reqs/dist.txt reqs/test.txt); job_skip_cache_name=skip_test_python_37_py-3.7_ubuntu-latest; job_skip_cache_path=.skip_cache_test_python_37; job_skiplists=(job_test os_linux os_macos os_windows); job_type=test; job_variant='Python 3.7'; analyze_set_job_skip_cache_key - job_cache_extra_deps=(); job_id=test_python_39; job_name='Test (Python 3.9)'; job_needs=(); job_os=Linux; job_platform=ubuntu-latest; job_python=3.9; job_reqs=(reqs/dist.txt reqs/test.txt); job_skip_cache_name=skip_test_python_39_py-3.9_ubuntu-latest; job_skip_cache_path=.skip_cache_test_python_39; job_skiplists=(job_test os_linux os_macos os_windows); job_type=test; job_variant='Python 3.9'; analyze_set_job_skip_cache_key + job_cache_extra_deps=(); job_id=test_linux; job_name='Test (Linux)'; job_needs=(); job_os=Linux; job_platform=ubuntu-18.04; job_python=3.8; job_reqs=(reqs/dist.txt reqs/test.txt); job_skip_cache_name=skip_test_linux_py-3.8_ubuntu-18.04; job_skip_cache_path=.skip_cache_test_linux; job_skiplists=(job_test os_linux); job_test_args='-p no:pytest-qt --ignore=test/gui_qt'; job_type=test; job_variant=Linux; analyze_set_job_skip_cache_key + job_cache_extra_deps=(osx/deps.sh); job_id=test_macos; job_name='Test (macOS)'; job_needs=(); job_os=macOS; job_platform=macos-10.15; job_python=3.8; job_reqs=(reqs/dist.txt reqs/test.txt); job_skip_cache_name=skip_test_macos_py-3.8_macos-10.15; job_skip_cache_path=.skip_cache_test_macos; job_skiplists=(job_test os_macos); job_test_args='-p no:pytest-qt --ignore=test/gui_qt'; job_type=test; job_variant=macOS; analyze_set_job_skip_cache_key + job_cache_extra_deps=(); job_id=test_windows; job_name='Test (Windows)'; job_needs=(); job_os=Windows; job_platform=windows-2019; job_python=3.8; job_reqs=(reqs/dist.txt reqs/test.txt); job_skip_cache_name=skip_test_windows_py-3.8_windows-2019; job_skip_cache_path=.skip_cache_test_windows; job_skiplists=(job_test os_windows); job_test_args='-p no:pytest-qt --ignore=test/gui_qt'; job_type=test; job_variant=Windows; analyze_set_job_skip_cache_key + job_cache_extra_deps=(); job_id=test_python_36; job_name='Test (Python 3.6)'; job_needs=(); job_os=Linux; job_platform=ubuntu-latest; job_python=3.6; job_reqs=(reqs/dist.txt reqs/test.txt); job_skip_cache_name=skip_test_python_36_py-3.6_ubuntu-latest; job_skip_cache_path=.skip_cache_test_python_36; job_skiplists=(job_test os_linux os_macos os_windows); job_test_args='-p no:pytest-qt --ignore=test/gui_qt'; job_type=test; job_variant='Python 3.6'; analyze_set_job_skip_cache_key + job_cache_extra_deps=(); job_id=test_python_37; job_name='Test (Python 3.7)'; job_needs=(); job_os=Linux; job_platform=ubuntu-latest; job_python=3.7; job_reqs=(reqs/dist.txt reqs/test.txt); job_skip_cache_name=skip_test_python_37_py-3.7_ubuntu-latest; job_skip_cache_path=.skip_cache_test_python_37; job_skiplists=(job_test os_linux os_macos os_windows); job_test_args='-p no:pytest-qt --ignore=test/gui_qt'; job_type=test; job_variant='Python 3.7'; analyze_set_job_skip_cache_key + job_cache_extra_deps=(); job_id=test_python_39; job_name='Test (Python 3.9)'; job_needs=(); job_os=Linux; job_platform=ubuntu-latest; job_python=3.9; job_reqs=(reqs/dist.txt reqs/test.txt); job_skip_cache_name=skip_test_python_39_py-3.9_ubuntu-latest; job_skip_cache_path=.skip_cache_test_python_39; job_skiplists=(job_test os_linux os_macos os_windows); job_test_args='-p no:pytest-qt --ignore=test/gui_qt'; job_type=test; job_variant='Python 3.9'; analyze_set_job_skip_cache_key + job_cache_extra_deps=(); job_id=test_qt_gui; job_name='Test (Qt GUI)'; job_needs=(); job_os=Linux; job_platform=ubuntu-18.04; job_python=3.8; job_reqs=(reqs/dist.txt reqs/dist_extra_gui_qt.txt reqs/test.txt); job_skip_cache_name=skip_test_qt_gui_py-3.8_ubuntu-18.04; job_skip_cache_path=.skip_cache_test_qt_gui; job_skiplists=(job_test_gui_qt); job_test_args=test/gui_qt; job_type=test_gui_qt; job_variant='Qt GUI'; analyze_set_job_skip_cache_key job_cache_extra_deps=(); job_id=test_packaging; job_name='Test (Packaging)'; job_needs=(); job_os=Linux; job_platform=ubuntu-latest; job_python=3.9; job_reqs=(reqs/packaging.txt reqs/setup.txt); job_skip_cache_name=skip_test_packaging_py-3.9_ubuntu-latest; job_skip_cache_path=.skip_cache_test_packaging; job_skiplists=(job_test_packaging); job_type=test_packaging; job_variant=Packaging; analyze_set_job_skip_cache_key job_cache_extra_deps=('reqs/dist_*.txt' linux/appimage/deps.sh); job_id=build_linux; job_name='Build (Linux)'; job_needs=(test_linux); job_os=Linux; job_platform=ubuntu-18.04; job_python=3.8; job_reqs=(reqs/build.txt reqs/setup.txt); job_skip_cache_name=skip_build_linux_py-3.8_ubuntu-18.04; job_skip_cache_path=.skip_cache_build_linux; job_skiplists=(job_build os_linux); job_type=build; job_variant=Linux; analyze_set_job_skip_cache_key job_cache_extra_deps=('reqs/dist_*.txt' osx/deps.sh); job_id=build_macos; job_name='Build (macOS)'; job_needs=(test_macos); job_os=macOS; job_platform=macos-10.15; job_python=3.8; job_reqs=(reqs/build.txt reqs/setup.txt); job_skip_cache_name=skip_build_macos_py-3.8_macos-10.15; job_skip_cache_path=.skip_cache_build_macos; job_skiplists=(job_build os_macos); job_type=build; job_variant=macOS; analyze_set_job_skip_cache_key @@ -94,6 +95,14 @@ jobs: restore-keys: 0_${{ steps.set_cache.outputs.test_python_39_skip_cache_key }} + - name: Check skip cache for Test (Qt GUI) + uses: actions/cache@v2 + with: + path: .skip_cache_test_qt_gui + key: 0_check_${{ steps.set_cache.outputs.test_qt_gui_skip_cache_key }}_${{ github.run_id }} + restore-keys: + 0_${{ steps.set_cache.outputs.test_qt_gui_skip_cache_key }} + - name: Check skip cache for Test (Packaging) uses: actions/cache@v2 with: @@ -132,12 +141,13 @@ jobs: CONTINUOUS_RELEASE_BRANCH: ${{ secrets.CONTINUOUS_RELEASE_BRANCH }} run: | analyze_set_release_info - job_cache_extra_deps=(); job_id=test_linux; job_name='Test (Linux)'; job_needs=(); job_os=Linux; job_platform=ubuntu-18.04; job_python=3.8; job_reqs=(reqs/dist.txt reqs/test.txt); job_skip_cache_name=skip_test_linux_py-3.8_ubuntu-18.04; job_skip_cache_path=.skip_cache_test_linux; job_skiplists=(job_test os_linux); job_type=test; job_variant=Linux; analyze_set_job_skip_job - job_cache_extra_deps=(osx/deps.sh); job_id=test_macos; job_name='Test (macOS)'; job_needs=(); job_os=macOS; job_platform=macos-10.15; job_python=3.8; job_reqs=(reqs/dist.txt reqs/test.txt); job_skip_cache_name=skip_test_macos_py-3.8_macos-10.15; job_skip_cache_path=.skip_cache_test_macos; job_skiplists=(job_test os_macos); job_type=test; job_variant=macOS; analyze_set_job_skip_job - job_cache_extra_deps=(); job_id=test_windows; job_name='Test (Windows)'; job_needs=(); job_os=Windows; job_platform=windows-2019; job_python=3.8; job_reqs=(reqs/dist.txt reqs/test.txt); job_skip_cache_name=skip_test_windows_py-3.8_windows-2019; job_skip_cache_path=.skip_cache_test_windows; job_skiplists=(job_test os_windows); job_type=test; job_variant=Windows; analyze_set_job_skip_job - job_cache_extra_deps=(); job_id=test_python_36; job_name='Test (Python 3.6)'; job_needs=(); job_os=Linux; job_platform=ubuntu-latest; job_python=3.6; job_reqs=(reqs/dist.txt reqs/test.txt); job_skip_cache_name=skip_test_python_36_py-3.6_ubuntu-latest; job_skip_cache_path=.skip_cache_test_python_36; job_skiplists=(job_test os_linux os_macos os_windows); job_type=test; job_variant='Python 3.6'; analyze_set_job_skip_job - job_cache_extra_deps=(); job_id=test_python_37; job_name='Test (Python 3.7)'; job_needs=(); job_os=Linux; job_platform=ubuntu-latest; job_python=3.7; job_reqs=(reqs/dist.txt reqs/test.txt); job_skip_cache_name=skip_test_python_37_py-3.7_ubuntu-latest; job_skip_cache_path=.skip_cache_test_python_37; job_skiplists=(job_test os_linux os_macos os_windows); job_type=test; job_variant='Python 3.7'; analyze_set_job_skip_job - job_cache_extra_deps=(); job_id=test_python_39; job_name='Test (Python 3.9)'; job_needs=(); job_os=Linux; job_platform=ubuntu-latest; job_python=3.9; job_reqs=(reqs/dist.txt reqs/test.txt); job_skip_cache_name=skip_test_python_39_py-3.9_ubuntu-latest; job_skip_cache_path=.skip_cache_test_python_39; job_skiplists=(job_test os_linux os_macos os_windows); job_type=test; job_variant='Python 3.9'; analyze_set_job_skip_job + job_cache_extra_deps=(); job_id=test_linux; job_name='Test (Linux)'; job_needs=(); job_os=Linux; job_platform=ubuntu-18.04; job_python=3.8; job_reqs=(reqs/dist.txt reqs/test.txt); job_skip_cache_name=skip_test_linux_py-3.8_ubuntu-18.04; job_skip_cache_path=.skip_cache_test_linux; job_skiplists=(job_test os_linux); job_test_args='-p no:pytest-qt --ignore=test/gui_qt'; job_type=test; job_variant=Linux; analyze_set_job_skip_job + job_cache_extra_deps=(osx/deps.sh); job_id=test_macos; job_name='Test (macOS)'; job_needs=(); job_os=macOS; job_platform=macos-10.15; job_python=3.8; job_reqs=(reqs/dist.txt reqs/test.txt); job_skip_cache_name=skip_test_macos_py-3.8_macos-10.15; job_skip_cache_path=.skip_cache_test_macos; job_skiplists=(job_test os_macos); job_test_args='-p no:pytest-qt --ignore=test/gui_qt'; job_type=test; job_variant=macOS; analyze_set_job_skip_job + job_cache_extra_deps=(); job_id=test_windows; job_name='Test (Windows)'; job_needs=(); job_os=Windows; job_platform=windows-2019; job_python=3.8; job_reqs=(reqs/dist.txt reqs/test.txt); job_skip_cache_name=skip_test_windows_py-3.8_windows-2019; job_skip_cache_path=.skip_cache_test_windows; job_skiplists=(job_test os_windows); job_test_args='-p no:pytest-qt --ignore=test/gui_qt'; job_type=test; job_variant=Windows; analyze_set_job_skip_job + job_cache_extra_deps=(); job_id=test_python_36; job_name='Test (Python 3.6)'; job_needs=(); job_os=Linux; job_platform=ubuntu-latest; job_python=3.6; job_reqs=(reqs/dist.txt reqs/test.txt); job_skip_cache_name=skip_test_python_36_py-3.6_ubuntu-latest; job_skip_cache_path=.skip_cache_test_python_36; job_skiplists=(job_test os_linux os_macos os_windows); job_test_args='-p no:pytest-qt --ignore=test/gui_qt'; job_type=test; job_variant='Python 3.6'; analyze_set_job_skip_job + job_cache_extra_deps=(); job_id=test_python_37; job_name='Test (Python 3.7)'; job_needs=(); job_os=Linux; job_platform=ubuntu-latest; job_python=3.7; job_reqs=(reqs/dist.txt reqs/test.txt); job_skip_cache_name=skip_test_python_37_py-3.7_ubuntu-latest; job_skip_cache_path=.skip_cache_test_python_37; job_skiplists=(job_test os_linux os_macos os_windows); job_test_args='-p no:pytest-qt --ignore=test/gui_qt'; job_type=test; job_variant='Python 3.7'; analyze_set_job_skip_job + job_cache_extra_deps=(); job_id=test_python_39; job_name='Test (Python 3.9)'; job_needs=(); job_os=Linux; job_platform=ubuntu-latest; job_python=3.9; job_reqs=(reqs/dist.txt reqs/test.txt); job_skip_cache_name=skip_test_python_39_py-3.9_ubuntu-latest; job_skip_cache_path=.skip_cache_test_python_39; job_skiplists=(job_test os_linux os_macos os_windows); job_test_args='-p no:pytest-qt --ignore=test/gui_qt'; job_type=test; job_variant='Python 3.9'; analyze_set_job_skip_job + job_cache_extra_deps=(); job_id=test_qt_gui; job_name='Test (Qt GUI)'; job_needs=(); job_os=Linux; job_platform=ubuntu-18.04; job_python=3.8; job_reqs=(reqs/dist.txt reqs/dist_extra_gui_qt.txt reqs/test.txt); job_skip_cache_name=skip_test_qt_gui_py-3.8_ubuntu-18.04; job_skip_cache_path=.skip_cache_test_qt_gui; job_skiplists=(job_test_gui_qt); job_test_args=test/gui_qt; job_type=test_gui_qt; job_variant='Qt GUI'; analyze_set_job_skip_job job_cache_extra_deps=(); job_id=test_packaging; job_name='Test (Packaging)'; job_needs=(); job_os=Linux; job_platform=ubuntu-latest; job_python=3.9; job_reqs=(reqs/packaging.txt reqs/setup.txt); job_skip_cache_name=skip_test_packaging_py-3.9_ubuntu-latest; job_skip_cache_path=.skip_cache_test_packaging; job_skiplists=(job_test_packaging); job_type=test_packaging; job_variant=Packaging; analyze_set_job_skip_job job_cache_extra_deps=('reqs/dist_*.txt' linux/appimage/deps.sh); job_id=build_linux; job_name='Build (Linux)'; job_needs=(test_linux); job_os=Linux; job_platform=ubuntu-18.04; job_python=3.8; job_reqs=(reqs/build.txt reqs/setup.txt); job_skip_cache_name=skip_build_linux_py-3.8_ubuntu-18.04; job_skip_cache_path=.skip_cache_build_linux; job_skiplists=(job_build os_linux); job_type=build; job_variant=Linux; analyze_set_job_skip_job job_cache_extra_deps=('reqs/dist_*.txt' osx/deps.sh); job_id=build_macos; job_name='Build (macOS)'; job_needs=(test_macos); job_os=macOS; job_platform=macos-10.15; job_python=3.8; job_reqs=(reqs/build.txt reqs/setup.txt); job_skip_cache_name=skip_build_macos_py-3.8_macos-10.15; job_skip_cache_path=.skip_cache_build_macos; job_skiplists=(job_build os_macos); job_type=build; job_variant=macOS; analyze_set_job_skip_job @@ -158,6 +168,8 @@ jobs: test_python_37_skip_cache_key: ${{ steps.set_cache.outputs.test_python_37_skip_cache_key }} test_python_39_skip_job: ${{ steps.set_ouputs.outputs.test_python_39_skip_job }} test_python_39_skip_cache_key: ${{ steps.set_cache.outputs.test_python_39_skip_cache_key }} + test_qt_gui_skip_job: ${{ steps.set_ouputs.outputs.test_qt_gui_skip_job }} + test_qt_gui_skip_cache_key: ${{ steps.set_cache.outputs.test_qt_gui_skip_cache_key }} test_packaging_skip_job: ${{ steps.set_ouputs.outputs.test_packaging_skip_job }} test_packaging_skip_cache_key: ${{ steps.set_cache.outputs.test_packaging_skip_cache_key }} build_linux_skip_job: ${{ steps.set_ouputs.outputs.build_linux_skip_job }} @@ -207,7 +219,8 @@ jobs: # Test {{{ - name: Run tests - run: run_tests + run: run_tests -p no:pytest-qt --ignore=test/gui_qt + # }}} @@ -261,7 +274,8 @@ jobs: # Test {{{ - name: Run tests - run: run_tests + run: run_tests -p no:pytest-qt --ignore=test/gui_qt + # }}} @@ -321,7 +335,8 @@ jobs: # Test {{{ - name: Run tests - run: run_tests + run: run_tests -p no:pytest-qt --ignore=test/gui_qt + # }}} @@ -376,7 +391,8 @@ jobs: # Test {{{ - name: Run tests - run: run_tests + run: run_tests -p no:pytest-qt --ignore=test/gui_qt + # }}} @@ -431,7 +447,8 @@ jobs: # Test {{{ - name: Run tests - run: run_tests + run: run_tests -p no:pytest-qt --ignore=test/gui_qt + # }}} @@ -486,7 +503,8 @@ jobs: # Test {{{ - name: Run tests - run: run_tests + run: run_tests -p no:pytest-qt --ignore=test/gui_qt + # }}} @@ -503,6 +521,65 @@ jobs: run: list_cache # }}} + # Job: Test (Qt GUI) {{{ + test_qt_gui: + + name: Test (Qt GUI) + runs-on: ubuntu-18.04 + needs: [analyze, ] + if: >- + !cancelled() + && needs.analyze.outputs.test_qt_gui_skip_job == 'no' + + steps: + + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: 3.8 + + - name: Set cache name + id: set_cache + run: setup_cache_name '3.8' 'ubuntu-18.04' + + - name: Setup cache + uses: actions/cache@v2 + with: + path: .cache + key: 0_${{ steps.set_cache.outputs.cache_name }}_${{ hashFiles('reqs/constraints.txt', 'reqs/dist.txt', 'reqs/dist_extra_gui_qt.txt', 'reqs/test.txt') }} + + - name: Setup pip options + run: setup_pip_options + + - name: Setup Python environment + run: setup_python_env -c reqs/constraints.txt -r reqs/dist.txt -r reqs/dist_extra_gui_qt.txt -r reqs/test.txt + - name: Build UI + run: python setup.py build_ui + + # Test {{{ + + - name: Run tests + run: run_tests test/gui_qt + + + # }}} + + - name: Update skip cache 1 + uses: actions/cache@v2 + with: + path: .skip_cache_test_qt_gui + key: 0_${{ needs.analyze.outputs.test_qt_gui_skip_cache_key }} + + - name: Update skip cache 2 + run: run_eval "echo 'https://github.com/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID' >'.skip_cache_test_qt_gui'" + + - name: List cache contents + run: list_cache + # }}} + # Job: Test (Packaging) {{{ test_packaging: @@ -827,7 +904,7 @@ jobs: name: Release environment: release runs-on: ubuntu-latest - needs: [analyze, test_linux, test_macos, test_windows, test_python_36, test_python_37, test_python_39, test_packaging, build_linux, build_macos, build_windows] + needs: [analyze, test_linux, test_macos, test_windows, test_python_36, test_python_37, test_python_39, test_qt_gui, test_packaging, build_linux, build_macos, build_windows] if: >- !cancelled() && needs.test_linux.result == 'success' @@ -836,6 +913,7 @@ jobs: && needs.test_python_36.result == 'success' && needs.test_python_37.result == 'success' && needs.test_python_39.result == 'success' + && needs.test_qt_gui.result == 'success' && needs.test_packaging.result == 'success' && needs.build_linux.result == 'success' && needs.build_macos.result == 'success' diff --git a/.github/workflows/ci/helpers.sh b/.github/workflows/ci/helpers.sh index e1d59e492..37aba6b8c 100644 --- a/.github/workflows/ci/helpers.sh +++ b/.github/workflows/ci/helpers.sh @@ -24,7 +24,10 @@ list_cache() run_tests() { - "$python" setup.py -q test -- --color=yes --durations=5 -ra "$@" + test_cmd=(env QT_QPA_PLATFORM=offscreen "$python" -m pytest --color=yes --durations=5 "$@") + run "$python" setup.py -q egg_info + info "$(printf "%q " "${test_cmd[@]}")" + "${test_cmd[@]}" } setup_cache_name() diff --git a/.github/workflows/ci/skiplist_job_build.txt b/.github/workflows/ci/skiplist_job_build.txt index a51ed269f..312ad65e6 100644 --- a/.github/workflows/ci/skiplist_job_build.txt +++ b/.github/workflows/ci/skiplist_job_build.txt @@ -1,5 +1,4 @@ -.github/workflows/ci/skiplist_job_test.txt -.github/workflows/ci/skiplist_job_test_packaging.txt +.github/workflows/ci/skiplist_job_test*.txt .gitignore pyproject.toml reqs/packaging.txt diff --git a/.github/workflows/ci/skiplist_job_test.txt b/.github/workflows/ci/skiplist_job_test.txt index 9b1780888..67fdb7a09 100644 --- a/.github/workflows/ci/skiplist_job_test.txt +++ b/.github/workflows/ci/skiplist_job_test.txt @@ -1,4 +1,5 @@ .github/workflows/ci/skiplist_job_build.txt +.github/workflows/ci/skiplist_job_test_gui_qt.txt .github/workflows/ci/skiplist_job_test_packaging.txt .gitignore LICENSE.txt @@ -13,5 +14,6 @@ reqs/build.txt reqs/dist_*.txt reqs/packaging.txt reqs/setup.txt +test/gui_qt/* windows/* {EOF} diff --git a/.github/workflows/ci/skiplist_job_test_gui_qt.txt b/.github/workflows/ci/skiplist_job_test_gui_qt.txt new file mode 100644 index 000000000..8c7aa1827 --- /dev/null +++ b/.github/workflows/ci/skiplist_job_test_gui_qt.txt @@ -0,0 +1,16 @@ +.github/workflows/ci/skiplist_job_build.txt +.github/workflows/ci/skiplist_job_test.txt +.github/workflows/ci/skiplist_job_test_packaging.txt +.gitignore +LICENSE.txt +linux/* +MANIFEST.in +osx/*_resources +osx/make_app.sh +pyproject.toml +reqs/build.txt +reqs/packaging.txt +reqs/setup.txt +test/test_* +windows/* +{EOF} diff --git a/.github/workflows/ci/skiplist_job_test_packaging.txt b/.github/workflows/ci/skiplist_job_test_packaging.txt index 52a906614..e90a35d37 100644 --- a/.github/workflows/ci/skiplist_job_test_packaging.txt +++ b/.github/workflows/ci/skiplist_job_test_packaging.txt @@ -1,5 +1,5 @@ .github/workflows/ci/skiplist_job_build.txt -.github/workflows/ci/skiplist_job_test.txt +.github/workflows/ci/skiplist_job_test*.txt .github/workflows/ci/skiplist_os_*.txt reqs/build.txt reqs/release.txt diff --git a/.github/workflows/ci/workflow_context.yml b/.github/workflows/ci/workflow_context.yml index f2a02b603..3c68b7964 100644 --- a/.github/workflows/ci/workflow_context.yml +++ b/.github/workflows/ci/workflow_context.yml @@ -39,6 +39,7 @@ jobs: type: test reqs: ['dist', 'test'] skiplists: ['job_test', 'os_linux'] + test_args: -p no:pytest-qt --ignore=test/gui_qt - <<: *test <<: *dist_macos cache_extra_deps: ['osx/deps.sh'] @@ -61,6 +62,14 @@ jobs: variant: Python 3.9 python: '3.9' + # Qt GUI tests. + - <<: *test + type: test_gui_qt + variant: Qt GUI + reqs: ['dist', 'dist_extra_gui_qt', 'test'] + skiplists: ['job_test_gui_qt'] + test_args: test/gui_qt + # Packaging tests. - <<: *dist_other type: test_packaging diff --git a/.github/workflows/ci/workflow_template.yml b/.github/workflows/ci/workflow_template.yml index 07bfa5a9f..359dc0099 100644 --- a/.github/workflows/ci/workflow_template.yml +++ b/.github/workflows/ci/workflow_template.yml @@ -145,11 +145,17 @@ jobs: <% endif %> <% endif %> - <% if j.type == 'test' %> + <% if j.type == 'test_gui_qt' %> + - name: Build UI + run: python setup.py build_ui + + <% endif %> + <% if j.type in ['test', 'test_gui_qt'] %> # Test {{{ - name: Run tests - run: run_tests + run: run_tests <@ j.test_args @> + # }}} diff --git a/MANIFEST.in b/MANIFEST.in index 04cefe43b..01c1941a7 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -22,9 +22,11 @@ include plover/messages/*/LC_MESSAGES/*.po include plover/messages/plover.pot include plover_build_utils/*.sh include pyproject.toml +include pytest.ini include reqs/*.txt include test/__init__.py include test/conftest.py +include test/gui_qt/test_*.py include tox.ini include windows/* # Exclude: CI/Git/GitHub specific files, diff --git a/news.d/api/1332.break.md b/news.d/api/1332.break.md new file mode 100644 index 000000000..268a01cfd --- /dev/null +++ b/news.d/api/1332.break.md @@ -0,0 +1,4 @@ +The custom `test` command implementation provided by `plover_build_utils.setup.Test` has been removed: +- support for it on setuptools' side has been deprecated since version 41.5.0 +- the custom handling of arguments conflicts with the use of some pytest options (e.g. `-m MARKEXPR`) +- the workaround for pytest cache handling is not necessary anymore diff --git a/news.d/feature/1308.ui.md b/news.d/feature/1308.ui.md new file mode 100644 index 000000000..ccf152084 --- /dev/null +++ b/news.d/feature/1308.ui.md @@ -0,0 +1,6 @@ +Improve accessibility: +- Disable tag-key navigation in tables, so focusing a table does not lock global tab-key navigation to it. +- Remove some container widget, tweak focus rules to avoid extra unnecessary steps when using tab-key navigation (like selecting the dictionaries widget outer frame). +- In form layouts, link each widget to its label (like each option in the configuration dialog). +- Set the accessible name / description of focusable widgets. +- Use lists for the dictionaries widget, suggestions widget, and the paper tape. diff --git a/news.d/feature/1332.ui.md b/news.d/feature/1332.ui.md new file mode 100644 index 000000000..ccf152084 --- /dev/null +++ b/news.d/feature/1332.ui.md @@ -0,0 +1,6 @@ +Improve accessibility: +- Disable tag-key navigation in tables, so focusing a table does not lock global tab-key navigation to it. +- Remove some container widget, tweak focus rules to avoid extra unnecessary steps when using tab-key navigation (like selecting the dictionaries widget outer frame). +- In form layouts, link each widget to its label (like each option in the configuration dialog). +- Set the accessible name / description of focusable widgets. +- Use lists for the dictionaries widget, suggestions widget, and the paper tape. diff --git a/plover/config.py b/plover/config.py index 5cafcb81b..fac9048e1 100644 --- a/plover/config.py +++ b/plover/config.py @@ -57,6 +57,9 @@ def replace(self, **kwargs): def from_dict(d): return DictionaryConfig(**d) + def __repr__(self): + return 'DictionaryConfig(%r, %r)' % (self.short_path, self.enabled) + ConfigOption = namedtuple('ConfigOption', ''' name default diff --git a/plover/gui_qt/add_translation_widget.ui b/plover/gui_qt/add_translation_widget.ui index 9c8937e75..98cab39aa 100644 --- a/plover/gui_qt/add_translation_widget.ui +++ b/plover/gui_qt/add_translation_widget.ui @@ -31,6 +31,16 @@ + + + + Dictionary: + + + dictionary + + + @@ -42,6 +52,9 @@ Strokes: + + strokes + @@ -52,6 +65,9 @@ 0 + + Strokes + @@ -65,6 +81,9 @@ Translation: + + translation + @@ -75,17 +94,19 @@ 0 - - - - - - Dictionary: + + Translation + + Dictionary + + + Select the target dictionary for the new translation. + false @@ -94,51 +115,58 @@ - + 0 0 + + Existing mappings (strokes) + QFrame::Box - - - - - true - Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + - + 0 0 + + Existing mappings (translations) + QFrame::Box - - - - - true - Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + strokes + translation + strokes_info + translation_info + dictionary + diff --git a/plover/gui_qt/config_file_widget.ui b/plover/gui_qt/config_file_widget.ui index 75d5f6029..602f64c7d 100644 --- a/plover/gui_qt/config_file_widget.ui +++ b/plover/gui_qt/config_file_widget.ui @@ -21,7 +21,14 @@ - + + + Log file path. + + + Path to the log file. + + @@ -31,6 +38,12 @@ 0 + + Browse. + + + Open a file picker to select the log file. + Browse diff --git a/plover/gui_qt/config_serial_widget.ui b/plover/gui_qt/config_serial_widget.ui index aaa1e4e15..3419b3565 100644 --- a/plover/gui_qt/config_serial_widget.ui +++ b/plover/gui_qt/config_serial_widget.ui @@ -13,287 +13,311 @@ - - - 0 - - - 0 - - - 0 - - - 0 - - - - - QFrame::StyledPanel + + Serial + + + + + + + 0 + 0 + - - QFrame::Sunken + + Connection - - - + + + - + 0 0 - - Connection + + Port + + + port - - - - - - 0 - 0 - - - - Port - - - - - - - - 0 - 0 - - - - true - - - QComboBox::InsertAtBottom - - - - - - - - 0 - 0 - - - - - 16777215 - 16777215 - - - - Scan - - - - - - - - 0 - 0 - - - - Baudrate - - - - - - - - 0 - 0 - - - - - - - - - Data format + + + + + 0 + 0 + + + + Port + + + Serial port device name. + + + true + + + QComboBox::InsertAtBottom - - - - - - 0 - 0 - - - - Data bits - - - - - - - - 0 - 0 - - - - - - - - - 0 - 0 - - - - Stop bits - - - - - - - - 0 - 0 - - - - - - - - - 0 - 0 - - - - Parity - - - - - - - - - - - + + + + + 0 + 0 + + + + + 16777215 + 16777215 + + + + Scan for available serial ports. + + + Scan + + + + + + + + 0 + 0 + + + + Baudrate + + + baudrate + + + + + + + + 0 + 0 + + + + Baudrate + + + + + + + + + + Data format + + + + + + + 0 + 0 + + + + Data bits + + + bytesize + + + + + + + + 0 + 0 + + + + Data bits + + + + + + + + 0 + 0 + + + + Stop bits + + + stopbits + + + + + + + + 0 + 0 + + + + Stop bits + + + + + + + + 0 + 0 + + + + Parity + + + parity + + + + + + + Parity + + + + + + + + + + + + + 0 + 0 + + + + + + + Timeout + + + + + + + 0 + 0 + + + + Duration + + + timeout + + + + + + + true + 0 0 - - Timeout + + Duration + + + Timeout duration in seconds. + + + 0.100000000000000 + + + + + + + + 0 + 0 + + + + Use timeout + + + + + + + + + + Flow control + + + + + + + 0 + 0 + + + + Xon/Xoff - - - - - - - true - - - - 0 - 0 - - - - 0.100000000000000 - - - - - - - - 0 - 0 - - - - seconds - - - - - - - - - - 0 - 0 - - - - Use timeout - - - - - - - Flow control + + + + 0 + 0 + + + + RTS/CTS - - - - - - 0 - 0 - - - - Xon/Xoff - - - - - - - - 0 - 0 - - - - RTS/CTS - - - - - - - + + + diff --git a/plover/gui_qt/config_window.py b/plover/gui_qt/config_window.py index cc71e056e..f9905ea4d 100644 --- a/plover/gui_qt/config_window.py +++ b/plover/gui_qt/config_window.py @@ -16,6 +16,7 @@ QFileDialog, QFormLayout, QFrame, + QGroupBox, QLabel, QScrollArea, QSpinBox, @@ -89,7 +90,7 @@ def on_activated(self, index): self.valueChanged.emit(self.itemData(index)) -class FileOption(QWidget, Ui_FileWidget): +class FileOption(QGroupBox, Ui_FileWidget): valueChanged = pyqtSignal(str) @@ -118,7 +119,36 @@ def on_path_edited(self): self.valueChanged.emit(expand_path(self.path.text())) -class KeymapOption(QTableWidget): +class TableOption(QTableWidget): + + def __init__(self): + super().__init__() + self.horizontalHeader().setStretchLastSection(True) + self.setSelectionMode(self.SingleSelection) + self.setTabKeyNavigation(False) + self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.verticalHeader().hide() + self.currentItemChanged.connect(self._on_current_item_changed) + + def _on_current_item_changed(self, current, previous): + # Ensure current item is visible. + parent = self.parent() + while parent is not None: + if isinstance(parent, QScrollArea): + row = current.row() + pos = self.pos() + x = pos.x() + y = ( + + pos.y() + + self.rowViewportPosition(row) + + self.rowHeight(row) + ) + parent.ensureVisible(x, y) + return + parent = parent.parent() + + +class KeymapOption(TableOption): valueChanged = pyqtSignal(QVariant) @@ -147,9 +177,6 @@ def __init__(self): # i18n: Widget: “KeymapOption”. _('Action'), )) - self.horizontalHeader().setStretchLastSection(True) - self.verticalHeader().hide() - self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.cellChanged.connect(self._on_cell_changed) def setValue(self, value): @@ -188,7 +215,7 @@ def _on_cell_changed(self, row, column): self.valueChanged.emit(self._value) -class MultipleChoicesOption(QTableWidget): +class MultipleChoicesOption(TableOption): valueChanged = pyqtSignal(QVariant) @@ -213,9 +240,6 @@ def __init__(self, choices=None, labels=None): } self.setColumnCount(2) self.setHorizontalHeaderLabels(labels) - self.horizontalHeader().setStretchLastSection(True) - self.verticalHeader().hide() - self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.cellChanged.connect(self._on_cell_changed) def setValue(self, value): @@ -276,6 +300,7 @@ def __init__(self, display_name, option_name, widget_class, self.dependents = dependents self.layout = None self.widget = None + self.label = None class ConfigWindow(QDialog, Ui_ConfigWindow, WindowState): @@ -387,33 +412,35 @@ def __init__(self, engine): self._supported_options.update(option.option_name for option in option_list) self._update_config() # Create and fill tabs. - options = {} + option_by_name = {} for section, option_list in mappings: layout = QFormLayout() for option in option_list: - widget = self._create_option_widget(option) - options[option.option_name] = option - option.tab_index = self.tabs.count() + option_by_name[option.option_name] = option option.layout = layout - option.widget = widget - label = QLabel(option.display_name) - label.setToolTip(option.help_text) - layout.addRow(label, widget) + option.widget = self._create_option_widget(option) + option.label = QLabel(option.display_name) + option.label.setToolTip(option.help_text) + option.label.setBuddy(option.widget) + layout.addRow(option.label, option.widget) frame = QFrame() frame.setLayout(layout) + frame.setAccessibleName(section) + frame.setFocusProxy(option_list[0].widget) scroll_area = QScrollArea() scroll_area.setWidgetResizable(True) scroll_area.setWidget(frame) + scroll_area.setFocusProxy(frame) self.tabs.addTab(scroll_area, section) # Update dependents. - for option in options.values(): + for option in option_by_name.values(): option.dependents = [ - (options[option_name], update_fn) + (option_by_name[option_name], update_fn) for option_name, update_fn in option.dependents ] - buttons = self.findChild(QWidget, 'buttons') - buttons.button(QDialogButtonBox.Ok).clicked.connect(self.on_apply) - buttons.button(QDialogButtonBox.Apply).clicked.connect(self.on_apply) + self.buttons.button(QDialogButtonBox.Ok).clicked.connect(self.on_apply) + self.buttons.button(QDialogButtonBox.Apply).clicked.connect(self.on_apply) + self.tabs.currentWidget().setFocus() self.restore_state() self.finished.connect(self.save_state) @@ -457,6 +484,8 @@ def _update_keymap(self, system_name=None, machine_type=None): def _create_option_widget(self, option): widget = option.widget_class() widget.setToolTip(option.help_text) + widget.setAccessibleName(option.display_name) + widget.setAccessibleDescription(option.help_text) widget.valueChanged.connect(partial(self.on_option_changed, option)) widget.setValue(self._config[option.option_name]) return widget @@ -476,6 +505,7 @@ def on_option_changed(self, option, value): self._config.maps[1][dependent.option_name] = update_fn(value) widget = self._create_option_widget(dependent) dependent.layout.replaceWidget(dependent.widget, widget) + dependent.label.setBuddy(widget) dependent.widget.deleteLater() dependent.widget = widget diff --git a/plover/gui_qt/dictionaries_widget.py b/plover/gui_qt/dictionaries_widget.py index 94f29eac9..79f75265e 100644 --- a/plover/gui_qt/dictionaries_widget.py +++ b/plover/gui_qt/dictionaries_widget.py @@ -3,19 +3,16 @@ import os from PyQt5.QtCore import ( - QItemSelection, - QItemSelectionModel, - QVariant, + QAbstractListModel, + QModelIndex, Qt, pyqtSignal, ) from PyQt5.QtGui import QCursor, QIcon from PyQt5.QtWidgets import ( - QApplication, QFileDialog, + QGroupBox, QMenu, - QTableWidgetItem, - QWidget, ) from plover import _ @@ -61,29 +58,396 @@ def _new_dictionary(filename): raise Exception('creating dictionary %s failed. %s' % (filename, e)) from e -class DictionariesWidget(QWidget, Ui_DictionariesWidget): +class DictionariesModel(QAbstractListModel): - add_translation = pyqtSignal(QVariant) + class DictionaryItem: + + __slots__ = 'row path enabled short_path _loaded state'.split() + + def __init__(self, row, config, loaded=None): + self.row = row + self.path = config.path + self.enabled = config.enabled + self.short_path = config.short_path + self.loaded = loaded + + @property + def loaded(self): + return self._loaded + + @loaded.setter + def loaded(self, loaded): + if loaded is None: + state = 'loading' + elif isinstance(loaded, ErroredDictionary): + state = 'error' + elif loaded.readonly: + state = 'readonly' + else: + state = 'normal' + self.state = state + self._loaded = loaded + + @property + def config(self): + return DictionaryConfig(self.path, self.enabled) + + @property + def is_loaded(self): + return self.state not in {'loading', 'error'} + + SUPPORTED_ROLES = { + Qt.AccessibleTextRole, Qt.CheckStateRole, + Qt.DecorationRole, Qt.DisplayRole, Qt.ToolTipRole + } + + FLAGS = Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsUserCheckable + + has_undo_changed = pyqtSignal(bool) + + def __init__(self, engine, icons, max_undo=20): + super().__init__() + self._engine = engine + self._favorite = None + self._from_path = {} + self._from_row = [] + self._reverse_order = False + self._undo_stack = [] + self._max_undo = max_undo + self._icons = icons + with engine: + config = engine.config + engine.signal_connect('config_changed', self._on_config_changed) + engine.signal_connect('dictionaries_loaded', self._on_dictionaries_loaded) + self._reset_items(config['dictionaries'], + config['classic_dictionaries_display_order'], + backup=False, publish=False) + + @property + def _config(self): + item_list = self._from_row + if self._reverse_order: + item_list = reversed(item_list) + return [item.config for item in item_list] + + def _updated_rows(self, row_list): + for row in row_list: + index = self.index(row) + self.dataChanged.emit(index, index, self.SUPPORTED_ROLES) + + def _backup_config(self): + config = self._config + assert not self._undo_stack or config != self._undo_stack[-1] + self._undo_stack.append(config) + if len(self._undo_stack) == 1: + self.has_undo_changed.emit(True) + elif len(self._undo_stack) > self._max_undo: + self._undo_stack.pop(0) + + def _publish_config(self, config): + self._engine.config = {'dictionaries': config} + + def _update_favorite(self): + item_list = self._from_row + if self._reverse_order: + item_list = reversed(item_list) + old, new = self._favorite, None + for item in item_list: + if item.enabled and item.state == 'normal': + new = item + break + if new is old: + return set() + self._favorite = new + return { + favorite.row for favorite in + filter(None, (old, new)) + } + + def _reset_items(self, config, reverse_order=None, backup=True, publish=True): + if backup: + self._backup_config() + if reverse_order is None: + reverse_order = self._reverse_order + self.layoutAboutToBeChanged.emit() + old_persistent_indexes = self.persistentIndexList() + assert all(index.isValid() for index in old_persistent_indexes) + old_persistent_items = [ + self._from_row[index.row()] + for index in old_persistent_indexes + ] + from_row = [] + from_path = {} + for row, d in enumerate(reversed(config) if reverse_order else config): + item = self._from_path.get(d.path) + if item is None: + item = self.DictionaryItem(row, d) + else: + item.row = row + item.enabled = d.enabled + assert d.path not in from_path + from_path[d.path] = item + from_row.append(item) + self._reverse_order = reverse_order + self._from_path = from_path + self._from_row = from_row + self._update_favorite() + new_persistent_indexes = [] + for old_item in old_persistent_items: + new_item = self._from_path.get(old_item.path) + if new_item is None: + new_index = QModelIndex() + else: + new_index = self.index(new_item.row) + new_persistent_indexes.append(new_index) + self.changePersistentIndexList(old_persistent_indexes, + new_persistent_indexes) + self.layoutChanged.emit() + if publish: + self._publish_config(config) + + def _on_config_changed(self, config_update): + config = config_update.get('dictionaries') + reverse_order = config_update.get('classic_dictionaries_display_order') + noop = True + if reverse_order is not None: + noop = reverse_order == self._reverse_order + if config is not None: + noop = noop and config == self._config + if noop: + return + if config is None: + config = self._config + else: + if self._undo_stack: + self.has_undo_changed.emit(False) + self._undo_stack.clear() + self._reset_items(config, reverse_order, + backup=False, publish=False) + + def _on_dictionaries_loaded(self, loaded_dictionaries): + updated_rows = set() + for item in self._from_row: + loaded = loaded_dictionaries.get(item.path) + if loaded == item.loaded: + continue + item.loaded = loaded + updated_rows.add(item.row) + if not updated_rows: + return + updated_rows.update(self._update_favorite()) + self._updated_rows(updated_rows) + + def _move(self, index_list, step): + row_list = sorted(self._normalized_row_list(index_list)) + if not row_list: + return + old_path_list = [item.path for item in self._from_row] + new_path_list = old_path_list[:] + if step > 0: + row_list = reversed(row_list) + row_limit = len(self._from_row) - 1 + update_row = min + else: + row_limit = 0 + update_row = max + for old_row in row_list: + new_row = update_row(old_row + step, row_limit) + new_path_list.insert(new_row, new_path_list.pop(old_row)) + row_limit = new_row - step + if old_path_list == new_path_list: + return + if self._reverse_order: + new_path_list = reversed(new_path_list) + config = [ + self._from_path[path].config + for path in new_path_list + ] + self._reset_items(config) + + @staticmethod + def _normalized_path_list(path_list): + return list(dict.fromkeys(map(normalize_path, path_list))) + + @staticmethod + def _normalized_row_list(index_list): + return list(dict.fromkeys(index.row() + for index in index_list + if index.isValid())) + + def _insert(self, dest_row, path_list): + old_path_list = [item.path for item in self._from_row] + new_path_list = ( + [p for p in old_path_list[:dest_row] if p not in path_list] + + path_list + + [p for p in old_path_list[dest_row:] if p not in path_list] + ) + if new_path_list == old_path_list: + return + if self._reverse_order: + new_path_list = reversed(new_path_list) + config = [ + self._from_path[path].config + if path in self._from_path + else DictionaryConfig(path) + for path in new_path_list + ] + self._reset_items(config) + + def add(self, path_list): + new_path_list = self._normalized_path_list( + path for path in path_list + if path not in self._from_path + ) + if new_path_list: + # Add with highest priority. + if self._reverse_order: + dest_row = len(self._from_path) + new_path_list = reversed(new_path_list) + else: + dest_row = 0 + self._insert(dest_row, new_path_list) + elif path_list: + # Trigger a reload, just in case. + self._engine.config = {} + + def iter_loaded(self, index_list): + row_list = sorted(self._normalized_row_list(index_list)) + if self._reverse_order: + row_list = reversed(row_list) + for row in row_list: + item = self._from_row[row] + if item.is_loaded: + yield item.loaded + + def insert(self, index, path_list): + # Insert at the end if `index` is not valid. + row = index.row() if index.isValid() else len(self._from_row) + self._insert(row, self._normalized_path_list(path_list)) + + def move(self, dst_index, src_index_list): + self.insert(dst_index, [self._from_row[row].path for row in + self._normalized_row_list(src_index_list)]) + + def move_down(self, index_list): + self._move(index_list, +1) + + def move_up(self, index_list): + self._move(index_list, -1) + + def remove(self, index_list): + row_set = self._normalized_row_list(index_list) + if not row_set: + return + config = [item.config + for item in self._from_row + if item.row not in row_set] + self._reset_items(config) + + def undo(self): + config = self._undo_stack.pop() + if not self._undo_stack: + self.has_undo_changed.emit(False) + self._reset_items(config, backup=False) + + # Model API. + + def rowCount(self, parent=QModelIndex()): + return 0 if parent.isValid() else len(self._from_row) + + @classmethod + def flags(cls, index): + return cls.FLAGS if index.isValid() else Qt.NoItemFlags + + def data(self, index, role): + if not index.isValid() or role not in self.SUPPORTED_ROLES: + return None + d = self._from_row[index.row()] + if role == Qt.DisplayRole: + return d.short_path + if role == Qt.CheckStateRole: + return Qt.Checked if d.enabled else Qt.Unchecked + if role == Qt.AccessibleTextRole: + accessible_text = [d.short_path] + if not d.enabled: + # i18n: Widget: “DictionariesWidget”, accessible text. + accessible_text.append(_('disabled')) + if d is self._favorite: + # i18n: Widget: “DictionariesWidget”, accessible text. + accessible_text.append(_('favorite')) + elif d.state == 'error': + # i18n: Widget: “DictionariesWidget”, accessible text. + accessible_text.append(_('errored: {exception}.').format( + exception=str(d.loaded.exception))) + elif d.state == 'loading': + # i18n: Widget: “DictionariesWidget”, accessible text. + accessible_text.append(_('loading')) + elif d.state == 'readonly': + # i18n: Widget: “DictionariesWidget”, accessible text. + accessible_text.append(_('read-only')) + return ', '.join(accessible_text) + if role == Qt.DecorationRole: + return self._icons.get('favorite' if d is self._favorite else d.state) + if role == Qt.ToolTipRole: + # i18n: Widget: “DictionariesWidget”, tooltip. + tooltip = [_('Full path: {path}.').format(path=d.config.path)] + if d is self._favorite: + # i18n: Widget: “DictionariesWidget”, tool tip. + tooltip.append(_('This dictionary is marked as the favorite.')) + elif d.state == 'loading': + # i18n: Widget: “DictionariesWidget”, tool tip. + tooltip.append(_('This dictionary is being loaded.')) + elif d.state == 'error': + # i18n: Widget: “DictionariesWidget”, tool tip. + tooltip.append(_('Loading this dictionary failed: {exception}.') + .format(exception=str(d.loaded.exception))) + elif d.state == 'readonly': + # i18n: Widget: “DictionariesWidget”, tool tip. + tooltip.append(_('This dictionary is read-only.')) + return '\n\n'.join(tooltip) + return None + + def setData(self, index, value, role): + if not index.isValid() or role != Qt.CheckStateRole: + return False + if value == Qt.Checked: + enabled = True + elif value == Qt.Unchecked: + enabled = False + else: + return False + d = self._from_row[index.row()] + if d.enabled == enabled: + return False + self._backup_config() + d.enabled = enabled + self._updated_rows({d.row} | self._update_favorite()) + self._publish_config(self._config) + return True + + +class DictionariesWidget(QGroupBox, Ui_DictionariesWidget): + + add_translation = pyqtSignal(str) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.setupUi(self) - engine = QApplication.instance().engine - self._engine = engine - self._states = [] - self._updating = False - self._config_dictionaries = {} - self._loaded_dictionaries = {} - self._reverse_order = False + self._setup = False + self._engine = None + self._model = None # The save/open/new dialogs will open on that directory. self._file_dialogs_directory = CONFIG_DIR + # Start with all actions disabled (until `setup` is called). for action in ( - self.action_Undo, + self.action_AddDictionaries, + self.action_AddTranslation, self.action_EditDictionaries, - self.action_SaveDictionaries, - self.action_RemoveDictionaries, - self.action_MoveDictionariesUp, self.action_MoveDictionariesDown, + self.action_MoveDictionariesUp, + self.action_RemoveDictionaries, + self.action_SaveDictionaries, + self.action_Undo, ): action.setEnabled(False) # Toolbar. @@ -101,274 +465,90 @@ def __init__(self, *args, **kwargs): # Add menu. self.menu_AddDictionaries = QMenu(self.action_AddDictionaries.text()) self.menu_AddDictionaries.setIcon(self.action_AddDictionaries.icon()) - self.menu_AddDictionaries.addAction( - # i18n: Widget: “DictionariesWidget”, “add” menu. - _('Open dictionaries'), - ).triggered.connect(self._add_existing_dictionaries) - self.menu_AddDictionaries.addAction( - # i18n: Widget: “DictionariesWidget”, “add” menu. - _('New dictionary'), - ).triggered.connect(self._create_new_dictionary) + self.menu_AddDictionaries.addAction(self.action_OpenDictionaries) + self.menu_AddDictionaries.addAction(self.action_CreateDictionary) + self.action_AddDictionaries.setMenu(self.menu_AddDictionaries) # Save menu. self.menu_SaveDictionaries = QMenu(self.action_SaveDictionaries.text()) self.menu_SaveDictionaries.setIcon(self.action_SaveDictionaries.icon()) - self.menu_SaveDictionaries.addAction( - # i18n: Widget: “DictionariesWidget”, “save” menu. - _('Create a copy of each dictionary'), - ).triggered.connect(self._save_dictionaries) - self.menu_SaveDictionaries.addAction( - # i18n: Widget: “DictionariesWidget”, “save” menu. - _('Merge dictionaries into a new one'), - ).triggered.connect(functools.partial(self._save_dictionaries, - merge=True)) - self.table.supportedDropActions = self._supported_drop_actions - self.table.dragEnterEvent = self._drag_enter_event - self.table.dragMoveEvent = self._drag_move_event - self.table.dropEvent = self._drop_event - engine.signal_connect('config_changed', self.on_config_changed) - engine.signal_connect('dictionaries_loaded', self.on_dictionaries_loaded) - - def setFocus(self): - self.table.setFocus() - - def on_dictionaries_loaded(self, loaded_dictionaries): - self._update_dictionaries(loaded_dictionaries=loaded_dictionaries, - record=False, save=False) - - def on_config_changed(self, config_update): - update_kwargs = {} - if 'dictionaries' in config_update: - update_kwargs.update( - config_dictionaries=config_update['dictionaries'], - record=False, save=False, reset_undo=True - ) - if 'classic_dictionaries_display_order' in config_update: - update_kwargs.update( - reverse_order=config_update['classic_dictionaries_display_order'], - record=False, save=False, - ) - if update_kwargs: - self._update_dictionaries(**update_kwargs) - - def _update_dictionaries(self, config_dictionaries=None, loaded_dictionaries=None, - reverse_order=None, record=True, save=True, - reset_undo=False, keep_selection=True): - if reverse_order is None: - reverse_order = self._reverse_order - if config_dictionaries is None: - config_dictionaries = self._config_dictionaries - if config_dictionaries == self._config_dictionaries and \ - reverse_order == self._reverse_order and \ - loaded_dictionaries is None: - return - if save: - self._engine.config = { 'dictionaries': config_dictionaries } - if record: - self._states.append(self._config_dictionaries) - self.action_Undo.setEnabled(True) - if keep_selection: - selected = [ - self._config_dictionaries[row].path - for row in self._get_selection() - ] - self._config_dictionaries = config_dictionaries - if loaded_dictionaries is None: - loaded_dictionaries = self._loaded_dictionaries - else: - self._loaded_dictionaries = loaded_dictionaries - self._reverse_order = reverse_order - self._updating = True - self.table.setRowCount(len(config_dictionaries)) - favorite_set = False - for n, dictionary in enumerate(config_dictionaries): - row = n - if self._reverse_order: - row = len(config_dictionaries) - row - 1 - item = QTableWidgetItem(dictionary.short_path) - item.setFlags((item.flags() | Qt.ItemIsUserCheckable) & ~Qt.ItemIsEditable) - item.setCheckState(Qt.Checked if dictionary.enabled else Qt.Unchecked) - item.setToolTip(dictionary.path) - self.table.setItem(row, 0, item) - item = QTableWidgetItem(str(n + 1)) - if dictionary.path not in loaded_dictionaries: - icon = 'loading' - # i18n: Widget: “DictionariesWidget”, tooltip. - tooltip = _('This dictionary is being loaded.') - else: - d = loaded_dictionaries.get(dictionary.path) - if isinstance(d, ErroredDictionary): - icon = 'error' - tooltip = str(d.exception) - elif d.readonly: - icon = 'readonly' - # i18n: Widget: “DictionariesWidget”, tooltip. - tooltip = _('This dictionary is read-only.') - elif not favorite_set and dictionary.enabled: - icon = 'favorite' - # i18n: Widget: “DictionariesWidget”, tooltip. - tooltip = _('This dictionary is marked as a favorite.') - favorite_set = True - else: - icon = 'normal' - tooltip = '' - item.setIcon(QIcon(':/dictionary_%s.svg' % icon)) - item.setToolTip(tooltip) - self.table.setVerticalHeaderItem(row, item) - if keep_selection: - row_list = [] - for path in selected: - for n, d in enumerate(config_dictionaries): - if d.path == path: - row_list.append(n) - break - self._set_selection(row_list) - if reset_undo: - self.action_Undo.setEnabled(False) - self._states = [] - self._updating = False - self.on_selection_changed() - - @staticmethod - def _supported_drop_actions(): - return Qt.CopyAction | Qt.LinkAction | Qt.MoveAction - - def is_accepted_drag_event(self, event): - if event.source() == self.table: - return True - mime = event.mimeData() - if mime.hasUrls(): - for url in mime.urls(): - # Only support local files. + self.menu_SaveDictionaries.addAction(self.action_CopyDictionaries) + self.menu_SaveDictionaries.addAction(self.action_MergeDictionaries) + self.view.dragEnterEvent = self._drag_enter_event + self.view.dragMoveEvent = self._drag_move_event + self.view.dropEvent = self._drag_drop_event + self.setFocusProxy(self.view) + # Edit context menu. + edit_menu = QMenu() + edit_menu.addAction(self.action_Undo) + edit_menu.addSeparator() + edit_menu.addMenu(self.menu_AddDictionaries) + edit_menu.addAction(self.action_EditDictionaries) + edit_menu.addMenu(self.menu_SaveDictionaries) + edit_menu.addAction(self.action_RemoveDictionaries) + edit_menu.addSeparator() + edit_menu.addAction(self.action_MoveDictionariesUp) + edit_menu.addAction(self.action_MoveDictionariesDown) + self.view.setContextMenuPolicy(Qt.CustomContextMenu) + self.view.customContextMenuRequested.connect( + lambda p: edit_menu.exec_(self.view.mapToGlobal(p))) + self.edit_menu = edit_menu + + def setup(self, engine): + assert not self._setup + self._engine = engine + self._model = DictionariesModel(engine, { + name: QIcon(':/dictionary_%s.svg' % name) + for name in 'favorite loading error readonly normal'.split() + }) + self._model.has_undo_changed.connect(self.on_has_undo) + self.view.setModel(self._model) + self.view.selectionModel().selectionChanged.connect(self.on_selection_changed) + for action in ( + self.action_AddDictionaries, + self.action_AddTranslation, + ): + action.setEnabled(True) + self._setup = True + + @property + def _selected(self): + return sorted(self.view.selectedIndexes(), + key=lambda index: index.row()) + + def _drag_accept(self, event): + accepted = False + if event.source() is self.view: + accepted = True + elif event.mimeData().hasUrls(): + # Only allow a list of supported local files. + for url in event.mimeData().urls(): if not url.isLocalFile(): break - # And only allow supported extensions. filename = url.toLocalFile() extension = os.path.splitext(filename)[1].lower()[1:] if extension not in _dictionary_formats(): break else: - return True - return False + accepted = True + if accepted: + event.accept() + return accepted def _drag_enter_event(self, event): - if self.is_accepted_drag_event(event): - event.accept() + self._drag_accept(event) def _drag_move_event(self, event): - if self.is_accepted_drag_event(event): - event.accept() + self._drag_accept(event) - def _drop_event(self, event): - if not self.is_accepted_drag_event(event): + def _drag_drop_event(self, event): + if not self._drag_accept(event): return - dictionaries = self._config_dictionaries[:] - dest_item = self.table.itemAt(event.pos()) - if dest_item is None: - if self._reverse_order: - dest_index = 0 - else: - dest_index = len(self._config_dictionaries) + index = self.view.indexAt(event.pos()) + if event.source() == self.view: + self._model.move(index, self._selected) else: - dest_index = dest_item.row() - if self._reverse_order: - dest_index = len(self._config_dictionaries) - dest_index - 1 - if event.source() == self.table: - sources = [ - dictionaries[row] - for row in self._get_selection() - ] - else: - sources = [ - DictionaryConfig(url.toLocalFile()) - for url in event.mimeData().urls() - ] - for dictionary in sources: - try: - source_index = [d.path for d in dictionaries].index(dictionary.path) - except ValueError: - pass - else: - if source_index == dest_index: - dest_index += 1 - continue - del dictionaries[source_index] - if source_index < dest_index: - dest_index -= 1 - dictionaries.insert(dest_index, dictionary) - dest_index += 1 - self._update_dictionaries(dictionaries, keep_selection=False) - - def _get_selection(self): - row_list = [item.row() for item in self.table.selectedItems()] - if self._reverse_order: - row_count = len(self._config_dictionaries) - row_list = [row_count - row - 1 for row in row_list] - row_list.sort() - return row_list - - def _set_selection(self, row_list): - selection = QItemSelection() - model = self.table.model() - for row in row_list: - if self._reverse_order: - row = len(self._config_dictionaries) - row - 1 - index = model.index(row, 0) - selection.select(index, index) - self.table.selectionModel().select(selection, QItemSelectionModel.Rows | - QItemSelectionModel.ClearAndSelect | - QItemSelectionModel.Current) - - def on_selection_changed(self): - if self._updating: - return - selection = self._get_selection() - has_selection = bool(selection) - for widget in ( - self.action_RemoveDictionaries, - self.action_MoveDictionariesUp, - self.action_MoveDictionariesDown, - ): - widget.setEnabled(has_selection) - has_live_selection = any( - self._config_dictionaries[row].path in self._loaded_dictionaries - for row in selection - ) - for widget in ( - self.action_EditDictionaries, - self.action_SaveDictionaries, - self.menu_SaveDictionaries, - ): - widget.setEnabled(has_live_selection) - - def on_dictionary_changed(self, item): - if self._updating: - return - row = item.row() - if self._reverse_order: - row = len(self._config_dictionaries) - row - 1 - dictionaries = self._config_dictionaries[:] - dictionaries[row] = dictionaries[row].replace( - enabled=bool(item.checkState() == Qt.Checked) - ) - self._update_dictionaries(dictionaries) - - def on_undo(self): - assert self._states - dictionaries = self._states.pop() - self.action_Undo.setEnabled(bool(self._states)) - self._update_dictionaries(dictionaries, record=False) - - def _edit(self, dictionaries): - editor = DictionaryEditor(self._engine, [d.path for d in dictionaries]) - editor.exec_() - - def on_activate_cell(self, row, col): - self._edit([self._config_dictionaries[row]]) - - def on_edit_dictionaries(self): - selection = self._get_selection() - assert selection - self._edit([self._config_dictionaries[row] for row in selection]) + path_list = [url.toLocalFile() for url in event.mimeData().urls()] + self._model.insert(index, path_list) def _get_dictionary_save_name(self, title, default_name=None, default_extensions=(), initial_filename=None): @@ -393,29 +573,36 @@ def _get_dictionary_save_name(self, title, default_name=None, return None return new_filename - def _copy_dictionaries(self, dictionaries_list): + def _edit_dictionaries(self, index_list): + path_list = [d.path for d in self._model.iter_loaded(index_list)] + if not path_list: + return + editor = DictionaryEditor(self._engine, path_list) + editor.exec_() + + def _copy_dictionaries(self, dictionaries): need_reload = False # i18n: Widget: “DictionariesWidget”, “save as copy” file picker. title_template = _('Save a copy of {name} as...') # i18n: Widget: “DictionariesWidget”, “save as copy” file picker. default_name_template = _('{name} - Copy') - for dictionary in dictionaries_list: - title = title_template.format(name=dictionary.short_path) - name, ext = os.path.splitext(os.path.basename(dictionary.path)) + for original in dictionaries: + title = title_template.format(name=os.path.basename(original.path)) + name, ext = os.path.splitext(os.path.basename(original.path)) default_name = default_name_template.format(name=name) new_filename = self._get_dictionary_save_name(title, default_name, [ext[1:]], - initial_filename=dictionary.path) + initial_filename=original.path) if new_filename is None: continue - with _new_dictionary(new_filename) as d: - d.update(self._loaded_dictionaries[dictionary.path]) + with _new_dictionary(new_filename) as copy: + copy.update(original) need_reload = True return need_reload - def _merge_dictionaries(self, dictionaries_list): + def _merge_dictionaries(self, dictionaries): names, exts = zip(*( os.path.splitext(os.path.basename(d.path)) - for d in dictionaries_list)) + for d in dictionaries)) default_name = ' + '.join(names) default_exts = list(dict.fromkeys(e[1:] for e in exts)) # i18n: Widget: “DictionariesWidget”, “save as merge” file picker. @@ -423,121 +610,94 @@ def _merge_dictionaries(self, dictionaries_list): new_filename = self._get_dictionary_save_name(title, default_name, default_exts) if new_filename is None: return False - with _new_dictionary(new_filename) as d: + with _new_dictionary(new_filename) as merge: # Merge in reverse priority order, so higher # priority entries overwrite lower ones. - for dictionary in reversed(dictionaries_list): - d.update(self._loaded_dictionaries[dictionary.path]) + for source in reversed(dictionaries): + merge.update(source) return True def _save_dictionaries(self, merge=False): - selection = self._get_selection() - assert selection - dictionaries_list = [self._config_dictionaries[row] - for row in selection] # Ignore dictionaries that are not loaded. - dictionaries_list = [dictionary - for dictionary in dictionaries_list - if dictionary.path in self._loaded_dictionaries] - if not dictionaries_list: + dictionaries = list(self._model.iter_loaded(self._selected)) + if not dictionaries: return if merge: save_fn = self._merge_dictionaries else: save_fn = self._copy_dictionaries - if save_fn(dictionaries_list): + if save_fn(dictionaries): # This will trigger a reload of any modified dictionary. self._engine.config = {} - def on_remove_dictionaries(self): - selection = self._get_selection() - assert selection - dictionaries = self._config_dictionaries[:] - for row in sorted(selection, reverse=True): - del dictionaries[row] - self._update_dictionaries(dictionaries, keep_selection=False) - - def on_add_dictionaries(self): - self.menu_AddDictionaries.exec_(QCursor.pos()) - - def _add_existing_dictionaries(self): + def on_open_dictionaries(self): new_filenames = QFileDialog.getOpenFileNames( # i18n: Widget: “DictionariesWidget”, “add” file picker. parent=self, caption=_('Add dictionaries'), directory=self._file_dialogs_directory, filter=_dictionary_filters(), )[0] - dictionaries = self._config_dictionaries[:] - for filename in new_filenames: - filename = normalize_path(filename) - self._file_dialogs_directory = os.path.dirname(filename) - for d in dictionaries: - if d.path == filename: - break - else: - dictionaries.insert(0, DictionaryConfig(filename)) - self._update_dictionaries(dictionaries, keep_selection=False) + if not new_filenames: + return + self._file_dialogs_directory = os.path.dirname(new_filenames[-1]) + self._model.add(new_filenames) - def _create_new_dictionary(self): + def on_create_dictionary(self): # i18n: Widget: “DictionariesWidget”, “new” file picker. new_filename = self._get_dictionary_save_name(_('New dictionary')) if new_filename is None: return - with _new_dictionary(new_filename) as d: + with _new_dictionary(new_filename): pass - dictionaries = self._config_dictionaries[:] - for d in dictionaries: - if d.path == new_filename: - break - else: - dictionaries.insert(0, DictionaryConfig(new_filename)) - # Note: pass in `loaded_dictionaries` to force update (use case: - # the user decided to overwrite an already loaded dictionary). - self._update_dictionaries(dictionaries, keep_selection=False, - loaded_dictionaries=self._loaded_dictionaries) + self._model.add([new_filename]) + + def on_copy_dictionaries(self): + self._save_dictionaries() + + def on_merge_dictionaries(self): + self._save_dictionaries(merge=True) + + def on_activate_dictionary(self, index): + self._edit_dictionaries([index]) + + def on_add_dictionaries(self): + self.menu_AddDictionaries.exec_(QCursor.pos()) def on_add_translation(self): - selection = self._get_selection() - if selection: - dictionary_path = self._config_dictionaries[selection[0]].path - else: - dictionary_path = None - self.add_translation.emit(dictionary_path) + dictionary = next(self._model.iter_loaded([self.view.currentIndex()]), None) + self.add_translation.emit(None if dictionary is None else dictionary.path) - def on_move_dictionaries_up(self): - if self._reverse_order: - self._decrease_dictionaries_priority() - else: - self._increase_dictionaries_priority() + def on_edit_dictionaries(self): + self._edit_dictionaries(self._selected) + + def on_has_undo(self, available): + self.action_Undo.setEnabled(available) def on_move_dictionaries_down(self): - if self._reverse_order: - self._increase_dictionaries_priority() - else: - self._decrease_dictionaries_priority() - - def _increase_dictionaries_priority(self): - dictionaries = self._config_dictionaries[:] - selection = [] - min_row = 0 - for old_row in self._get_selection(): - new_row = max(min_row, old_row - 1) - dictionaries.insert(new_row, dictionaries.pop(old_row)) - selection.append(new_row) - min_row = new_row + 1 - if dictionaries == self._config_dictionaries: - return - self._update_dictionaries(dictionaries) - - def _decrease_dictionaries_priority(self): - dictionaries = self._config_dictionaries[:] - selection = [] - max_row = len(dictionaries) - 1 - for old_row in reversed(self._get_selection()): - new_row = min(max_row, old_row + 1) - dictionaries.insert(new_row, dictionaries.pop(old_row)) - selection.append(new_row) - max_row = new_row - 1 - if dictionaries == self._config_dictionaries: - return - self._update_dictionaries(dictionaries) + self._model.move_down(self._selected) + + def on_move_dictionaries_up(self): + self._model.move_up(self._selected) + + def on_remove_dictionaries(self): + self._model.remove(self._selected) + + def on_selection_changed(self): + selection = self._selected + has_selection = bool(selection) + for widget in ( + self.action_RemoveDictionaries, + self.action_MoveDictionariesUp, + self.action_MoveDictionariesDown, + ): + widget.setEnabled(has_selection) + has_live_selection = next(self._model.iter_loaded(selection), None) is not None + for widget in ( + self.action_EditDictionaries, + self.action_SaveDictionaries, + self.menu_SaveDictionaries, + ): + widget.setEnabled(has_live_selection) + + def on_undo(self): + self._model.undo() diff --git a/plover/gui_qt/dictionaries_widget.ui b/plover/gui_qt/dictionaries_widget.ui index 3ee2f93f7..bf3606364 100644 --- a/plover/gui_qt/dictionaries_widget.ui +++ b/plover/gui_qt/dictionaries_widget.ui @@ -1,7 +1,7 @@ DictionariesWidget - + 0 @@ -19,6 +19,9 @@ + + Dictionaries + 0 @@ -33,10 +36,16 @@ 0 - + + + Dictionaries + QFrame::Box + + false + true @@ -55,20 +64,18 @@ true + + QAbstractItemView::ExtendedSelection + QAbstractItemView::SelectRows Qt::ElideMiddle - + true - - - - Dictionaries - - + @@ -186,28 +193,32 @@ Move selected dictionaries down. + + + Create a copy of each dictionary + + + + + Merge dictionaries into a new one + + + + + Open dictionaries + + + + + New dictionary + + - - table - itemSelectionChanged() - DictionariesWidget - on_selection_changed() - - - 199 - 149 - - - 199 - 149 - - - action_RemoveDictionaries triggered() @@ -289,10 +300,10 @@ - table - cellActivated(int,int) + view + activated(QModelIndex) DictionariesWidget - on_activate_cell(int,int) + on_activate_dictionary(QModelIndex) 199 @@ -337,15 +348,63 @@ - table - itemChanged() + action_OpenDictionaries + triggered() + DictionariesWidget + on_open_dictionaries() + + + -1 + -1 + + + 182 + 118 + + + + + action_CreateDictionary + triggered() DictionariesWidget - on_dictionary_changed(QTableWidgetItem*) + on_create_dictionary() + -1 + -1 + + 182 118 + + + + action_MergeDictionaries + triggered() + DictionariesWidget + on_merge_dictionaries() + + + -1 + -1 + + + 182 + 118 + + + + + action_CopyDictionaries + triggered() + DictionariesWidget + on_copy_dictionaries() + + + -1 + -1 + 182 118 @@ -354,15 +413,17 @@ - on_selection_changed() on_remove_dictionaries() on_undo() on_edit_dictionaries() on_add_dictionaries() on_add_translation() - on_activate_cell(int,int) + on_activate_dictionary(QModelIndex) on_move_dictionaries_up() on_move_dictionaries_down() - on_dictionary_changed(QTableWidgetItem*) + on_open_dictionaries() + on_create_dictionary() + on_merge_dictionaries() + on_copy_dictionaries() diff --git a/plover/gui_qt/dictionary_editor.ui b/plover/gui_qt/dictionary_editor.ui index 4402b7806..0f2ae08a7 100644 --- a/plover/gui_qt/dictionary_editor.ui +++ b/plover/gui_qt/dictionary_editor.ui @@ -22,6 +22,9 @@ + + Filter + Filter @@ -37,10 +40,17 @@ By strokes: + + strokes_filter + - + + + Strokes filter + + @@ -50,6 +60,9 @@ 0 + + Apply filter + Apply @@ -60,13 +73,26 @@ By translation: + + translation_filter + - + + + Translation filter + + + + Clear filter + + + + Clear @@ -77,9 +103,15 @@ + + Mappings + QFrame::Box + + false + true @@ -163,6 +195,13 @@ + + table + strokes_filter + translation_filter + pushButton + pushButton_2 + diff --git a/plover/gui_qt/lookup_dialog.ui b/plover/gui_qt/lookup_dialog.ui index 6e755070b..61b7713f7 100644 --- a/plover/gui_qt/lookup_dialog.ui +++ b/plover/gui_qt/lookup_dialog.ui @@ -41,6 +41,12 @@ 0 + + Pattern + + + Translation pattern to lookup. + @@ -51,6 +57,9 @@ 0 + + Results + diff --git a/plover/gui_qt/machine_options.py b/plover/gui_qt/machine_options.py index 394b801d5..6a361a6c4 100644 --- a/plover/gui_qt/machine_options.py +++ b/plover/gui_qt/machine_options.py @@ -1,7 +1,7 @@ from copy import copy from PyQt5.QtCore import QVariant, pyqtSignal -from PyQt5.QtWidgets import QWidget +from PyQt5.QtWidgets import QGroupBox from serial import Serial from serial.tools.list_ports import comports @@ -12,7 +12,7 @@ from plover.gui_qt.config_serial_widget_ui import Ui_SerialWidget -class SerialOption(QWidget, Ui_SerialWidget): +class SerialOption(QGroupBox, Ui_SerialWidget): valueChanged = pyqtSignal(QVariant) @@ -96,7 +96,7 @@ def on_rtscts_changed(self, value): self._update('rtscts', value) -class KeyboardOption(QWidget, Ui_KeyboardWidget): +class KeyboardOption(QGroupBox, Ui_KeyboardWidget): valueChanged = pyqtSignal(QVariant) diff --git a/plover/gui_qt/main_window.py b/plover/gui_qt/main_window.py index 5a1f25e68..f6dccb0ed 100644 --- a/plover/gui_qt/main_window.py +++ b/plover/gui_qt/main_window.py @@ -43,22 +43,12 @@ def __init__(self, engine, use_qt_notifications): } all_actions = find_menu_actions(self.menubar) # Dictionaries. - self.dictionaries = self.scroll_area.widget() self.dictionaries.add_translation.connect(self._add_translation) - self.dictionaries.setFocus() + self.dictionaries.setup(engine) + # Populate edit menu from dictionaries' own. edit_menu = all_actions['menu_Edit'].menu() - edit_menu.addAction(self.dictionaries.action_Undo) - edit_menu.addSeparator() - edit_menu.addMenu(self.dictionaries.menu_AddDictionaries) - edit_menu.addAction(self.dictionaries.action_EditDictionaries) - edit_menu.addMenu(self.dictionaries.menu_SaveDictionaries) - edit_menu.addAction(self.dictionaries.action_RemoveDictionaries) - edit_menu.addSeparator() - edit_menu.addAction(self.dictionaries.action_MoveDictionariesUp) - edit_menu.addAction(self.dictionaries.action_MoveDictionariesDown) - self.dictionaries.setContextMenuPolicy(Qt.CustomContextMenu) - self.dictionaries.customContextMenuRequested.connect( - lambda p: edit_menu.exec_(self.dictionaries.mapToGlobal(p))) + for action in self.dictionaries.edit_menu.actions(): + edit_menu.addAction(action) # Tray icon. self._trayicon = TrayIcon() self._trayicon.enable() @@ -90,14 +80,14 @@ def __init__(self, engine, use_qt_notifications): engine.signal_connect('machine_state_changed', self._trayicon.update_machine_state) engine.signal_connect('quit', self.on_quit) self.action_Quit.triggered.connect(engine.quit) - # Populate tools bar/menu. - tools_menu = all_actions['menu_Tools'].menu() # Toolbar popup menu for selecting which tools are shown. self.toolbar_menu = QMenu() self.toolbar.setContextMenuPolicy(Qt.CustomContextMenu) self.toolbar.customContextMenuRequested.connect( lambda: self.toolbar_menu.popup(QCursor.pos()) ) + # Populate tools bar/menu. + tools_menu = all_actions['menu_Tools'].menu() for tool_plugin in registry.list_plugins('gui.qt.tool'): tool = tool_plugin.obj menu_action = tools_menu.addAction(tool.TITLE) @@ -145,7 +135,6 @@ def __init__(self, engine, use_qt_notifications): config = self._engine.config self._update_machine(config['machine_type']) self._configured = False - self.dictionaries.on_config_changed(config) self.set_visible(not config['start_minimized']) # Process events before starting the engine # (to avoid display lag at window creation). diff --git a/plover/gui_qt/main_window.ui b/plover/gui_qt/main_window.ui index eb2d17be7..b4ead2ec4 100644 --- a/plover/gui_qt/main_window.ui +++ b/plover/gui_qt/main_window.ui @@ -54,6 +54,12 @@ + + State + + + Connection state for the current machine. + @@ -70,6 +76,12 @@ Disconnect and reconnect the machine. + + Reconnect + + + Disconnect and reconnect the machine. + @@ -87,30 +99,19 @@ 0 + + Type + + + Change the current machine type. + - - - QFrame::Panel - - - true - - - - - 0 - 0 - 311 - 233 - - - - + @@ -304,7 +305,7 @@ - scroll_area + dictionaries machine_type reconnect output_enable diff --git a/plover/gui_qt/paper_tape.py b/plover/gui_qt/paper_tape.py index c0dd301ec..a2edf2d5d 100644 --- a/plover/gui_qt/paper_tape.py +++ b/plover/gui_qt/paper_tape.py @@ -1,7 +1,10 @@ - import time -from PyQt5.QtCore import Qt +from PyQt5.QtCore import ( + QAbstractListModel, + QModelIndex, + Qt, +) from PyQt5.QtGui import QFont from PyQt5.QtWidgets import ( QFileDialog, @@ -18,32 +21,107 @@ from plover.gui_qt.tool import Tool +STYLE_PAPER, STYLE_RAW = ( + # i18n: Paper tape style. + _('Paper'), + # i18n: Paper tape style. + _('Raw'), +) +TAPE_STYLES = (STYLE_PAPER, STYLE_RAW) + + +class TapeModel(QAbstractListModel): + + def __init__(self): + super().__init__() + self._stroke_list = [] + self._style = None + self._all_keys = None + self._numbers = None + + def rowCount(self, parent): + return 0 if parent.isValid() else len(self._stroke_list) + + @property + def style(self): + return self._style + + @style.setter + def style(self, style): + assert style in TAPE_STYLES + self.layoutAboutToBeChanged.emit() + self._style = style + self.layoutChanged.emit() + + def _paper_format(self, stroke): + text = self._all_keys_filler * 1 + keys = stroke.steno_keys[:] + if any(key in self._numbers for key in keys): + keys.append('#') + for key in keys: + index = system.KEY_ORDER[key] + text[index] = self._all_keys[index] + return ''.join(text) + + @staticmethod + def _raw_format(stroke): + return stroke.rtfcre + + def headerData(self, section, orientation, role): + if (section != 0 or orientation != Qt.Horizontal or + role != Qt.DisplayRole or self._style != STYLE_PAPER): + return None + return self._all_keys + + def data(self, index, role): + if not index.isValid(): + return None + stroke = self._stroke_list[index.row()] + if role == Qt.DisplayRole: + if self._style == STYLE_PAPER: + return self._paper_format(stroke) + if self._style == STYLE_RAW: + return self._raw_format(stroke) + if role == Qt.AccessibleTextRole: + return stroke.rtfcre + return None + + def reset(self): + self.modelAboutToBeReset.emit() + self._all_keys = ''.join(key.strip('-') for key in system.KEYS) + self._all_keys_filler = [ + ' ' * wcwidth(k) + for k in self._all_keys + ] + self._numbers = set(system.NUMBERS.values()) + self._stroke_list.clear() + self.modelReset.emit() + return self._all_keys + + def append(self, stroke): + row = len(self._stroke_list) + self.beginInsertRows(QModelIndex(), row, row) + self._stroke_list.append(stroke) + self.endInsertRows() + + class PaperTape(Tool, Ui_PaperTape): - ''' Paper tape display of strokes. ''' + # i18n: Widget: “PaperTape”, tooltip. + __doc__ = _('Paper tape display of strokes.') TITLE = _('Paper Tape') ICON = ':/tape.svg' ROLE = 'paper_tape' SHORTCUT = 'Ctrl+T' - STYLE_PAPER, STYLE_RAW = ( - # i18n: Paper tape style. - _('Paper'), - # i18n: Paper tape style. - _('Raw'), - ) - STYLES = (STYLE_PAPER, STYLE_RAW) - def __init__(self, engine): super().__init__(engine) self.setupUi(self) - self._strokes = [] - self._all_keys = None - self._all_keys_filler = None - self._formatter = None - self._history_size = 2000000 - self.styles.addItems(self.STYLES) + self._model = TapeModel() + self.header.setContentsMargins(4, 0, 0, 0) + self.styles.addItems(TAPE_STYLES) + self.tape.setModel(self._model) # Toolbar. self.layout().addWidget(ToolBar( self.action_ToggleOnTop, @@ -63,8 +141,9 @@ def __init__(self, engine): def _restore_state(self, settings): style = settings.value('style', None, int) if style is not None: - self.styles.setCurrentText(self.STYLES[style]) - self.on_style_changed(self.STYLES[style]) + style = TAPE_STYLES[style] + self.styles.setCurrentText(style) + self.on_style_changed(style) font_string = settings.value('font') if font_string is not None: font = QFont() @@ -77,67 +156,42 @@ def _restore_state(self, settings): self.on_toggle_ontop(ontop) def _save_state(self, settings): - settings.setValue('style', self.STYLES.index(self._style)) - settings.setValue('font', self.header.font().toString()) + settings.setValue('style', TAPE_STYLES.index(self._style)) + settings.setValue('font', self.tape.font().toString()) ontop = bool(self.windowFlags() & Qt.WindowStaysOnTopHint) settings.setValue('ontop', ontop) def on_config_changed(self, config): if 'system_name' in config: - self._strokes = [] - self._all_keys = ''.join(key.strip('-') for key in system.KEYS) - self._all_keys_filler = [ - ' ' * wcwidth(k) - for k in self._all_keys - ] - self._numbers = set(system.NUMBERS.values()) - self.header.setText(self._all_keys) - self.on_style_changed(self._style) + self._model.reset() + + @property + def _scroll_at_end(self): + scrollbar = self.tape.verticalScrollBar() + return scrollbar.value() == scrollbar.maximum() @property def _style(self): return self.styles.currentText() - def _paper_format(self, stroke): - text = self._all_keys_filler * 1 - keys = stroke.steno_keys[:] - if any(key in self._numbers for key in keys): - keys.append('#') - for key in keys: - index = system.KEY_ORDER[key] - text[index] = self._all_keys[index] - return ''.join(text) - - def _raw_format(self, stroke): - return stroke.rtfcre - - def _show_stroke(self, stroke): - text = self._formatter(stroke) - self.tape.appendPlainText(text) - def on_stroke(self, stroke): - assert len(self._strokes) <= self._history_size - if len(self._strokes) == self._history_size: - self._strokes.pop(0) - self._strokes.append(stroke) - self._show_stroke(stroke) + scroll_at_end = self._scroll_at_end + self._model.append(stroke) + if scroll_at_end: + self.tape.scrollToBottom() self.action_Clear.setEnabled(True) self.action_Save.setEnabled(True) def on_style_changed(self, style): - assert style in self.STYLES - if style == self.STYLE_PAPER: - self.header.show() - self._formatter = self._paper_format - elif style == self.STYLE_RAW: - self.header.hide() - self._formatter = self._raw_format - self.tape.clear() - for stroke in self._strokes: - self._show_stroke(stroke) + assert style in TAPE_STYLES + scroll_at_end = self._scroll_at_end + self._model.style = style + self.header.setVisible(style == STYLE_PAPER) + if scroll_at_end: + self.tape.scrollToBottom() def on_select_font(self): - font, ok = QFontDialog.getFont(self.header.font(), self, '', + font, ok = QFontDialog.getFont(self.tape.font(), self, '', QFontDialog.MonospacedFonts) if ok: self.header.setFont(font) @@ -164,7 +218,7 @@ def on_clear(self): self._strokes = [] self.action_Clear.setEnabled(False) self.action_Save.setEnabled(False) - self.tape.clear() + self._model.reset() def on_save(self): filename_suggestion = 'steno-notes-%s.txt' % time.strftime('%Y-%m-%d-%H-%M') @@ -176,4 +230,6 @@ def on_save(self): if not filename: return with open(filename, 'w') as fp: - fp.write(self.tape.toPlainText()) + for row in range(self.tape.count()): + item = self.tape.item(row) + print(item.data(Qt.DisplayRole), file=fp) diff --git a/plover/gui_qt/paper_tape.ui b/plover/gui_qt/paper_tape.ui index 785d8d883..43b9f9fc5 100644 --- a/plover/gui_qt/paper_tape.ui +++ b/plover/gui_qt/paper_tape.ui @@ -27,7 +27,10 @@ - Mode + Mode: + + + styles @@ -39,6 +42,12 @@ 0 + + Mode + + + Select paper tape display mode. + @@ -57,17 +66,23 @@ - + + + Tape + QFrame::Panel - + false - - QPlainTextEdit::NoWrap + + QAbstractItemView::SingleSelection + + + QAbstractItemView::SelectRows - + true diff --git a/plover/gui_qt/suggestions_dialog.ui b/plover/gui_qt/suggestions_dialog.ui index 128a3a7d8..fb144083f 100644 --- a/plover/gui_qt/suggestions_dialog.ui +++ b/plover/gui_qt/suggestions_dialog.ui @@ -23,7 +23,11 @@ - + + + Suggestions + + diff --git a/plover/gui_qt/suggestions_widget.py b/plover/gui_qt/suggestions_widget.py index 957f733cb..91b06502b 100644 --- a/plover/gui_qt/suggestions_widget.py +++ b/plover/gui_qt/suggestions_widget.py @@ -1,91 +1,162 @@ +from PyQt5.QtCore import ( + QAbstractListModel, + QModelIndex, + Qt, +) from PyQt5.QtGui import ( QFont, - QTextCursor, QTextCharFormat, + QTextCursor, + QTextDocument, +) +from PyQt5.QtWidgets import ( + QListView, + QStyle, + QStyledItemDelegate, ) -from PyQt5.QtWidgets import QWidget from plover import _ from plover.translation import escape_translation -from plover.gui_qt.suggestions_widget_ui import Ui_SuggestionsWidget - -class SuggestionsWidget(QWidget, Ui_SuggestionsWidget): +# i18n: Widget: “SuggestionsWidget”. +NO_SUGGESTIONS_STRING = _('no suggestions') +MAX_SUGGESTIONS_COUNT = 10 - STYLE_TRANSLATION, STYLE_STROKES = range(2) - # Anatomy of the text document: - # - "root": - # - 0+ "suggestions" blocks - # - 1+ "translation" blocks - # - 1-10 "strokes" blocks +class SuggestionsDelegate(QStyledItemDelegate): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setupUi(self) + def __init__(self, parent=None): + super().__init__(parent=parent) + self._doc = QTextDocument() self._translation_char_format = QTextCharFormat() self._strokes_char_format = QTextCharFormat() self._strokes_char_format.font().setStyleHint(QFont.Monospace) + @property + def text_font(self): + return self._translation_char_format.font() + + @text_font.setter + def text_font(self, font): + self._translation_char_format.setFont(font) + + @property + def strokes_font(self): + return self._strokes_char_format.font() + + @strokes_font.setter + def strokes_font(self, font): + self._strokes_char_format.setFont(font) + + def _format_suggestion(self, index): + suggestion = index.data(Qt.DisplayRole) + self._doc.clear() + cursor = QTextCursor(self._doc) + cursor.setCharFormat(self._translation_char_format) + cursor.insertText(escape_translation(suggestion.text) + ':') + if not suggestion.steno_list: + cursor.insertText(' ' + NO_SUGGESTIONS_STRING) + return + for strokes_list in suggestion.steno_list[:MAX_SUGGESTIONS_COUNT]: + cursor.insertBlock() + cursor.setCharFormat(self._strokes_char_format) + cursor.insertText(' ' + '/'.join(strokes_list)) + + def paint(self, painter, option, index): + painter.save() + if option.state & QStyle.State_Selected: + painter.fillRect(option.rect, option.palette.highlight()) + text_color = option.palette.highlightedText() + else: + text_color = option.palette.text() + self._translation_char_format.setForeground(text_color) + self._strokes_char_format.setForeground(text_color) + painter.translate(option.rect.topLeft()) + self._format_suggestion(index) + self._doc.drawContents(painter) + painter.restore() + + def sizeHint(self, option, index): + self._format_suggestion(index) + return self._doc.size().toSize() + + +class SuggestionsModel(QAbstractListModel): + + def __init__(self): + super().__init__() + self._suggestion_list = [] + + def rowCount(self, parent): + return 0 if parent.isValid() else len(self._suggestion_list) + + def data(self, index, role): + if not index.isValid(): + return None + suggestion = self._suggestion_list[index.row()] + if role == Qt.DisplayRole: + return suggestion + if role == Qt.AccessibleTextRole: + translation = escape_translation(suggestion.text) + if suggestion.steno_list: + steno = ', '.join('/'.join(strokes_list) for strokes_list in + suggestion.steno_list[:MAX_SUGGESTIONS_COUNT]) + else: + steno = NO_SUGGESTIONS_STRING + return translation + ': ' + steno + return None + + def clear(self): + self.modelAboutToBeReset.emit() + self._suggestion_list.clear() + self.modelReset.emit() + + def extend(self, suggestion_list): + row = len(self._suggestion_list) + self.beginInsertRows(QModelIndex(), row, row + len(suggestion_list)) + self._suggestion_list.extend(suggestion_list) + self.endInsertRows() + + +class SuggestionsWidget(QListView): + + def __init__(self, parent=None): + super().__init__(parent=parent) + self.setResizeMode(self.Adjust) + self._model = SuggestionsModel() + self._delegate = SuggestionsDelegate(self) + self.setModel(self._model) + self.setItemDelegate(self._delegate) + def append(self, suggestion_list): - scrollbar = self.suggestions.verticalScrollBar() + scrollbar = self.verticalScrollBar() scroll_at_end = scrollbar.value() == scrollbar.maximum() - cursor = self.suggestions.textCursor() - cursor.movePosition(QTextCursor.End) - for suggestion in suggestion_list: - cursor.insertBlock() - cursor.setCharFormat(self._translation_char_format) - cursor.block().setUserState(self.STYLE_TRANSLATION) - cursor.insertText(escape_translation(suggestion.text) + ':') - if not suggestion.steno_list: - # i18n: Widget: “SuggestionsWidget”. - cursor.insertText(' ' + _('no suggestions')) - continue - for strokes_list in suggestion.steno_list[:10]: - cursor.insertBlock() - cursor.setCharFormat(self._strokes_char_format) - cursor.block().setUserState(self.STYLE_STROKES) - cursor.insertText(' ' + '/'.join(strokes_list)) - cursor.insertText('\n') - # Keep current position when not at the end of the document. + self._model.extend(suggestion_list) if scroll_at_end: - scrollbar.setValue(scrollbar.maximum()) + self.scrollToBottom() def clear(self): - self.suggestions.clear() + self._model.clear() def _reformat(self): - document = self.suggestions.document() - cursor = self.suggestions.textCursor() - block = document.begin() - style_format = { - self.STYLE_TRANSLATION: self._translation_char_format, - self.STYLE_STROKES: self._strokes_char_format, - } - while block != document.end(): - style = block.userState() - fmt = style_format.get(style) - if fmt is not None: - cursor.setPosition(block.position()) - cursor.select(QTextCursor.BlockUnderCursor) - cursor.setCharFormat(fmt) - block = block.next() + self._model.layoutAboutToBeChanged.emit() + self._model.layoutChanged.emit() @property def text_font(self): - return self._translation_char_format.font() + return self._delegate.text_font @text_font.setter def text_font(self, font): - self._translation_char_format.setFont(font) + self._delegate.text_font = font self._reformat() @property def strokes_font(self): - return self._strokes_char_format.font() + return self._delegate.strokes_font @strokes_font.setter def strokes_font(self, font): - self._strokes_char_format.setFont(font) + self._delegate.strokes_font = font self._reformat() diff --git a/plover/gui_qt/suggestions_widget.ui b/plover/gui_qt/suggestions_widget.ui deleted file mode 100644 index 2d74000c8..000000000 --- a/plover/gui_qt/suggestions_widget.ui +++ /dev/null @@ -1,46 +0,0 @@ - - - SuggestionsWidget - - - - 0 - 0 - 400 - 300 - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - QFrame::Box - - - false - - - true - - - - - - - - diff --git a/plover/messages/plover.pot b/plover/messages/plover.pot index dcbb6e94a..56a3727c1 100644 --- a/plover/messages/plover.pot +++ b/plover/messages/plover.pot @@ -6,9 +6,9 @@ #, fuzzy msgid "" msgstr "" -"Project-Id-Version: plover 4.0.0.dev8\n" +"Project-Id-Version: plover 4.0.0.dev9\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2021-04-16 05:40+0200\n" +"POT-Creation-Date: 2021-05-23 20:19+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -44,69 +44,125 @@ msgid "" "hackers, hobbyists, accessibility mavens, and all-around speed demons." msgstr "" -#. Widget: “AboutDialog”, windowtitle. +#. Widget: “AboutDialog”, window title. #: plover/gui_qt/about_dialog_ui.py:38 msgid "Plover: About" msgstr "" #. Widget: “AddTranslationDialog”, tooltip. #. Widget: “AddTranslationWidget”, tooltip. -#: plover/gui_qt/add_translation_dialog.py:8 -#: plover/gui_qt/add_translation_widget.py:22 +#: plover/gui_qt/add_translation_dialog.py:10 +#: plover/gui_qt/add_translation_widget.py:23 msgid "Add a new translation to the dictionary." msgstr "" -#: plover/gui_qt/add_translation_dialog.py:10 +#: plover/gui_qt/add_translation_dialog.py:12 msgid "Add Translation" msgstr "" #. Widget: “AddTranslationWidget”. -#: plover/gui_qt/add_translation_widget.py:199 +#: plover/gui_qt/add_translation_widget.py:200 msgid "{dictionary} (disabled)" msgstr "" #. Widget: “AddTranslationWidget”. -#: plover/gui_qt/add_translation_widget.py:258 +#: plover/gui_qt/add_translation_widget.py:259 msgid "{strokes} maps to " msgstr "" #. Widget: “AddTranslationWidget”. -#: plover/gui_qt/add_translation_widget.py:269 +#: plover/gui_qt/add_translation_widget.py:270 msgid "Overwritten entries:" msgstr "" #. Widget: “AddTranslationWidget”. -#: plover/gui_qt/add_translation_widget.py:274 +#: plover/gui_qt/add_translation_widget.py:275 msgid "{strokes} is not mapped in any dictionary" msgstr "" #. Widget: “AddTranslationWidget”. -#: plover/gui_qt/add_translation_widget.py:290 +#: plover/gui_qt/add_translation_widget.py:291 msgid "{translation} is mapped to: {strokes}" msgstr "" #. Widget: “AddTranslationWidget”. -#: plover/gui_qt/add_translation_widget.py:293 +#: plover/gui_qt/add_translation_widget.py:294 msgid "{translation} is not in the dictionary" msgstr "" #. Widget: “AddTranslationWidget”, text. -#: plover/gui_qt/add_translation_widget_ui.py:101 +#: plover/gui_qt/add_translation_widget_ui.py:106 +msgid "Dictionary:" +msgstr "" + +#. Widget: “AddTranslationWidget”, text. +#: plover/gui_qt/add_translation_widget_ui.py:108 msgid "Strokes:" msgstr "" +#. Widget: “AddTranslationWidget”, accessible name. +#. Widget: “DictionaryEditor”. +#: plover/gui_qt/add_translation_widget_ui.py:110 +#: plover/gui_qt/dictionary_editor.py:164 +msgid "Strokes" +msgstr "" + #. Widget: “AddTranslationWidget”, text. -#: plover/gui_qt/add_translation_widget_ui.py:103 +#: plover/gui_qt/add_translation_widget_ui.py:112 msgid "Translation:" msgstr "" -#. Widget: “AddTranslationWidget”, text. -#: plover/gui_qt/add_translation_widget_ui.py:105 -msgid "Dictionary:" +#. Widget: “AddTranslationWidget”, accessible name. +#. Widget: “DictionaryEditor”. +#: plover/gui_qt/add_translation_widget_ui.py:114 +#: plover/gui_qt/dictionary_editor.py:167 +msgid "Translation" msgstr "" -#. Widget: “FileWidget”, text. +#. Widget: “AddTranslationWidget”, accessible name. +#. Widget: “DictionaryEditor”. +#: plover/gui_qt/add_translation_widget_ui.py:116 +#: plover/gui_qt/dictionary_editor.py:170 +msgid "Dictionary" +msgstr "" + +#. Widget: “AddTranslationWidget”, accessible description. +#: plover/gui_qt/add_translation_widget_ui.py:118 +msgid "Select the target dictionary for the new translation." +msgstr "" + +#. Widget: “AddTranslationWidget”, accessible name. +#: plover/gui_qt/add_translation_widget_ui.py:120 +msgid "Existing mappings (strokes)" +msgstr "" + +#. Widget: “AddTranslationWidget”, accessible name. +#: plover/gui_qt/add_translation_widget_ui.py:122 +msgid "Existing mappings (translations)" +msgstr "" + +#. Widget: “FileWidget”, accessible name. #: plover/gui_qt/config_file_widget_ui.py:45 +msgid "Log file path." +msgstr "" + +#. Widget: “FileWidget”, accessible description. +#: plover/gui_qt/config_file_widget_ui.py:47 +msgid "Path to the log file." +msgstr "" + +#. Widget: “FileWidget”, accessible name. +#: plover/gui_qt/config_file_widget_ui.py:49 +msgid "Browse." +msgstr "" + +#. Widget: “FileWidget”, accessible description. +#: plover/gui_qt/config_file_widget_ui.py:51 +msgid "Open a file picker to select the log file." +msgstr "" + +#. Widget: “FileWidget”, text. +#: plover/gui_qt/config_file_widget_ui.py:53 msgid "Browse" msgstr "" @@ -115,155 +171,187 @@ msgstr "" msgid "Arpeggiate" msgstr "" +#. Widget: “SerialWidget”, accessible name. +#: plover/gui_qt/config_serial_widget_ui.py:205 +msgid "Serial" +msgstr "" + #. Widget: “SerialWidget”, title. -#: plover/gui_qt/config_serial_widget_ui.py:209 +#: plover/gui_qt/config_serial_widget_ui.py:207 msgid "Connection" msgstr "" #. Widget: “SerialWidget”, text. +#. Widget: “SerialWidget”, accessible name. +#: plover/gui_qt/config_serial_widget_ui.py:209 #: plover/gui_qt/config_serial_widget_ui.py:211 msgid "Port" msgstr "" -#. Widget: “SerialWidget”, text. +#. Widget: “SerialWidget”, accessible description. #: plover/gui_qt/config_serial_widget_ui.py:213 +msgid "Serial port device name." +msgstr "" + +#. Widget: “SerialWidget”, accessible description. +#: plover/gui_qt/config_serial_widget_ui.py:215 +msgid "Scan for available serial ports." +msgstr "" + +#. Widget: “SerialWidget”, text. +#: plover/gui_qt/config_serial_widget_ui.py:217 msgid "Scan" msgstr "" #. Widget: “SerialWidget”, text. -#: plover/gui_qt/config_serial_widget_ui.py:215 +#. Widget: “SerialWidget”, accessible name. +#: plover/gui_qt/config_serial_widget_ui.py:219 +#: plover/gui_qt/config_serial_widget_ui.py:221 msgid "Baudrate" msgstr "" #. Widget: “SerialWidget”, title. -#: plover/gui_qt/config_serial_widget_ui.py:217 +#: plover/gui_qt/config_serial_widget_ui.py:223 msgid "Data format" msgstr "" #. Widget: “SerialWidget”, text. -#: plover/gui_qt/config_serial_widget_ui.py:219 +#. Widget: “SerialWidget”, accessible name. +#: plover/gui_qt/config_serial_widget_ui.py:225 +#: plover/gui_qt/config_serial_widget_ui.py:227 msgid "Data bits" msgstr "" #. Widget: “SerialWidget”, text. -#: plover/gui_qt/config_serial_widget_ui.py:221 +#. Widget: “SerialWidget”, accessible name. +#: plover/gui_qt/config_serial_widget_ui.py:229 +#: plover/gui_qt/config_serial_widget_ui.py:231 msgid "Stop bits" msgstr "" #. Widget: “SerialWidget”, text. -#: plover/gui_qt/config_serial_widget_ui.py:223 +#. Widget: “SerialWidget”, accessible name. +#: plover/gui_qt/config_serial_widget_ui.py:233 +#: plover/gui_qt/config_serial_widget_ui.py:235 msgid "Parity" msgstr "" #. Widget: “SerialWidget”, title. -#: plover/gui_qt/config_serial_widget_ui.py:225 +#: plover/gui_qt/config_serial_widget_ui.py:237 msgid "Timeout" msgstr "" #. Widget: “SerialWidget”, text. -#: plover/gui_qt/config_serial_widget_ui.py:227 -msgid "seconds" +#. Widget: “SerialWidget”, accessible name. +#: plover/gui_qt/config_serial_widget_ui.py:239 +#: plover/gui_qt/config_serial_widget_ui.py:241 +msgid "Duration" +msgstr "" + +#. Widget: “SerialWidget”, accessible description. +#: plover/gui_qt/config_serial_widget_ui.py:243 +msgid "Timeout duration in seconds." msgstr "" #. Widget: “SerialWidget”, text. -#: plover/gui_qt/config_serial_widget_ui.py:229 +#: plover/gui_qt/config_serial_widget_ui.py:245 msgid "Use timeout" msgstr "" #. Widget: “SerialWidget”, title. -#: plover/gui_qt/config_serial_widget_ui.py:231 +#: plover/gui_qt/config_serial_widget_ui.py:247 msgid "Flow control" msgstr "" #. Widget: “SerialWidget”, text. -#: plover/gui_qt/config_serial_widget_ui.py:233 +#: plover/gui_qt/config_serial_widget_ui.py:249 msgid "Xon/Xoff" msgstr "" #. Widget: “SerialWidget”, text. -#: plover/gui_qt/config_serial_widget_ui.py:235 +#: plover/gui_qt/config_serial_widget_ui.py:251 msgid "RTS/CTS" msgstr "" #. Widget: “NopeOption” (empty config option message, #. e.g. the machine option when selecting the Treal machine). -#: plover/gui_qt/config_window.py:45 +#: plover/gui_qt/config_window.py:47 msgid "Nothing to see here!" msgstr "" #. Widget: “KeymapOption”. -#: plover/gui_qt/config_window.py:145 +#: plover/gui_qt/config_window.py:176 msgid "Key" msgstr "" #. Widget: “KeymapOption”. -#: plover/gui_qt/config_window.py:147 +#: plover/gui_qt/config_window.py:178 msgid "Action" msgstr "" #. Widget: “MultipleChoicesOption”. -#: plover/gui_qt/config_window.py:196 +#: plover/gui_qt/config_window.py:224 msgid "Choice" msgstr "" #. Widget: “MultipleChoicesOption”. -#: plover/gui_qt/config_window.py:198 +#: plover/gui_qt/config_window.py:226 msgid "Selected" msgstr "" #. Widget: “ConfigWindow”. -#: plover/gui_qt/config_window.py:294 +#: plover/gui_qt/config_window.py:320 msgid "Interface" msgstr "" -#: plover/gui_qt/config_window.py:295 +#: plover/gui_qt/config_window.py:321 msgid "Start minimized:" msgstr "" -#: plover/gui_qt/config_window.py:296 +#: plover/gui_qt/config_window.py:322 msgid "Minimize the main window to systray on startup." msgstr "" -#: plover/gui_qt/config_window.py:297 +#: plover/gui_qt/config_window.py:323 msgid "Show paper tape:" msgstr "" -#: plover/gui_qt/config_window.py:298 +#: plover/gui_qt/config_window.py:324 msgid "Open the paper tape on startup." msgstr "" -#: plover/gui_qt/config_window.py:299 +#: plover/gui_qt/config_window.py:325 msgid "Show suggestions:" msgstr "" -#: plover/gui_qt/config_window.py:300 +#: plover/gui_qt/config_window.py:326 msgid "Open the suggestions dialog on startup." msgstr "" -#: plover/gui_qt/config_window.py:301 +#: plover/gui_qt/config_window.py:327 msgid "Add translation dialog opacity:" msgstr "" -#: plover/gui_qt/config_window.py:303 +#: plover/gui_qt/config_window.py:329 msgid "" "Set the translation dialog opacity:\n" "- 0 makes the dialog invisible\n" "- 100 is fully opaque" msgstr "" -#: plover/gui_qt/config_window.py:306 +#: plover/gui_qt/config_window.py:332 msgid "Dictionaries display order:" msgstr "" -#: plover/gui_qt/config_window.py:308 +#: plover/gui_qt/config_window.py:334 msgid "top-down" msgstr "" -#: plover/gui_qt/config_window.py:308 +#: plover/gui_qt/config_window.py:334 msgid "bottom-up" msgstr "" -#: plover/gui_qt/config_window.py:309 +#: plover/gui_qt/config_window.py:335 msgid "" "Set the display order for dictionaries:\n" "- top-down: match the search order; highest priority first\n" @@ -271,114 +359,114 @@ msgid "" msgstr "" #. Widget: “ConfigWindow”. -#: plover/gui_qt/config_window.py:314 +#: plover/gui_qt/config_window.py:340 msgid "Logging" msgstr "" -#: plover/gui_qt/config_window.py:315 +#: plover/gui_qt/config_window.py:341 msgid "Log file:" msgstr "" -#: plover/gui_qt/config_window.py:317 +#: plover/gui_qt/config_window.py:343 msgid "Select a log file" msgstr "" -#: plover/gui_qt/config_window.py:318 +#: plover/gui_qt/config_window.py:344 msgid "Log files (*.log)" msgstr "" -#: plover/gui_qt/config_window.py:319 +#: plover/gui_qt/config_window.py:345 msgid "File to use for logging strokes/translations." msgstr "" -#: plover/gui_qt/config_window.py:320 +#: plover/gui_qt/config_window.py:346 msgid "Log strokes:" msgstr "" -#: plover/gui_qt/config_window.py:321 +#: plover/gui_qt/config_window.py:347 msgid "Save strokes to the logfile." msgstr "" -#: plover/gui_qt/config_window.py:322 +#: plover/gui_qt/config_window.py:348 msgid "Log translations:" msgstr "" -#: plover/gui_qt/config_window.py:323 +#: plover/gui_qt/config_window.py:349 msgid "Save translations to the logfile." msgstr "" #. Widget: “ConfigWindow”. #. Widget: “MainWindow”, title. -#: plover/gui_qt/config_window.py:326 plover/gui_qt/main_window_ui.py:170 +#: plover/gui_qt/config_window.py:352 plover/gui_qt/main_window_ui.py:164 msgid "Machine" msgstr "" -#: plover/gui_qt/config_window.py:327 +#: plover/gui_qt/config_window.py:353 msgid "Machine:" msgstr "" -#: plover/gui_qt/config_window.py:332 +#: plover/gui_qt/config_window.py:358 msgid "Options:" msgstr "" -#: plover/gui_qt/config_window.py:333 +#: plover/gui_qt/config_window.py:359 msgid "Keymap:" msgstr "" #. Widget: “ConfigWindow”. #. Widget: “MainWindow”, title. -#: plover/gui_qt/config_window.py:336 plover/gui_qt/main_window_ui.py:174 +#: plover/gui_qt/config_window.py:362 plover/gui_qt/main_window_ui.py:180 msgid "Output" msgstr "" -#: plover/gui_qt/config_window.py:337 +#: plover/gui_qt/config_window.py:363 msgid "Enable at start:" msgstr "" -#: plover/gui_qt/config_window.py:338 +#: plover/gui_qt/config_window.py:364 msgid "Enable output on startup." msgstr "" -#: plover/gui_qt/config_window.py:339 +#: plover/gui_qt/config_window.py:365 msgid "Start attached:" msgstr "" -#: plover/gui_qt/config_window.py:340 +#: plover/gui_qt/config_window.py:366 msgid "" "Disable preceding space on first output.\n" "\n" "This option is only applicable when spaces are placed before." msgstr "" -#: plover/gui_qt/config_window.py:343 +#: plover/gui_qt/config_window.py:369 msgid "Start capitalized:" msgstr "" -#: plover/gui_qt/config_window.py:344 +#: plover/gui_qt/config_window.py:370 msgid "Capitalize the first word." msgstr "" -#: plover/gui_qt/config_window.py:345 +#: plover/gui_qt/config_window.py:371 msgid "Space placement:" msgstr "" -#: plover/gui_qt/config_window.py:347 +#: plover/gui_qt/config_window.py:373 msgid "Before Output" msgstr "" -#: plover/gui_qt/config_window.py:348 +#: plover/gui_qt/config_window.py:374 msgid "After Output" msgstr "" -#: plover/gui_qt/config_window.py:350 +#: plover/gui_qt/config_window.py:376 msgid "Set automatic space placement: before or after each word." msgstr "" -#: plover/gui_qt/config_window.py:351 +#: plover/gui_qt/config_window.py:377 msgid "Undo levels:" msgstr "" -#: plover/gui_qt/config_window.py:355 +#: plover/gui_qt/config_window.py:381 msgid "" "Set how many preceding strokes can be undone.\n" "\n" @@ -387,137 +475,159 @@ msgid "" msgstr "" #. Widget: “ConfigWindow”. -#: plover/gui_qt/config_window.py:361 +#: plover/gui_qt/config_window.py:387 msgid "Plugins" msgstr "" -#: plover/gui_qt/config_window.py:362 +#: plover/gui_qt/config_window.py:388 msgid "Extension:" msgstr "" -#: plover/gui_qt/config_window.py:366 +#: plover/gui_qt/config_window.py:392 msgid "Name" msgstr "" #. Widget: “MainWindow”, text. -#: plover/gui_qt/config_window.py:366 plover/gui_qt/main_window_ui.py:176 +#: plover/gui_qt/config_window.py:392 plover/gui_qt/main_window_ui.py:182 msgid "Enabled" msgstr "" -#: plover/gui_qt/config_window.py:367 +#: plover/gui_qt/config_window.py:393 msgid "Configure enabled plugin extensions." msgstr "" #. Widget: “ConfigWindow”. -#: plover/gui_qt/config_window.py:370 +#: plover/gui_qt/config_window.py:396 msgid "System" msgstr "" -#: plover/gui_qt/config_window.py:371 +#: plover/gui_qt/config_window.py:397 msgid "System:" msgstr "" -#. Widget: “ConfigWindow”, windowtitle. +#. Widget: “ConfigWindow”, window title. #: plover/gui_qt/config_window_ui.py:43 msgid "Plover: Configuration" msgstr "" #. Widget: “DictionariesWidget”, file picker. -#: plover/gui_qt/dictionaries_widget.py:43 +#: plover/gui_qt/dictionaries_widget.py:40 msgid "Dictionaries ({extensions})" msgstr "" #. Widget: “DictionariesWidget”, file picker. -#: plover/gui_qt/dictionaries_widget.py:46 +#: plover/gui_qt/dictionaries_widget.py:43 msgid "{format} dictionaries ({extensions})" msgstr "" -#. Widget: “DictionariesWidget”, “add” menu. -#: plover/gui_qt/dictionaries_widget.py:106 -msgid "Open dictionaries" +#. Widget: “DictionariesWidget”, accessible text. +#: plover/gui_qt/dictionaries_widget.py:374 +msgid "disabled" msgstr "" -#. Widget: “DictionariesWidget”, “add” menu. -#. Widget: “DictionariesWidget”, “new” file picker. -#: plover/gui_qt/dictionaries_widget.py:110 -#: plover/gui_qt/dictionaries_widget.py:483 -msgid "New dictionary" +#. Widget: “DictionariesWidget”, accessible text. +#: plover/gui_qt/dictionaries_widget.py:377 +msgid "favorite" msgstr "" -#. Widget: “DictionariesWidget”, “save” menu. -#: plover/gui_qt/dictionaries_widget.py:117 -msgid "Create a copy of each dictionary" +#. Widget: “DictionariesWidget”, accessible text. +#: plover/gui_qt/dictionaries_widget.py:380 +msgid "errored: {exception}." msgstr "" -#. Widget: “DictionariesWidget”, “save” menu. -#: plover/gui_qt/dictionaries_widget.py:121 -msgid "Merge dictionaries into a new one" +#. Widget: “DictionariesWidget”, accessible text. +#: plover/gui_qt/dictionaries_widget.py:384 +msgid "loading" +msgstr "" + +#. Widget: “DictionariesWidget”, accessible text. +#: plover/gui_qt/dictionaries_widget.py:387 +msgid "read-only" msgstr "" #. Widget: “DictionariesWidget”, tooltip. -#: plover/gui_qt/dictionaries_widget.py:196 +#: plover/gui_qt/dictionaries_widget.py:393 +msgid "Full path: {path}." +msgstr "" + +#. Widget: “DictionariesWidget”, tool tip. +#: plover/gui_qt/dictionaries_widget.py:396 +msgid "This dictionary is marked as the favorite." +msgstr "" + +#. Widget: “DictionariesWidget”, tool tip. +#: plover/gui_qt/dictionaries_widget.py:399 msgid "This dictionary is being loaded." msgstr "" -#. Widget: “DictionariesWidget”, tooltip. -#: plover/gui_qt/dictionaries_widget.py:205 -msgid "This dictionary is read-only." +#. Widget: “DictionariesWidget”, tool tip. +#: plover/gui_qt/dictionaries_widget.py:402 +msgid "Loading this dictionary failed: {exception}." msgstr "" -#. Widget: “DictionariesWidget”, tooltip. -#: plover/gui_qt/dictionaries_widget.py:209 -msgid "This dictionary is marked as a favorite." +#. Widget: “DictionariesWidget”, tool tip. +#: plover/gui_qt/dictionaries_widget.py:406 +msgid "This dictionary is read-only." msgstr "" #. Widget: “DictionariesWidget”, “save as copy” file picker. -#: plover/gui_qt/dictionaries_widget.py:399 +#: plover/gui_qt/dictionaries_widget.py:586 msgid "Save a copy of {name} as..." msgstr "" #. Widget: “DictionariesWidget”, “save as copy” file picker. -#: plover/gui_qt/dictionaries_widget.py:401 +#: plover/gui_qt/dictionaries_widget.py:588 msgid "{name} - Copy" msgstr "" #. Widget: “DictionariesWidget”, “save as merge” file picker. -#: plover/gui_qt/dictionaries_widget.py:422 +#: plover/gui_qt/dictionaries_widget.py:609 msgid "Merge {names} as..." msgstr "" #. Widget: “DictionariesWidget”, “add” file picker. -#. Widget: “DictionariesWidget”, tooltip. -#: plover/gui_qt/dictionaries_widget.py:466 -#: plover/gui_qt/dictionaries_widget_ui.py:130 +#. Widget: “DictionariesWidget”, tool tip. +#: plover/gui_qt/dictionaries_widget.py:636 +#: plover/gui_qt/dictionaries_widget_ui.py:139 msgid "Add dictionaries" msgstr "" +#. Widget: “DictionariesWidget”, “new” file picker. #. Widget: “DictionariesWidget”, text. -#: plover/gui_qt/dictionaries_widget_ui.py:102 +#: plover/gui_qt/dictionaries_widget.py:647 +#: plover/gui_qt/dictionaries_widget_ui.py:163 +msgid "New dictionary" +msgstr "" + +#. Widget: “DictionariesWidget”, title. +#. Widget: “DictionariesWidget”, accessible name. +#: plover/gui_qt/dictionaries_widget_ui.py:109 +#: plover/gui_qt/dictionaries_widget_ui.py:111 msgid "Dictionaries" msgstr "" #. Widget: “DictionariesWidget”, text. -#: plover/gui_qt/dictionaries_widget_ui.py:104 +#: plover/gui_qt/dictionaries_widget_ui.py:113 msgid "&Edit dictionaries" msgstr "" -#. Widget: “DictionariesWidget”, tooltip. -#: plover/gui_qt/dictionaries_widget_ui.py:106 +#. Widget: “DictionariesWidget”, tool tip. +#: plover/gui_qt/dictionaries_widget_ui.py:115 msgid "Edit selected dictionaries" msgstr "" #. Widget: “DictionariesWidget”, shortcut. -#: plover/gui_qt/dictionaries_widget_ui.py:108 +#: plover/gui_qt/dictionaries_widget_ui.py:117 msgid "Ctrl+E" msgstr "" #. Widget: “DictionariesWidget”, text. -#: plover/gui_qt/dictionaries_widget_ui.py:110 +#: plover/gui_qt/dictionaries_widget_ui.py:119 msgid "&Save dictionaries as..." msgstr "" -#. Widget: “DictionariesWidget”, tooltip. -#: plover/gui_qt/dictionaries_widget_ui.py:112 +#. Widget: “DictionariesWidget”, tool tip. +#: plover/gui_qt/dictionaries_widget_ui.py:121 msgid "" "Save the selected dictionaries: create a new copy of each dictionary, or " "merge them into a new dictionary." @@ -525,176 +635,218 @@ msgstr "" #. Widget: “DictionariesWidget”, shortcut. #. Widget: “PaperTape”, shortcut. -#: plover/gui_qt/dictionaries_widget_ui.py:114 -#: plover/gui_qt/paper_tape_ui.py:99 +#: plover/gui_qt/dictionaries_widget_ui.py:123 +#: plover/gui_qt/paper_tape_ui.py:107 msgid "Ctrl+S" msgstr "" #. Widget: “DictionariesWidget”, text. -#: plover/gui_qt/dictionaries_widget_ui.py:116 +#: plover/gui_qt/dictionaries_widget_ui.py:125 msgid "&Remove dictionaries" msgstr "" -#. Widget: “DictionariesWidget”, tooltip. -#: plover/gui_qt/dictionaries_widget_ui.py:118 +#. Widget: “DictionariesWidget”, tool tip. +#: plover/gui_qt/dictionaries_widget_ui.py:127 msgid "Remove selected dictionaries" msgstr "" #. Widget: “DictionariesWidget”, shortcut. #. Widget: “DictionaryEditor”, shortcut. -#: plover/gui_qt/dictionaries_widget_ui.py:120 -#: plover/gui_qt/dictionary_editor_ui.py:116 +#: plover/gui_qt/dictionaries_widget_ui.py:129 +#: plover/gui_qt/dictionary_editor_ui.py:136 msgid "Del" msgstr "" #. Widget: “DictionariesWidget”, text. #. Widget: “DictionaryEditor”, text. -#: plover/gui_qt/dictionaries_widget_ui.py:122 -#: plover/gui_qt/dictionary_editor_ui.py:118 +#: plover/gui_qt/dictionaries_widget_ui.py:131 +#: plover/gui_qt/dictionary_editor_ui.py:138 msgid "&Undo" msgstr "" -#. Widget: “DictionariesWidget”, tooltip. -#: plover/gui_qt/dictionaries_widget_ui.py:124 +#. Widget: “DictionariesWidget”, tool tip. +#: plover/gui_qt/dictionaries_widget_ui.py:133 msgid "Undo last add/delete/reorder operation." msgstr "" #. Widget: “DictionariesWidget”, shortcut. #. Widget: “DictionaryEditor”, shortcut. -#: plover/gui_qt/dictionaries_widget_ui.py:126 -#: plover/gui_qt/dictionary_editor_ui.py:122 +#: plover/gui_qt/dictionaries_widget_ui.py:135 +#: plover/gui_qt/dictionary_editor_ui.py:142 msgid "Ctrl+Z" msgstr "" #. Widget: “DictionariesWidget”, text. -#: plover/gui_qt/dictionaries_widget_ui.py:128 +#: plover/gui_qt/dictionaries_widget_ui.py:137 msgid "&Add dictionaries" msgstr "" #. Widget: “DictionariesWidget”, shortcut. -#: plover/gui_qt/dictionaries_widget_ui.py:132 +#: plover/gui_qt/dictionaries_widget_ui.py:141 msgid "Ctrl+O" msgstr "" #. Widget: “DictionariesWidget”, text. -#: plover/gui_qt/dictionaries_widget_ui.py:134 +#: plover/gui_qt/dictionaries_widget_ui.py:143 msgid "Add &translation" msgstr "" -#. Widget: “DictionariesWidget”, tooltip. -#. Widget: “DictionaryEditor”, tooltip. -#: plover/gui_qt/dictionaries_widget_ui.py:136 -#: plover/gui_qt/dictionary_editor_ui.py:126 +#. Widget: “DictionariesWidget”, tool tip. +#. Widget: “DictionaryEditor”, tool tip. +#: plover/gui_qt/dictionaries_widget_ui.py:145 +#: plover/gui_qt/dictionary_editor_ui.py:146 msgid "Add a new translation" msgstr "" #. Widget: “DictionariesWidget”, shortcut. #. Widget: “DictionaryEditor”, shortcut. -#: plover/gui_qt/dictionaries_widget_ui.py:138 -#: plover/gui_qt/dictionary_editor_ui.py:128 +#: plover/gui_qt/dictionaries_widget_ui.py:147 +#: plover/gui_qt/dictionary_editor_ui.py:148 msgid "Ctrl+N" msgstr "" #. Widget: “DictionariesWidget”, text. -#: plover/gui_qt/dictionaries_widget_ui.py:140 +#: plover/gui_qt/dictionaries_widget_ui.py:149 msgid "Move dictionaries &up" msgstr "" -#. Widget: “DictionariesWidget”, tooltip. -#: plover/gui_qt/dictionaries_widget_ui.py:142 +#. Widget: “DictionariesWidget”, tool tip. +#: plover/gui_qt/dictionaries_widget_ui.py:151 msgid "Move selected dictionaries up." msgstr "" #. Widget: “DictionariesWidget”, text. -#: plover/gui_qt/dictionaries_widget_ui.py:144 +#: plover/gui_qt/dictionaries_widget_ui.py:153 msgid "Move dictionaries &down" msgstr "" -#. Widget: “DictionariesWidget”, tooltip. -#: plover/gui_qt/dictionaries_widget_ui.py:146 +#. Widget: “DictionariesWidget”, tool tip. +#: plover/gui_qt/dictionaries_widget_ui.py:155 msgid "Move selected dictionaries down." msgstr "" -#. Widget: “DictionaryEditor”. -#: plover/gui_qt/dictionary_editor.py:163 -msgid "Strokes" +#. Widget: “DictionariesWidget”, text. +#: plover/gui_qt/dictionaries_widget_ui.py:157 +msgid "Create a copy of each dictionary" msgstr "" -#. Widget: “DictionaryEditor”. -#: plover/gui_qt/dictionary_editor.py:166 -msgid "Translation" +#. Widget: “DictionariesWidget”, text. +#: plover/gui_qt/dictionaries_widget_ui.py:159 +msgid "Merge dictionaries into a new one" msgstr "" -#. Widget: “DictionaryEditor”. -#: plover/gui_qt/dictionary_editor.py:169 -msgid "Dictionary" +#. Widget: “DictionariesWidget”, text. +#: plover/gui_qt/dictionaries_widget_ui.py:161 +msgid "Open dictionaries" msgstr "" -#. Widget: “DictionaryEditor”, windowtitle. -#: plover/gui_qt/dictionary_editor_ui.py:100 +#. Widget: “DictionaryEditor”, window title. +#: plover/gui_qt/dictionary_editor_ui.py:108 msgid "Plover: Dictionary Editor" msgstr "" +#. Widget: “DictionaryEditor”, accessible name. #. Widget: “DictionaryEditor”, title. -#: plover/gui_qt/dictionary_editor_ui.py:102 +#: plover/gui_qt/dictionary_editor_ui.py:110 +#: plover/gui_qt/dictionary_editor_ui.py:112 msgid "Filter" msgstr "" #. Widget: “DictionaryEditor”, text. -#: plover/gui_qt/dictionary_editor_ui.py:104 +#: plover/gui_qt/dictionary_editor_ui.py:114 msgid "By strokes:" msgstr "" +#. Widget: “DictionaryEditor”, accessible name. +#: plover/gui_qt/dictionary_editor_ui.py:116 +msgid "Strokes filter" +msgstr "" + +#. Widget: “DictionaryEditor”, accessible name. +#: plover/gui_qt/dictionary_editor_ui.py:118 +msgid "Apply filter" +msgstr "" + #. Widget: “DictionaryEditor”, text. -#: plover/gui_qt/dictionary_editor_ui.py:106 +#: plover/gui_qt/dictionary_editor_ui.py:120 msgid "Apply" msgstr "" #. Widget: “DictionaryEditor”, text. -#: plover/gui_qt/dictionary_editor_ui.py:108 +#: plover/gui_qt/dictionary_editor_ui.py:122 msgid "By translation:" msgstr "" +#. Widget: “DictionaryEditor”, accessible name. +#: plover/gui_qt/dictionary_editor_ui.py:124 +msgid "Translation filter" +msgstr "" + +#. Widget: “DictionaryEditor”, accessible name. +#: plover/gui_qt/dictionary_editor_ui.py:126 +msgid "Clear filter" +msgstr "" + #. Widget: “DictionaryEditor”, text. -#: plover/gui_qt/dictionary_editor_ui.py:110 +#: plover/gui_qt/dictionary_editor_ui.py:128 msgid "Clear" msgstr "" +#. Widget: “DictionaryEditor”, accessible name. +#: plover/gui_qt/dictionary_editor_ui.py:130 +msgid "Mappings" +msgstr "" + #. Widget: “DictionaryEditor”, text. -#: plover/gui_qt/dictionary_editor_ui.py:112 +#: plover/gui_qt/dictionary_editor_ui.py:132 msgid "&Delete" msgstr "" -#. Widget: “DictionaryEditor”, tooltip. -#: plover/gui_qt/dictionary_editor_ui.py:114 +#. Widget: “DictionaryEditor”, tool tip. +#: plover/gui_qt/dictionary_editor_ui.py:134 msgid "Delete selected entries." msgstr "" -#. Widget: “DictionaryEditor”, tooltip. -#: plover/gui_qt/dictionary_editor_ui.py:120 +#. Widget: “DictionaryEditor”, tool tip. +#: plover/gui_qt/dictionary_editor_ui.py:140 msgid "Undo last add/delete/edit operation." msgstr "" #. Widget: “DictionaryEditor”, text. -#: plover/gui_qt/dictionary_editor_ui.py:124 +#: plover/gui_qt/dictionary_editor_ui.py:144 msgid "&New translation" msgstr "" #. Widget: “LookupDialog”, tooltip. -#: plover/gui_qt/lookup_dialog.py:13 +#: plover/gui_qt/lookup_dialog.py:14 msgid "Search the dictionary for translations." msgstr "" -#: plover/gui_qt/lookup_dialog.py:15 +#: plover/gui_qt/lookup_dialog.py:16 msgid "Lookup" msgstr "" -#. Widget: “LookupDialog”, windowtitle. +#. Widget: “LookupDialog”, window title. #: plover/gui_qt/lookup_dialog_ui.py:56 msgid "Plover: Dictionary Lookup" msgstr "" -#: plover/gui_qt/machine_options.py:104 +#. Widget: “LookupDialog”, accessible name. +#: plover/gui_qt/lookup_dialog_ui.py:58 +msgid "Pattern" +msgstr "" + +#. Widget: “LookupDialog”, accessible description. +#: plover/gui_qt/lookup_dialog_ui.py:60 +msgid "Translation pattern to lookup." +msgstr "" + +#. Widget: “LookupDialog”, accessible name. +#: plover/gui_qt/lookup_dialog_ui.py:62 +msgid "Results" +msgstr "" + +#: plover/gui_qt/machine_options.py:106 msgid "" "Arpeggiate allows using non-NKRO keyboards.\n" "\n" @@ -702,255 +854,304 @@ msgid "" "space bar is pressed to send the stroke." msgstr "" -#. Widget: “MainWindow”, tooltip. -#: plover/gui_qt/main_window_ui.py:172 plover/gui_qt/main_window_ui.py:212 +#. Widget: “MainWindow”, accessible name. +#: plover/gui_qt/main_window_ui.py:166 +msgid "State" +msgstr "" + +#. Widget: “MainWindow”, accessible description. +#: plover/gui_qt/main_window_ui.py:168 +msgid "Connection state for the current machine." +msgstr "" + +#. Widget: “MainWindow”, tool tip. +#. Widget: “MainWindow”, accessible description. +#: plover/gui_qt/main_window_ui.py:170 plover/gui_qt/main_window_ui.py:174 +#: plover/gui_qt/main_window_ui.py:218 msgid "Disconnect and reconnect the machine." msgstr "" -#. Widget: “MainWindow”, text. +#. Widget: “MainWindow”, accessible name. +#: plover/gui_qt/main_window_ui.py:172 +msgid "Reconnect" +msgstr "" + +#. Widget: “MainWindow”, accessible name. +#: plover/gui_qt/main_window_ui.py:176 +msgid "Type" +msgstr "" + +#. Widget: “MainWindow”, accessible description. #: plover/gui_qt/main_window_ui.py:178 +msgid "Change the current machine type." +msgstr "" + +#. Widget: “MainWindow”, text. +#: plover/gui_qt/main_window_ui.py:184 msgid "Disabled" msgstr "" #. Widget: “MainWindow”, title. -#: plover/gui_qt/main_window_ui.py:180 +#: plover/gui_qt/main_window_ui.py:186 msgid "&File" msgstr "" #. Widget: “MainWindow”, title. -#: plover/gui_qt/main_window_ui.py:182 +#: plover/gui_qt/main_window_ui.py:188 msgid "&Tools" msgstr "" #. Widget: “MainWindow”, title. -#: plover/gui_qt/main_window_ui.py:184 +#: plover/gui_qt/main_window_ui.py:190 msgid "&Help" msgstr "" #. Widget: “MainWindow”, title. -#: plover/gui_qt/main_window_ui.py:186 +#: plover/gui_qt/main_window_ui.py:192 msgid "&Edit" msgstr "" -#. Widget: “MainWindow”, windowtitle. -#: plover/gui_qt/main_window_ui.py:188 +#. Widget: “MainWindow”, window title. +#: plover/gui_qt/main_window_ui.py:194 msgid "Plover: Toolbar" msgstr "" #. Widget: “MainWindow”, text. -#: plover/gui_qt/main_window_ui.py:190 +#: plover/gui_qt/main_window_ui.py:196 msgid "&Quit Plover" msgstr "" -#. Widget: “MainWindow”, tooltip. -#: plover/gui_qt/main_window_ui.py:192 +#. Widget: “MainWindow”, tool tip. +#: plover/gui_qt/main_window_ui.py:198 msgid "Quit the application." msgstr "" #. Widget: “MainWindow”, shortcut. -#: plover/gui_qt/main_window_ui.py:194 +#: plover/gui_qt/main_window_ui.py:200 msgid "Ctrl+Q" msgstr "" #. Widget: “MainWindow”, text. -#: plover/gui_qt/main_window_ui.py:196 +#: plover/gui_qt/main_window_ui.py:202 msgid "&Configure" msgstr "" -#. Widget: “MainWindow”, tooltip. -#: plover/gui_qt/main_window_ui.py:198 +#. Widget: “MainWindow”, tool tip. +#: plover/gui_qt/main_window_ui.py:204 msgid "Open the configuration dialog." msgstr "" #. Widget: “MainWindow”, shortcut. -#: plover/gui_qt/main_window_ui.py:200 +#: plover/gui_qt/main_window_ui.py:206 msgid "Ctrl+," msgstr "" #. Widget: “MainWindow”, text. -#: plover/gui_qt/main_window_ui.py:202 +#: plover/gui_qt/main_window_ui.py:208 msgid "Open config &folder" msgstr "" -#. Widget: “MainWindow”, tooltip. -#: plover/gui_qt/main_window_ui.py:204 +#. Widget: “MainWindow”, tool tip. +#: plover/gui_qt/main_window_ui.py:210 msgid "Open the configuration folder." msgstr "" #. Widget: “MainWindow”, text. -#: plover/gui_qt/main_window_ui.py:206 +#: plover/gui_qt/main_window_ui.py:212 msgid "&About" msgstr "" -#. Widget: “MainWindow”, tooltip. -#: plover/gui_qt/main_window_ui.py:208 +#. Widget: “MainWindow”, tool tip. +#: plover/gui_qt/main_window_ui.py:214 msgid "Open the about dialog." msgstr "" #. Widget: “MainWindow”, text. -#: plover/gui_qt/main_window_ui.py:210 +#: plover/gui_qt/main_window_ui.py:216 msgid "&Reconnect machine" msgstr "" #. Widget: “MainWindow”, shortcut. -#: plover/gui_qt/main_window_ui.py:214 +#: plover/gui_qt/main_window_ui.py:220 msgid "Ctrl+R" msgstr "" #. Widget: “MainWindow”, text. -#: plover/gui_qt/main_window_ui.py:216 +#: plover/gui_qt/main_window_ui.py:222 msgid "&Show" msgstr "" -#. Widget: “MainWindow”, tooltip. -#: plover/gui_qt/main_window_ui.py:218 +#. Widget: “MainWindow”, tool tip. +#: plover/gui_qt/main_window_ui.py:224 msgid "Show the main window." msgstr "" #. Widget: “MainWindow”, text. -#: plover/gui_qt/main_window_ui.py:220 +#: plover/gui_qt/main_window_ui.py:226 msgid "Toggle &output" msgstr "" -#. Widget: “MainWindow”, tooltip. -#: plover/gui_qt/main_window_ui.py:222 +#. Widget: “MainWindow”, tool tip. +#: plover/gui_qt/main_window_ui.py:228 msgid "Toggle the output." msgstr "" #. Widget: “MainWindow”, shortcut. -#: plover/gui_qt/main_window_ui.py:224 +#: plover/gui_qt/main_window_ui.py:230 msgid "Ctrl+." msgstr "" -#: plover/gui_qt/paper_tape.py:25 -msgid "Paper Tape" -msgstr "" - #. Paper tape style. -#: plover/gui_qt/paper_tape.py:32 +#: plover/gui_qt/paper_tape.py:26 msgid "Paper" msgstr "" #. Paper tape style. -#: plover/gui_qt/paper_tape.py:34 +#: plover/gui_qt/paper_tape.py:28 msgid "Raw" msgstr "" -#: plover/gui_qt/paper_tape.py:158 +#. Widget: “PaperTape”, tooltip. +#: plover/gui_qt/paper_tape.py:111 +msgid "Paper tape display of strokes." +msgstr "" + +#: plover/gui_qt/paper_tape.py:113 +msgid "Paper Tape" +msgstr "" + +#: plover/gui_qt/paper_tape.py:212 msgid "Do you want to clear the paper tape?" msgstr "" -#: plover/gui_qt/paper_tape.py:172 +#: plover/gui_qt/paper_tape.py:226 msgid "Save Paper Tape" msgstr "" #. Paper tape, "save" file picker. -#: plover/gui_qt/paper_tape.py:174 +#: plover/gui_qt/paper_tape.py:228 msgid "Text files (*.txt)" msgstr "" #. Widget: “PaperTape”, text. -#: plover/gui_qt/paper_tape_ui.py:87 +#: plover/gui_qt/paper_tape_ui.py:89 +msgid "Mode:" +msgstr "" + +#. Widget: “PaperTape”, accessible name. +#: plover/gui_qt/paper_tape_ui.py:91 msgid "Mode" msgstr "" +#. Widget: “PaperTape”, accessible description. +#: plover/gui_qt/paper_tape_ui.py:93 +msgid "Select paper tape display mode." +msgstr "" + +#. Widget: “PaperTape”, accessible name. +#: plover/gui_qt/paper_tape_ui.py:95 +msgid "Tape" +msgstr "" + #. Widget: “PaperTape”, text. #. Widget: “SuggestionsDialog”, text. -#: plover/gui_qt/paper_tape_ui.py:89 plover/gui_qt/suggestions_dialog_ui.py:53 +#: plover/gui_qt/paper_tape_ui.py:97 plover/gui_qt/suggestions_dialog_ui.py:55 msgid "&Clear" msgstr "" -#. Widget: “PaperTape”, tooltip. -#: plover/gui_qt/paper_tape_ui.py:91 +#. Widget: “PaperTape”, tool tip. +#: plover/gui_qt/paper_tape_ui.py:99 msgid "Clear paper tape." msgstr "" #. Widget: “PaperTape”, shortcut. #. Widget: “SuggestionsDialog”, shortcut. -#: plover/gui_qt/paper_tape_ui.py:93 plover/gui_qt/suggestions_dialog_ui.py:57 +#: plover/gui_qt/paper_tape_ui.py:101 plover/gui_qt/suggestions_dialog_ui.py:59 msgid "Ctrl+L" msgstr "" #. Widget: “PaperTape”, text. -#: plover/gui_qt/paper_tape_ui.py:95 +#: plover/gui_qt/paper_tape_ui.py:103 msgid "&Save" msgstr "" -#. Widget: “PaperTape”, tooltip. -#: plover/gui_qt/paper_tape_ui.py:97 +#. Widget: “PaperTape”, tool tip. +#: plover/gui_qt/paper_tape_ui.py:105 msgid "Save paper tape to file." msgstr "" #. Widget: “PaperTape”, text. #. Widget: “SuggestionsDialog”, text. -#: plover/gui_qt/paper_tape_ui.py:101 plover/gui_qt/suggestions_dialog_ui.py:59 +#: plover/gui_qt/paper_tape_ui.py:109 plover/gui_qt/suggestions_dialog_ui.py:61 msgid "&Toggle \"always on top\"" msgstr "" -#. Widget: “PaperTape”, tooltip. -#. Widget: “SuggestionsDialog”, tooltip. -#: plover/gui_qt/paper_tape_ui.py:103 plover/gui_qt/suggestions_dialog_ui.py:61 +#. Widget: “PaperTape”, tool tip. +#. Widget: “SuggestionsDialog”, tool tip. +#: plover/gui_qt/paper_tape_ui.py:111 plover/gui_qt/suggestions_dialog_ui.py:63 msgid "Toggle \"always on top\"." msgstr "" #. Widget: “PaperTape”, shortcut. #. Widget: “SuggestionsDialog”, shortcut. -#: plover/gui_qt/paper_tape_ui.py:105 plover/gui_qt/suggestions_dialog_ui.py:63 +#: plover/gui_qt/paper_tape_ui.py:113 plover/gui_qt/suggestions_dialog_ui.py:65 msgid "Ctrl+T" msgstr "" #. Widget: “PaperTape”, text. #. Widget: “SuggestionsDialog”, text. -#: plover/gui_qt/paper_tape_ui.py:107 plover/gui_qt/suggestions_dialog_ui.py:65 +#: plover/gui_qt/paper_tape_ui.py:115 plover/gui_qt/suggestions_dialog_ui.py:67 msgid "Select &font" msgstr "" -#. Widget: “PaperTape”, tooltip. -#. Widget: “SuggestionsDialog”, tooltip. -#: plover/gui_qt/paper_tape_ui.py:109 plover/gui_qt/suggestions_dialog_ui.py:67 +#. Widget: “PaperTape”, tool tip. +#. Widget: “SuggestionsDialog”, tool tip. +#: plover/gui_qt/paper_tape_ui.py:117 plover/gui_qt/suggestions_dialog_ui.py:69 msgid "Open font selection dialog." msgstr "" #. Widget: “SuggestionsDialog”, tooltip. -#: plover/gui_qt/suggestions_dialog.py:26 +#: plover/gui_qt/suggestions_dialog.py:27 msgid "Suggest possible strokes for the last written words." msgstr "" -#: plover/gui_qt/suggestions_dialog.py:28 +#. Widget: “SuggestionsDialog”, accessible name. +#: plover/gui_qt/suggestions_dialog.py:29 +#: plover/gui_qt/suggestions_dialog_ui.py:53 msgid "Suggestions" msgstr "" #. Widget: “SuggestionsDialog”, “font” menu. -#: plover/gui_qt/suggestions_dialog.py:57 +#: plover/gui_qt/suggestions_dialog.py:58 msgid "&Text" msgstr "" #. Widget: “SuggestionsDialog”, “font” menu. -#: plover/gui_qt/suggestions_dialog.py:59 +#: plover/gui_qt/suggestions_dialog.py:60 msgid "&Strokes" msgstr "" -#. Widget: “SuggestionsDialog”, tooltip. -#: plover/gui_qt/suggestions_dialog_ui.py:55 +#. Widget: “SuggestionsDialog”, tool tip. +#: plover/gui_qt/suggestions_dialog_ui.py:57 msgid "Clear the history." msgstr "" #. Widget: “SuggestionsWidget”. -#: plover/gui_qt/suggestions_widget.py:42 +#: plover/gui_qt/suggestions_widget.py:23 msgid "no suggestions" msgstr "" -#: plover/gui_qt/trayicon.py:116 +#: plover/gui_qt/trayicon.py:115 msgid "{machine} is {state}" msgstr "" #. Tray icon tooltip. -#: plover/gui_qt/trayicon.py:122 +#: plover/gui_qt/trayicon.py:121 msgid "output is enabled" msgstr "" #. Tray icon tooltip. -#: plover/gui_qt/trayicon.py:125 +#: plover/gui_qt/trayicon.py:124 msgid "output is disabled" msgstr "" diff --git a/plover_build_utils/setup.py b/plover_build_utils/setup.py index d5401ed10..7a655edd8 100644 --- a/plover_build_utils/setup.py +++ b/plover_build_utils/setup.py @@ -2,7 +2,6 @@ import contextlib import importlib import os -import shutil import subprocess import sys @@ -49,52 +48,6 @@ def bdist_wheel(self): return dist_path raise Exception('could not find wheel path') -# `test` command. {{{ - -class Test(Command): - - description = 'run unit tests after in-place build' - command_consumes_arguments = True - user_options = [] - test_dir = 'test' - build_before = True - - def initialize_options(self): - self.args = [] - - def finalize_options(self): - pass - - def run(self): - with self.project_on_sys_path(build=self.build_before): - self.run_tests() - - def run_tests(self): - # Remove __pycache__ directory so pytest does not freak out - # when switching between the Linux/Windows versions. - pycache = os.path.join(self.test_dir, '__pycache__') - if os.path.exists(pycache): - shutil.rmtree(pycache) - custom_testsuite = None - args = [] - for a in self.args: - if '-' == a[0]: - args.append(a) - elif os.path.exists(a): - custom_testsuite = a - args.append(a) - else: - args.extend(('-k', a)) - if custom_testsuite is None: - args.insert(0, self.test_dir) - sys.argv[1:] = args - main = pkg_resources.load_entry_point('pytest', - 'console_scripts', - 'py.test') - sys.exit(main()) - -# }}} - # i18n support. {{{ def babel_options(package, resource_dir=None): diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..59abe85c3 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,9 @@ +[pytest] +addopts = -ra +markers = + gui_qt: GUI specific tests. +qt_api = pyqt5 +testpaths = + test + +# vim: commentstring=#\ %s list diff --git a/reqs/constraints.txt b/reqs/constraints.txt index 027611ec4..26cece351 100644 --- a/reqs/constraints.txt +++ b/reqs/constraints.txt @@ -48,6 +48,7 @@ PyQt5-Qt5==5.15.2 PyQt5-sip==12.8.1 pyserial==3.5 pytest==6.2.2 +pytest-qt==3.3.0 python-xlib==0.29 pytz==2021.1 PyYAML==5.4.1 diff --git a/reqs/test.txt b/reqs/test.txt index 6f8ebeb3b..2ec35ce50 100644 --- a/reqs/test.txt +++ b/reqs/test.txt @@ -1,3 +1,4 @@ pytest +pytest-qt # vim: ft=cfg commentstring=#\ %s list diff --git a/setup.py b/setup.py index 82cad13d0..ea1597e52 100755 --- a/setup.py +++ b/setup.py @@ -24,18 +24,16 @@ exec(fp.read()) from plover_build_utils.setup import ( - BuildPy, BuildUi, Command, Develop, Test, babel_options + BuildPy, BuildUi, Command, Develop, babel_options ) BuildPy.build_dependencies.append('build_ui') Develop.build_dependencies.append('build_py') -Test.build_before = False cmdclass = { 'build_py': BuildPy, 'build_ui': BuildUi, 'develop': Develop, - 'test': Test, } options = {} diff --git a/test/conftest.py b/test/conftest.py index 406349724..2f77105e5 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,3 +1,5 @@ +import os + import pytest from plover import system @@ -11,3 +13,9 @@ def setup_plover(): system.setup(DEFAULT_SYSTEM_NAME) pytest.register_assert_rewrite('plover_build_utils.testing') + +def pytest_collection_modifyitems(items): + for item in items: + # Mark `gui_qt` tests. + if 'gui_qt' in item.location[0].split(os.path.sep): + item.add_marker(pytest.mark.gui_qt) diff --git a/test/gui_qt/test_dictionaries_widget.py b/test/gui_qt/test_dictionaries_widget.py new file mode 100644 index 000000000..6368a6801 --- /dev/null +++ b/test/gui_qt/test_dictionaries_widget.py @@ -0,0 +1,977 @@ +from collections import namedtuple +from pathlib import Path +from textwrap import dedent +from types import SimpleNamespace +from unittest import mock +import operator + +from PyQt5.QtCore import QModelIndex, QPersistentModelIndex, Qt + +import pytest + +from plover.config import DictionaryConfig +from plover.engine import ErroredDictionary +from plover.gui_qt.dictionaries_widget import DictionariesModel, DictionariesWidget +from plover.steno_dictionary import StenoDictionary, StenoDictionaryCollection +from plover.misc import expand_path + +from plover_build_utils.testing import parametrize + + +INVALID_EXCEPTION = Exception('loading error') + +ICON_TO_CHAR = { + 'error': '!', + 'favorite': '★', + 'loading': '🗘', + 'normal': '⎕', + 'readonly': '🛇', +} +ICON_FROM_CHAR = {c: i for i, c in ICON_TO_CHAR.items()} + +ENABLED_TO_CHAR = { + False: '☐', + True: '☑', +} +ENABLED_FROM_CHAR = {c: e for e, c in ENABLED_TO_CHAR.items()} + +CHECKED_TO_BOOL = { + Qt.Checked: True, + Qt.Unchecked: False, +} + +MODEL_ROLES = sorted([Qt.AccessibleTextRole, Qt.CheckStateRole, + Qt.DecorationRole, Qt.DisplayRole, Qt.ToolTipRole]) + + +def parse_state(state_str): + state_str = dedent(state_str).strip() + if not state_str: + return + for line in state_str.split('\n'): + enabled, icon, path = line.split() + yield ENABLED_FROM_CHAR[enabled], ICON_FROM_CHAR[icon], path + +def config_dictionaries_from_state(state_str): + return [ + DictionaryConfig(path, enabled) + for enabled, icon, path in parse_state(state_str) + ] + +def steno_dictionaries_from_state(state_str, existing_dictionaries=None): + new_dictionaries = [] + for enabled, icon, path in parse_state(state_str): + if icon == 'loading': + continue + path = expand_path(path) + if existing_dictionaries is None: + steno_dict = None + else: + steno_dict = existing_dictionaries.get(path) + if steno_dict is None: + if icon == 'error' or path.endswith('.bad'): + steno_dict = ErroredDictionary(path, INVALID_EXCEPTION) + else: + steno_dict = StenoDictionary() + steno_dict.path = path + steno_dict.readonly = ( + icon == 'readonly' or + path.endswith('.ro') or + path.startswith('asset:') + ) + steno_dict.enabled = enabled + new_dictionaries.append(steno_dict) + return new_dictionaries + + +class ModelTest(namedtuple('ModelTest', ''' + config dictionaries engine + model signals connections + initial_state + ''')): + + def configure(self, **kwargs): + self.connections['config_changed'](kwargs) + + def configure_dictionaries(self, state): + self.configure(dictionaries=config_dictionaries_from_state(state)) + + def load_dictionaries(self, state): + self.dictionaries.set_dicts(steno_dictionaries_from_state(state, self.dictionaries)) + self.connections['dictionaries_loaded'](self.dictionaries) + loaded = [row + for row, (enabled, icon, path) + in enumerate(parse_state(state)) + if icon != 'loading'] + + def check(self, expected, + config_change=None, data_change=None, + layout_change=False, undo_change=None): + __tracebackhide__ = operator.methodcaller('errisinstance', AssertionError) + expected = dedent(expected).strip() + if expected: + expected_config = expected + expected_state = expected.split('\n') + else: + expected_config = '' + expected_state = [] + actual_state = [] + for row in range(self.model.rowCount()): + index = self.model.index(row) + enabled = CHECKED_TO_BOOL[index.data(Qt.CheckStateRole)] + icon = index.data(Qt.DecorationRole) + path = index.data(Qt.DisplayRole) + actual_state.append('%s %s %s' % ( + ENABLED_TO_CHAR.get(enabled, '?'), + ICON_TO_CHAR.get(icon, '?'), + path)) + assert actual_state == expected_state + assert not self.engine.mock_calls, 'unexpected engine call' + if config_change == 'reload': + assert self.config.mock_calls == [mock.call({})] + self.config.reset_mock() + elif config_change == 'update': + config_update = { + 'dictionaries': config_dictionaries_from_state(expected_config), + } + assert self.config.mock_calls == [mock.call(config_update)] + self.config.reset_mock() + else: + assert not self.config.mock_calls, 'unexpected config call' + signal_calls = self.signals.mock_calls[:] + if undo_change is not None: + call = signal_calls.pop(0) + assert call == mock.call.has_undo_changed(undo_change) + if data_change is not None: + for row in data_change: + index = self.model.index(row) + call = signal_calls.pop(0) + call.args[2].sort() + assert call == mock.call.dataChanged(index, index, MODEL_ROLES) + if layout_change: + assert signal_calls[0:2] == [mock.call.layoutAboutToBeChanged([], self.model.NoLayoutChangeHint), + mock.call.layoutChanged([], self.model.NoLayoutChangeHint)] + del signal_calls[0:2] + assert not signal_calls + self.signals.reset_mock() + + def reset_mocks(self): + self.config.reset_mock() + self.engine.reset_mock() + self.signals.reset_mock() + + +@pytest.fixture +def model_test(monkeypatch, request): + state = request.function.__doc__ + # Patch configuration directory. + current_dir = Path('.').resolve() + monkeypatch.setattr('plover.misc.CONFIG_DIR', str(current_dir)) + monkeypatch.setattr('plover.gui_qt.dictionaries_widget.CONFIG_DIR', str(current_dir)) + # Disable i18n support. + monkeypatch.setattr('plover.gui_qt.dictionaries_widget._', lambda s: s) + # Fake config. + config = mock.PropertyMock() + config.return_value = {} + # Dictionaries. + dictionaries = StenoDictionaryCollection() + # Fake engine. + engine = mock.MagicMock(spec=''' + __enter__ __exit__ + config signal_connect + '''.split()) + engine.__enter__.return_value = engine + type(engine).config = config + signals = mock.MagicMock() + config.return_value = { + 'dictionaries': config_dictionaries_from_state(state) if state else [], + 'classic_dictionaries_display_order': False, + } + # Setup model. + model = DictionariesModel(engine, {name: name for name in ICON_TO_CHAR}, max_undo=5) + for slot in ''' + dataChanged + layoutAboutToBeChanged + layoutChanged + has_undo_changed + '''.split(): + getattr(model, slot).connect(getattr(signals, slot)) + connections = dict(call.args for call in engine.signal_connect.mock_calls) + assert connections.keys() == {'config_changed', 'dictionaries_loaded'} + config.reset_mock() + engine.reset_mock() + # Test helper. + test = ModelTest(config, dictionaries, engine, model, signals, connections, state) + if state and any(icon != 'loading' for enabled, icon, path in parse_state(state)): + test.load_dictionaries(state) + test.reset_mocks() + return test + + +def test_model_accessible_text_1(model_test): + ''' + ☑ 🗘 read-only.ro + ☑ 🗘 user.json + ☑ 🗘 invalid.bad + ☑ 🗘 commands.json + ☐ 🗘 asset:plover:assets/main.json + ''' + for n, expected in enumerate(( + 'read-only.ro, loading', + 'user.json, loading', + 'invalid.bad, loading', + 'commands.json, loading', + 'asset:plover:assets/main.json, disabled, loading', + )): + assert model_test.model.index(n).data(Qt.AccessibleTextRole) == expected + +def test_model_accessible_text_2(model_test): + ''' + ☑ 🛇 read-only.ro + ☑ ★ user.json + ☑ ⎕ commands.json + ☐ 🛇 asset:plover:assets/main.json + ''' + for n, expected in enumerate(( + 'read-only.ro, read-only', + 'user.json, favorite', + 'commands.json', + 'asset:plover:assets/main.json, disabled, read-only', + )): + assert model_test.model.index(n).data(Qt.AccessibleTextRole) == expected + +def test_model_accessible_text_3(model_test): + ''' + ☑ ! invalid.bad + ''' + expected = 'invalid.bad, errored: %s.' % INVALID_EXCEPTION + assert model_test.model.index(0).data(Qt.AccessibleTextRole) == expected + +def test_model_accessible_text_4(model_test): + ''' + ☐ ! invalid.bad + ''' + expected = 'invalid.bad, disabled, errored: %s.' % INVALID_EXCEPTION + assert model_test.model.index(0).data(Qt.AccessibleTextRole) == expected + +def test_model_add_existing(model_test): + ''' + ☑ ★ user.json + ☑ ⎕ commands.json + ☐ ⎕ main.json + ''' + model_test.model.add([expand_path('main.json')]) + model_test.check(model_test.initial_state, config_change='reload') + +def test_model_add_new_1(model_test): + ''' + ☑ ★ user.json + ☐ ⎕ commands.json + ☑ 🗘 main.json + ''' + model_test.model.add([expand_path('read-only.ro')]) + model_test.check( + ''' + ☑ 🗘 read-only.ro + ☑ ★ user.json + ☐ ⎕ commands.json + ☑ 🗘 main.json + ''', + config_change='update', + layout_change=True, + undo_change=True, + ) + +def test_model_add_new_2(model_test): + ''' + ☑ ★ user.json + ☐ ⎕ commands.json + ☑ 🗘 main.json + ''' + model_test.model.add(['duplicated.json', + 'unique.json', + 'duplicated.json']) + model_test.check( + ''' + ☑ 🗘 duplicated.json + ☑ 🗘 unique.json + ☑ ★ user.json + ☐ ⎕ commands.json + ☑ 🗘 main.json + ''', + config_change='update', + layout_change=True, + undo_change=True, + ) + +def test_model_add_nothing(model_test): + ''' + ☑ ★ user.json + ☑ ⎕ commands.json + ☑ ⎕ main.json + ''' + model_test.model.add([]) + model_test.check(model_test.initial_state) + +def test_model_config_update(model_test): + ''' + ☐ ⎕ user.json + ☑ ★ commands.json + ☑ 🛇 read-only.ro + ☑ ! invalid.bad + ☐ 🛇 asset:plover:assets/main.json + ''' + state = ''' + ☑ ★ user.json + ☐ ⎕ commands.json + ☑ 🗘 main.json + ''' + model_test.configure_dictionaries(state) + model_test.check(state, layout_change=True) + state = ''' + ☑ ★ user.json + ☐ ⎕ commands.json + ☑ ⎕ main.json + ''' + model_test.load_dictionaries(state) + model_test.check(state, data_change=[2]) + +def test_model_insert_1(model_test): + ''' + ☐ ⎕ user.json + ☑ ★ commands.json + ☑ 🛇 read-only.ro + ☑ ! invalid.bad + ☐ 🛇 asset:plover:assets/main.json + ''' + model_test.model.insert(model_test.model.index(2), + ['main.json', + 'commands.json', + 'read-only.ro']) + model_test.check( + ''' + ☐ ⎕ user.json + ☑ 🗘 main.json + ☑ ★ commands.json + ☑ 🛇 read-only.ro + ☑ ! invalid.bad + ☐ 🛇 asset:plover:assets/main.json + ''', + config_change='update', + layout_change=True, + undo_change=True, + ) + + +def test_model_insert_2(model_test): + ''' + ☐ ⎕ user.json + ☑ 🗘 main.json + ☑ ★ commands.json + ☑ 🛇 read-only.ro + ☑ ! invalid.bad + ☐ 🛇 asset:plover:assets/main.json + ''' + model_test.model.insert(QModelIndex(), + ['commands.json', + 'user.json', + 'commands.json']) + model_test.check( + ''' + ☑ 🗘 main.json + ☑ 🛇 read-only.ro + ☑ ! invalid.bad + ☐ 🛇 asset:plover:assets/main.json + ☑ ★ commands.json + ☐ ⎕ user.json + ''', + config_change='update', + layout_change=True, + undo_change=True, + ) + +def test_model_insert_3(model_test): + ''' + ☑ ★ user.json + ☑ ⎕ commands.json + ☑ ⎕ main.json + ''' + model_test.model.insert(QModelIndex(), []) + model_test.check(model_test.initial_state) + +def test_model_display_order(model_test): + ''' + ☑ 🛇 read-only.ro + ☑ ★ user.json + ☑ ⎕ commands.json + ☐ 🛇 asset:plover:assets/main.json + ''' + state = model_test.initial_state + # Flip display order. + model_test.configure(classic_dictionaries_display_order=True) + model_test.check('\n'.join(reversed(state.split('\n'))), layout_change=True) + # Reset display order to default. + model_test.configure(classic_dictionaries_display_order=False) + model_test.check(state, layout_change=True) + +def test_model_favorite(model_test): + ''' + ☑ 🛇 read-only.ro + ☑ ★ user.json + ☑ ! invalid.bad + ☑ ⎕ commands.json + ☐ 🛇 asset:plover:assets/main.json + ''' + # New favorite. + model_test.model.setData(model_test.model.index(1), Qt.Unchecked, Qt.CheckStateRole) + model_test.check( + ''' + ☑ 🛇 read-only.ro + ☐ ⎕ user.json + ☑ ! invalid.bad + ☑ ★ commands.json + ☐ 🛇 asset:plover:assets/main.json + ''', + config_change='update', + data_change=[1, 3], + undo_change=True, + ) + # No favorite. + model_test.model.setData(model_test.model.index(3), Qt.Unchecked, Qt.CheckStateRole) + model_test.check( + ''' + ☑ 🛇 read-only.ro + ☐ ⎕ user.json + ☑ ! invalid.bad + ☐ ⎕ commands.json + ☐ 🛇 asset:plover:assets/main.json + ''', + config_change='update', + data_change=[3], + ) + +def test_model_initial_setup(model_test): + ''' + ☑ 🗘 read-only.ro + ☑ 🗘 user.json + ☑ 🗘 invalid.bad + ☑ 🗘 commands.json + ☐ 🗘 asset:plover:assets/main.json + ''' + state = model_test.initial_state + # Initial state. + model_test.check(state) + # First config notification: no-op. + model_test.configure(**model_test.config.return_value) + model_test.check(state) + # After loading dictionaries. + state = ''' + ☑ 🛇 read-only.ro + ☑ ★ user.json + ☑ ! invalid.bad + ☑ ⎕ commands.json + ☐ 🛇 asset:plover:assets/main.json + ''' + model_test.load_dictionaries(state) + model_test.check(state, data_change=range(5)) + +def test_model_iter_loaded(model_test): + ''' + ☑ 🗘 magnum.json + ☑ ★ user.json + ☐ ⎕ commands.json + ☑ 🗘 main.json + ''' + model_test.check(model_test.initial_state) + index_list = [model_test.model.index(n) for n in range(4)] + index_list.append(QModelIndex()) + assert list(model_test.model.iter_loaded(index_list)) == model_test.dictionaries.dicts + assert list(model_test.model.iter_loaded(reversed(index_list))) == model_test.dictionaries.dicts + model_test.configure(classic_dictionaries_display_order=False) + assert list(model_test.model.iter_loaded(index_list)) == model_test.dictionaries.dicts + assert list(model_test.model.iter_loaded(reversed(index_list))) == model_test.dictionaries.dicts + +def test_model_move_dictionaries(model_test): + ''' + ☑ 🛇 read-only.ro + ☐ ⎕ commands.json + ☑ ★ user.json + ☑ ⎕ main.json + ''' + model_test.check(model_test.initial_state) + model_test.model.move(QModelIndex(), [model_test.model.index(0), + model_test.model.index(2)]) + model_test.check( + ''' + ☐ ⎕ commands.json + ☑ ★ main.json + ☑ 🛇 read-only.ro + ☑ ⎕ user.json + ''', + config_change='update', + layout_change=True, + undo_change=True, + ) + +def test_model_move_down(model_test): + ''' + ☐ ⎕ commands.json + ☑ ★ main.json + ☑ 🛇 read-only.ro + ☑ ⎕ user.json + ''' + model_test.model.move_down([model_test.model.index(n) for n in [1]]) + model_test.check( + ''' + ☐ ⎕ commands.json + ☑ 🛇 read-only.ro + ☑ ★ main.json + ☑ ⎕ user.json + ''', + config_change='update', + layout_change=True, + undo_change=True, + ) + model_test.model.move_down([model_test.model.index(n) for n in [0, 2]]) + model_test.check( + ''' + ☑ 🛇 read-only.ro + ☐ ⎕ commands.json + ☑ ★ user.json + ☑ ⎕ main.json + ''', + config_change='update', + layout_change=True, + ) + model_test.model.move_down([model_test.model.index(n) for n in [1]]) + model_test.check( + ''' + ☑ 🛇 read-only.ro + ☑ ★ user.json + ☐ ⎕ commands.json + ☑ ⎕ main.json + ''', + config_change='update', + layout_change=True, + ) + +def test_model_move_down_nothing(model_test): + ''' + ☑ ★ user.json + ☑ ⎕ commands.json + ☑ ⎕ main.json + ''' + model_test.model.move_down([QModelIndex()]) + model_test.check(model_test.initial_state) + +def test_model_move_up(model_test): + ''' + ☑ 🛇 read-only.ro + ☑ ⎕ user.json + ☐ ⎕ commands.json + ☑ ★ main.json + ''' + model_test.model.move_up([model_test.model.index(n) for n in [2, 3]]) + model_test.check( + ''' + ☑ 🛇 read-only.ro + ☐ ⎕ commands.json + ☑ ★ main.json + ☑ ⎕ user.json + ''', + config_change='update', + layout_change=True, + undo_change=True, + ) + model_test.model.move_up([model_test.model.index(n) for n in [1, 2]]) + model_test.check( + ''' + ☐ ⎕ commands.json + ☑ ★ main.json + ☑ 🛇 read-only.ro + ☑ ⎕ user.json + ''', + config_change='update', + layout_change=True, + ) + +def test_model_move_up_nothing(model_test): + ''' + ☑ ★ user.json + ☑ ⎕ commands.json + ☑ ⎕ main.json + ''' + model_test.model.move_up([QModelIndex()]) + model_test.check(model_test.initial_state) + +def test_model_persistent_index(model_test): + ''' + ☑ 🛇 read-only.ro + ☑ ★ user.json + ☑ ⎕ commands.json + ☐ 🛇 asset:plover:assets/main.json + ''' + persistent_index = QPersistentModelIndex(model_test.model.index(1)) + assert persistent_index.row() == 1 + assert persistent_index.data(Qt.CheckStateRole) == Qt.Checked + assert persistent_index.data(Qt.DecorationRole) == 'favorite' + assert persistent_index.data(Qt.DisplayRole) == 'user.json' + model_test.configure(classic_dictionaries_display_order=True) + assert persistent_index.row() == 2 + assert persistent_index.data(Qt.CheckStateRole) == Qt.Checked + assert persistent_index.data(Qt.DecorationRole) == 'favorite' + assert persistent_index.data(Qt.DisplayRole) == 'user.json' + model_test.model.setData(persistent_index, Qt.Unchecked, Qt.CheckStateRole) + assert persistent_index.row() == 2 + assert persistent_index.data(Qt.CheckStateRole) == Qt.Unchecked + assert persistent_index.data(Qt.DecorationRole) == 'normal' + assert persistent_index.data(Qt.DisplayRole) == 'user.json' + +def test_model_qtmodeltester(model_test, qtmodeltester): + ''' + ☑ 🛇 read-only.ro + ☑ ★ user.json + ☑ ⎕ commands.json + ☐ 🛇 asset:plover:assets/main.json + ''' + qtmodeltester.check(model_test.model) + +def test_model_remove(model_test): + ''' + ☐ ⎕ commands.json + ☑ 🛇 read-only.ro + ☑ ★ main.json + ☑ ⎕ user.json + ''' + model_test.model.remove([model_test.model.index(n) for n in [0, 3]]) + model_test.check( + ''' + ☑ 🛇 read-only.ro + ☑ ★ main.json + ''', + config_change='update', + layout_change=True, + undo_change=True, + ) + model_test.model.remove([model_test.model.index(n) for n in [0, 1]]) + model_test.check('', config_change='update', layout_change=True) + +def test_model_remove_nothing_1(model_test): + ''' + ☐ ⎕ commands.json + ☑ 🛇 read-only.ro + ☑ ★ main.json + ☑ ⎕ user.json + ''' + model_test.model.remove([]) + model_test.check(model_test.initial_state) + +def test_model_remove_nothing_2(model_test): + ''' + ☐ ⎕ commands.json + ☑ 🛇 read-only.ro + ☑ ★ main.json + ☑ ⎕ user.json + ''' + model_test.model.remove([QModelIndex()]) + model_test.check(model_test.initial_state) + +def test_model_set_checked(model_test): + on_state = '☑ 🗘 user.json' + off_state = '☐ 🗘 user.json' + model_test.model.add(['user.json']) + model_test.check(on_state, config_change='update', + layout_change=True, undo_change=True) + first_index = model_test.model.index(0) + for index, value, ret, state in ( + # Invalid index. + (QModelIndex(), Qt.Unchecked, False, on_state), + # Invalid values. + (first_index, 'pouet', False, on_state), + (first_index, Qt.PartiallyChecked, False, on_state), + # Already checked. + (first_index, Qt.Checked, False, on_state), + # Uncheck. + (first_index, Qt.Unchecked, True, off_state), + # Recheck. + (first_index, Qt.Checked, True, on_state), + ): + assert model_test.model.setData(index, value, Qt.CheckStateRole) == ret + model_test.check(state, config_change='update' if ret else None, + data_change=[index.row()] if ret else None) + +def test_model_unrelated_config_change(model_test): + ''' + ☑ ★ user.json + ☑ ⎕ commands.json + ☑ ⎕ main.json + ''' + model_test.configure(start_minimized=True) + model_test.check(model_test.initial_state) + +def test_model_undo_1(model_test): + ''' + ☐ 🗘 1.json + ☐ 🗘 2.json + ☐ 🗘 3.json + ☐ 🗘 4.json + ☐ 🗘 5.json + ☐ 🗘 6.json + ''' + # Check max undo size. + state = dedent(model_test.initial_state).strip() + state_stack = [] + for n in range(6): + state_stack.append(state) + state = state.split('\n') + state[n] = '☑' + state[n][1:] + state = '\n'.join(state) + model_test.model.setData(model_test.model.index(n), Qt.Checked, Qt.CheckStateRole) + model_test.check(state, config_change='update', data_change=[n], + undo_change=(True if n == 0 else None)) + for n in range(5): + model_test.model.undo() + model_test.check(state_stack.pop(), + config_change='update', + layout_change=True, + undo_change=(False if n == 4 else None)) + +def test_model_undo_2(model_test): + ''' + ☑ ★ user.json + ☑ ⎕ commands.json + ☑ ⎕ main.json + ''' + # Changing display order as no impact on the undo stack. + model_test.configure(classic_dictionaries_display_order=True) + model_test.check( + ''' + ☑ ⎕ main.json + ☑ ⎕ commands.json + ☑ ★ user.json + ''', + layout_change=True, + ) + + +class WidgetTest(namedtuple('WidgetTest', ''' + registry + bot widget + file_dialog + create_dictionary + model_test + ''')): + + def select(self, selection): + sm = self.widget.view.selectionModel() + for row in selection: + sm.select(self.model.index(row), sm.Select) + + def unselect(self, selection): + sm = self.widget.view.selectionModel() + for row in selection: + sm.select(self.model.index(row), sm.Deselect) + + def __getattr__(self, name): + return getattr(self.model_test, name) + + +@pytest.fixture +def widget_test(model_test, monkeypatch, qtbot): + # Fake registry. + def list_plugins(plugin_type): + assert plugin_type == 'dictionary' + for name, readonly in ( + ('bad', False), + ('json', False), + ('ro', True), + ): + obj = SimpleNamespace(readonly=readonly) + yield SimpleNamespace(name=name, obj=obj) + registry = mock.MagicMock(spec=['list_plugins']) + registry.list_plugins.side_effect = list_plugins + monkeypatch.setattr('plover.gui_qt.dictionaries_widget.registry', registry) + # Fake file dialog. + file_dialog = mock.MagicMock(spec=''' + getOpenFileNames + getSaveFileName + '''.split()) + monkeypatch.setattr('plover.gui_qt.dictionaries_widget.QFileDialog', file_dialog) + # Fake `create_dictionary`. + def create_dictionary(filename, threaded_save=True): + pass + steno_dict = mock.create_autospec(StenoDictionary) + create_dictionary = mock.create_autospec(create_dictionary, return_value=steno_dict) + monkeypatch.setattr('plover.gui_qt.dictionaries_widget.create_dictionary', create_dictionary) + # Patch `DictionariesModel` constructor to use our own instance. + monkeypatch.setattr('plover.gui_qt.dictionaries_widget.DictionariesModel', + lambda engine, icons: model_test.model) + widget = DictionariesWidget() + widget.setup(model_test.engine) + qtbot.addWidget(widget) + test = WidgetTest(registry, qtbot, widget, file_dialog, create_dictionary, model_test) + return test + + +@parametrize(( + # No selection. + lambda: ((), ''' + AddDictionaries + AddTranslation + '''), + # No loaded dictionary selected. + lambda: ([1, 4], ''' + AddDictionaries + AddTranslation + MoveDictionariesDown + MoveDictionariesUp + RemoveDictionaries + '''), + # At least one loaded dictionary selected. + lambda: ([0, 2], ''' + AddDictionaries + AddTranslation + EditDictionaries + MoveDictionariesDown + MoveDictionariesUp + RemoveDictionaries + SaveDictionaries + '''), + lambda: ([1, 3], ''' + AddDictionaries + AddTranslation + EditDictionaries + MoveDictionariesDown + MoveDictionariesUp + RemoveDictionaries + SaveDictionaries + '''), +)) +def test_widget_selection(widget_test, selection, enabled_actions): + ''' + ☑ ★ favorite.json + ☑ 🗘 loading.json + ☑ ⎕ normal.json + ☑ 🛇 read-only.ro + ☑ ! invalid.bad + ''' + widget_test.select(selection) + for action_name in ''' + AddDictionaries + AddTranslation + EditDictionaries + MoveDictionariesDown + MoveDictionariesUp + RemoveDictionaries + SaveDictionaries + Undo + '''.split(): + action = getattr(widget_test.widget, 'action_' + action_name) + enabled = action.isEnabled() + msg = '%s is %s' % (action_name, 'enabled' if enabled else 'disabled') + assert enabled == (action_name in enabled_actions), msg + + +FILE_PICKER_SAVE_FILTER = 'Dictionaries (*.bad *.json);; BAD dictionaries (*.bad);; JSON dictionaries (*.json)' + +def test_widget_save_copy_1(widget_test): + ''' + ☑ ★ favorite.json + ☑ 🗘 loading.json + ☑ ⎕ normal.json + ☑ 🛇 read-only.ro + ☑ ! invalid.bad + ''' + # Setup. + copy_names = ( + expand_path('favorite_copy.json'), + '', + expand_path('read-only_copy.json'), + ) + widget_test.file_dialog.getSaveFileName.side_effect = [ + [name] + for name in copy_names + ] + steno_dict_copies = ( + mock.create_autospec(StenoDictionary), + mock.create_autospec(StenoDictionary), + ) + widget_test.create_dictionary.side_effect = steno_dict_copies + # Execution. + widget_test.select(range(5)) + widget_test.widget.action_CopyDictionaries.trigger() + # Check. + assert widget_test.file_dialog.mock_calls == [ + mock.call.getSaveFileName( + parent=widget_test.widget, + caption='Save a copy of %s as...' % name, + directory=expand_path('%s - Copy.json' % Path(name).stem), + filter=FILE_PICKER_SAVE_FILTER, + ) + for name in ['favorite.json', 'normal.json', 'read-only.ro'] + ] + assert widget_test.create_dictionary.mock_calls == [ + mock.call(name, threaded_save=False) + for name in copy_names if name + ] + assert steno_dict_copies[0].mock_calls == [ + mock.call.update(widget_test.dictionaries.dicts[0]), + mock.call.save(), + ] + assert steno_dict_copies[1].mock_calls == [ + mock.call.update(widget_test.dictionaries.dicts[2]), + mock.call.save(), + ] + +def test_widget_save_merge_1(widget_test): + ''' + ☑ ★ favorite.json + ☑ 🗘 loading.json + ☑ ⎕ normal.json + ☑ 🛇 read-only.ro + ☑ ! invalid.bad + ''' + # Setup. + merge_name = 'favorite + normal + read-only' + widget_test.file_dialog.getSaveFileName.return_value = [expand_path('merge.json')] + # Execution. + widget_test.select(range(5)) + widget_test.widget.action_MergeDictionaries.trigger() + # Check. + assert widget_test.file_dialog.mock_calls == [mock.call.getSaveFileName( + parent=widget_test.widget, + caption='Merge %s as...' % merge_name, + directory=expand_path(merge_name + '.json'), + filter=FILE_PICKER_SAVE_FILTER, + )] + assert widget_test.create_dictionary.mock_calls == [mock.call(expand_path('merge.json'), threaded_save=False)] + steno_dict = widget_test.create_dictionary.return_value + assert steno_dict.mock_calls == [ + mock.call.update(widget_test.dictionaries.dicts[2]), + mock.call.update(widget_test.dictionaries.dicts[1]), + mock.call.update(widget_test.dictionaries.dicts[0]), + mock.call.save(), + ] + +def test_widget_save_merge_2(widget_test): + ''' + ☑ ★ favorite.json + ☑ 🗘 loading.json + ☑ ⎕ normal.json + ☑ 🛇 read-only.ro + ☑ ! invalid.bad + ''' + # Setup. + merge_name = 'favorite + normal' + widget_test.file_dialog.getSaveFileName.return_value = [''] + # Execution. + widget_test.select([0, 2]) + widget_test.widget.action_MergeDictionaries.trigger() + # Check. + assert widget_test.file_dialog.mock_calls == [mock.call.getSaveFileName( + parent=widget_test.widget, + caption='Merge %s as...' % merge_name, + directory=expand_path(merge_name + '.json'), + filter=FILE_PICKER_SAVE_FILTER, + )] + assert widget_test.create_dictionary.mock_calls == [] diff --git a/tox.ini b/tox.ini index 3dba280af..e32ed5500 100644 --- a/tox.ini +++ b/tox.ini @@ -111,7 +111,9 @@ commands = [testenv:test] description = run tests +setenv = + QT_QPA_PLATFORM=offscreen commands = - {envpython} setup.py -q test -- {posargs} + {envpython} -m pytest {posargs} # vim: ft=cfg list