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