├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md └── workflows │ ├── deploy_doc_l10n.yml │ ├── draft_release.yml │ ├── lint_pr.yml │ ├── lint_push.yml │ └── publish_release.yml ├── .gitignore ├── LICENSE.txt ├── README.md ├── docs ├── README.md ├── _template │ ├── breadcrumbs.html │ └── layout.html ├── conf.py ├── index.md ├── options.md └── pympress.md ├── pympress ├── __init__.py ├── __main__.py ├── app.py ├── builder.py ├── config.py ├── deck.py ├── dialog.py ├── document.py ├── editable_label.py ├── extras.py ├── media_overlays │ ├── __init__.py │ ├── base.py │ ├── gif_backend.py │ ├── gst_backend.py │ └── vlc_backend.py ├── pointer.py ├── scribble.py ├── share │ ├── applications │ │ └── io.github.pympress.desktop │ ├── css │ │ └── default.css │ ├── defaults.conf │ ├── locale │ │ ├── babel_mapping.cfg │ │ ├── cs │ │ │ └── LC_MESSAGES │ │ │ │ └── pympress.po │ │ ├── de │ │ │ └── LC_MESSAGES │ │ │ │ └── pympress.po │ │ ├── es │ │ │ └── LC_MESSAGES │ │ │ │ └── pympress.po │ │ ├── fr │ │ │ └── LC_MESSAGES │ │ │ │ └── pympress.po │ │ ├── hi │ │ │ └── LC_MESSAGES │ │ │ │ └── pympress.po │ │ ├── it │ │ │ └── LC_MESSAGES │ │ │ │ └── pympress.po │ │ ├── ja │ │ │ └── LC_MESSAGES │ │ │ │ └── pympress.po │ │ ├── pl │ │ │ └── LC_MESSAGES │ │ │ │ └── pympress.po │ │ ├── pympress.pot │ │ ├── zh_CN │ │ │ └── LC_MESSAGES │ │ │ │ └── pympress.po │ │ ├── zh_HANT │ │ │ └── LC_MESSAGES │ │ │ │ └── pympress.po │ │ └── zh_TW │ │ │ └── LC_MESSAGES │ │ │ └── pympress.po │ ├── pixmaps │ │ ├── eraser.png │ │ ├── make-png │ │ ├── marker_1.png │ │ ├── marker_2.png │ │ ├── marker_3.png │ │ ├── marker_fill_1.png │ │ ├── marker_fill_2.png │ │ ├── marker_fill_3.png │ │ ├── pointer.svg │ │ ├── pointer_blue.png │ │ ├── pointer_green.png │ │ ├── pointer_red.png │ │ ├── pympress-16.png │ │ ├── pympress-22.png │ │ ├── pympress-24.png │ │ ├── pympress-32.png │ │ ├── pympress-48.png │ │ ├── pympress-64.png │ │ ├── pympress.ico │ │ ├── pympress.png │ │ └── pympress.svg │ └── xml │ │ ├── autoplay.glade │ │ ├── content.glade │ │ ├── deck.glade │ │ ├── highlight.glade │ │ ├── layout_dialog.glade │ │ ├── media_overlay.glade │ │ ├── menu_bar.xml │ │ ├── presenter.glade │ │ ├── shortcuts.glade │ │ └── time_report_dialog.glade ├── surfacecache.py ├── talk_time.py ├── ui.py └── util.py ├── pyproject.toml ├── scripts └── poedit.sh ├── setup.cfg └── setup.py /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a problem in pympress 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Environment (please complete the following information):** 27 | - OS: [e.g. Ubuntu] 28 | - Python version: [e.g. 3.9] 29 | - Pympress version: [e.g. 1.5.0] 30 | - Installation method: [e.g. source, pip, binary installer, chocolatey, copr, other package manager] 31 | 32 | **Debug information (see below for file locations)** 33 | - What is reported in pympress.log? 34 | - Does the problem still happen if you remove your config file? 35 | (You can just move the config file to a different location to be able to restore it after testing) 36 | 37 | 38 | **Additional context** 39 | Add any other context about the problem here. 40 | 41 | 42 | 76 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Discussions 4 | url: https://github.com/Cimbali/pympress/discussions 5 | about: To ask and answer questions that don’t fit in the categories above. 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for pympress 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/deploy_doc_l10n.yml: -------------------------------------------------------------------------------- 1 | name: Update docs and translatable strings 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | strings: 10 | name: Upload translatable strings 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Python 15 | uses: actions/setup-python@v4 16 | with: 17 | python-version: '3.x' 18 | - name: Install dependencies 19 | run: | 20 | sudo apt-get update -q 21 | sudo apt-get install -qy jq 22 | pip install -e .[babel] 23 | - name: Extract 24 | run: python setup.py extract_messages 25 | - name: Upload 26 | env: 27 | poeditor_api_token: ${{ secrets.POEDITOR_API_TOKEN }} 28 | run: ./scripts/poedit.sh upload 29 | 30 | build: 31 | name: Generate docs 32 | needs: strings 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v4 36 | - name: Set up Python 37 | uses: actions/setup-python@v4 38 | with: 39 | python-version: '3.x' 40 | - name: Install dependencies 41 | run: | 42 | sudo apt-get update -q 43 | sudo apt-get install -qy jq gobject-introspection libgirepository-1.0-1 gir1.2-gtk-3.0 gir1.2-glib-2.0 gir1.2-gstreamer-1.0 gir1.2-poppler-0.18 python3-gi python3-gi-cairo python3-pip python3-setuptools python3-wheel python3-sphinx libgirepository1.0-dev vlc 44 | pip install .[build_sphinx] .[vlc_video] 45 | - name: Build 46 | env: 47 | poeditor_api_token: ${{ secrets.POEDITOR_API_TOKEN }} 48 | run: | 49 | ./scripts/poedit.sh contributors 50 | python3 -m sphinx -bhtml docs/ build/sphinx/html -t api_doc -t install_instructions 51 | tar czf pympress-docs.tar.gz -C build/sphinx/html/ . 52 | - name: Upload 53 | uses: actions/upload-artifact@v4 54 | with: 55 | name: pympress-docs.tar.gz 56 | path: pympress-docs.tar.gz 57 | 58 | deploy: 59 | name: Deploy docs 60 | needs: build 61 | runs-on: ubuntu-latest 62 | steps: 63 | - uses: actions/checkout@v4 64 | with: 65 | repository: pympress/pympress.github.io 66 | token: ${{ secrets.PYMPRESSDOCS_ACTION_PAT }} 67 | ref: main 68 | - name: Download 69 | uses: actions/download-artifact@v4 70 | with: 71 | name: pympress-docs.tar.gz 72 | path: . 73 | - name: Extract and push 74 | run: | 75 | tar xzf pympress-docs.tar.gz 76 | rm pympress-docs.tar.gz 77 | git add . 78 | git -c user.email=me@cimba.li -c user.name="${GITHUB_ACTOR}" commit -m "Github Action-built docs update" 79 | git push 80 | -------------------------------------------------------------------------------- /.github/workflows/lint_pr.yml: -------------------------------------------------------------------------------- 1 | name: Linting on PR, with stricter rules on new code 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | lint: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | python-version: [3.8, 3.9, '3.10', '3.11', '3.12', '3.13'] 14 | 15 | steps: 16 | - name: Checkout PR merge commit 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | python -m pip install flake8 flake8-docstrings 28 | 29 | - name: Fetch pull request target branch 30 | run: git fetch origin ${{ github.base_ref }} 31 | 32 | - name: Lint with standard ignores on modified files 33 | shell: bash 34 | run: | 35 | flake8 . --count --show-source --statistics | sed -r 'h;s/^(\S+):([0-9]+):([0-9]+): /::error file=\1,line=\2,col=\3::/p;g' 36 | 37 | - name: Lint changes with flake8 38 | shell: bash 39 | # Reduced list of ignores, applied on the changed lines only 40 | run: | 41 | git diff -z --name-only FETCH_HEAD -- '**.py' | xargs -r0 flake8 --exit-zero --ignore=D107,D200,D210,D413,E251,E302,E303,W504 > errors 42 | git diff FETCH_HEAD -U0 -- '**.py' | sed -rn -e '/^\+\+\+ /{s,^\+\+\+ ./,,;h}' -e '/^@@ /{G;s/^@@ -[0-9,]+ \+([0-9,]+) @@.*\n(.*)/\2,\1/p}' | ( 43 | while IFS=, read file start lines; do for (( l = start ; l < $start + ${lines:-1}; ++l )); do echo "^$file:$l:"; done; done 44 | ) > changed_lines 45 | # Invert return value, i.e. error iff matches 46 | ! grep -f changed_lines errors | sed -r 'h;s/^(\S+):([0-9]+):([0-9]+): /::error file=\1,line=\2,col=\3::/p;g' 47 | -------------------------------------------------------------------------------- /.github/workflows/lint_push.yml: -------------------------------------------------------------------------------- 1 | name: Linting on push 2 | 3 | on: [push] 4 | 5 | jobs: 6 | lint: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | python-version: [3.8, 3.9, '3.10', '3.11', '3.12', '3.13'] 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | python -m pip install flake8 flake8-docstrings 25 | 26 | - name: Lint with flake8 27 | # Full list of ignores, fail on errors. No Docs errors. 28 | run: | 29 | flake8 . --count --show-source --statistics --select=E,F,W,C | sed -r 'h;s/^(\S+):([0-9]+):([0-9]+): /::error file=\1,line=\2,col=\3::/p;g' 30 | shell: bash 31 | 32 | - name: Lint docstrings 33 | run: | 34 | flake8 . --count --show-source --statistics --select=D | sed -r 'h;s/^(\S+):([0-9]+):([0-9]+): /::warning file=\1,line=\2,col=\3::/p;g' 35 | shell: bash 36 | -------------------------------------------------------------------------------- /.github/workflows/publish_release.yml: -------------------------------------------------------------------------------- 1 | name: 'Publish package: upload to pypi, brew, obs, and copr' 2 | 3 | on: 4 | # When the draft release is converted to a public release, send out the binaries etc. to all the platforms 5 | release: 6 | types: [published] 7 | # Manual trigger 8 | workflow_dispatch: 9 | inputs: 10 | tag: 11 | description: 'Release tag for which to build' 12 | required: true 13 | 14 | jobs: 15 | pypi: 16 | runs-on: ubuntu-latest 17 | outputs: 18 | tag: ${{ steps.name.outputs.tag }} 19 | release: ${{ steps.name.outputs.release }} 20 | url: ${{ steps.info.outputs.url }} 21 | sha256: ${{ steps.info.outputs.sha256 }} 22 | changes: ${{ steps.changes.outputs.changes }} 23 | 24 | steps: 25 | - name: Set up Python 26 | uses: actions/setup-python@v4 27 | with: 28 | python-version: '3.x' 29 | 30 | - name: Install dependencies 31 | run: | 32 | sudo apt-get update -q 33 | sudo apt-get install -qy pandoc 34 | python -m pip install --break-system-packages --upgrade pip 35 | pip install --break-system-packages setuptools wheel twine babel 36 | 37 | - name: Define name 38 | id: name 39 | run: | 40 | ref=${{ github.ref }} 41 | [ "${ref::10}" = 'refs/tags/' ] && tag=${ref:10} || tag=${{ github.event.inputs.tag }} 42 | if echo ${tag#v} | grep -qxE '[0-9]+(\.[0-9]+)*' ; then release=final; else release=prerelease; fi 43 | printf '%s\n' "tag=${tag#v}" "release=$release" | tee -a $GITHUB_OUTPUT 44 | 45 | - name: Lookup what is in pypi currently 46 | id: prev 47 | run: | 48 | curl -sL "https://pypi.org/pypi/pympress/json" | 49 | jq '.releases | to_entries | map(.key as $version | .value | map(select(.packagetype == "sdist")) | first | .version = $version) | sort_by(.upload_time) | last' \ 50 | > last.json 51 | 52 | jq -r '"version=\(.version)","url=\(.url)","sha256=\(.digests.sha256)"' last.json | tee -a $GITHUB_OUTPUT 53 | 54 | - name: Extract changes from release 55 | id: changes 56 | shell: 57 | bash 58 | env: 59 | tag: ${{ steps.name.outputs.tag }} 60 | GITHUB_TOKEN: ${{ secrets.PYMPRESS_ACTION_PAT }} 61 | run: | 62 | curl -s -u "Cimbali:$GITHUB_TOKEN" -H "X-GitHub-Api-Version: 2022-11-28" -H "Accept: application/vnd.github+json" \ 63 | "https://api.github.com/repos/Cimbali/pympress/releases" -o - | 64 | jq ".[] | select(.tag_name == \"v$tag\") | del(.author, .assets[].uploader)" | tee release.json 65 | 66 | echo 'changes< updated.json ; do 96 | sleep 60 # be patient with pypi 97 | done 98 | 99 | jq -r '"url=\(.url)","sha256=\(.digests.sha256)"' updated.json | tee -a $GITHUB_OUTPUT 100 | 101 | - name: Run a check on the generated file 102 | run: | 103 | if ! jq -r '"\(.digests.sha256) dist/\(.filename)"' updated.json | sha256sum -c ; then 104 | echo '::warning:: Generated sdist file did not match pypi sha256sum' 105 | fi 106 | 107 | 108 | aur: 109 | name: Publish to AUR 110 | needs: pypi 111 | runs-on: ubuntu-latest 112 | steps: 113 | - name: Clone repo 114 | run: git clone https://Cimbali@github.com/Cimbali/pympress-pkgbuild aur-repo 115 | 116 | - name: Get info 117 | id: info 118 | env: 119 | tag: ${{ needs.pypi.outputs.tag }} 120 | run: | 121 | version=`awk -F= '$1 == "pkgver" {print $2}' aur-repo/PKGBUILD | tr -d "[()\"']"` 122 | if [[ "$version" = "$tag" ]]; then 123 | release=`awk -F= '$1 == "pkgrel" {print $2}' aur-repo/PKGBUILD | tr -d "[()\"']"` 124 | else 125 | release=0 126 | fi 127 | 128 | printf '%s\n' "version=$version" "release=$release" | tee -a $GITHUB_OUTPUT 129 | 130 | - name: Update info 131 | run: | 132 | while read param value; do 133 | sed -i -r "s,^(\\s*$param ?=[('\" ]*)[A-Za-z0-9\${\}:/._-]+([ '\")]*)$,\1$value\2," aur-repo/.SRCINFO aur-repo/PKGBUILD 134 | done < ./osc-config 172 | osc="osc --config $GITHUB_WORKSPACE/osc-config" 173 | 174 | $osc co -o osc home:cimbali python-pympress 175 | version=`awk -F: '$1 == "Version" { print $2 }' osc/pympress.spec | tr -d ' '` 176 | 177 | echo version=$version | tee -a $GITHUB_OUTPUT 178 | 179 | if ! grep -qFe "- *Update to v${version//./\\.}" osc/pympress.changes; then 180 | echo '::warning:: Last version missing from changelog' 181 | fi 182 | env: 183 | OPENBUILDSERVICE_TOKEN_SECRET: ${{ secrets.OPENBUILDSERVICE_TOKEN_SECRET }} 184 | 185 | - name: Upload to OpenBuildService 186 | if: needs.pypi.outputs.tag != steps.info.outputs.version 187 | run: | 188 | trap 'rm -f ./osc-config' EXIT && echo "$OPENBUILDSERVICE_TOKEN_SECRET" > ./osc-config 189 | osc="osc --config $GITHUB_WORKSPACE/osc-config" 190 | cd osc 191 | 192 | $osc vc -m "$changes" 193 | sed -i "2s/Cimba Li /me@cimba.li/" pympress.changes 194 | $osc ci -m "Release $tag" 195 | 196 | $osc sr --yes -m "Version $tag" 'X11:Utilities' pympress 197 | env: 198 | OPENBUILDSERVICE_TOKEN_SECRET: ${{ secrets.OPENBUILDSERVICE_TOKEN_SECRET }} 199 | tag: ${{ needs.pypi.outputs.tag }} 200 | changes: ${{ needs.pypi.outputs.changes }} 201 | 202 | 203 | copr: 204 | name: Upload to COPR 205 | needs: pypi 206 | runs-on: ubuntu-latest 207 | 208 | steps: 209 | - name: Install dependencies 210 | run: | 211 | sudo apt-get update -q 212 | sudo apt-get install -qy cpio rpm2cpio python3-m2crypto 213 | python3 -m pip install --break-system-packages copr-cli 214 | 215 | - name: Fetch info from COPR 216 | id: info 217 | run: | 218 | trap 'rm -f ./copr-config' EXIT && echo "$COPR_TOKEN_CONFIG" > ./copr-config 219 | copr-cli --config ./copr-config get-package --name=python3-pympress --output-format=json cimbali/pympress | 220 | jq -r '"version=\(.latest_build.source_package.version | sub("-[0-9]*$"; ""))"' | tee -a $GITHUB_OUTPUT 221 | env: 222 | COPR_TOKEN_CONFIG: ${{ secrets.COPR_TOKEN_CONFIG }} 223 | 224 | - name: Get SRPM URL from GitHub Release and download 225 | env: 226 | tag: ${{ needs.pypi.outputs.tag }} 227 | run: | 228 | url="https://github.com/Cimbali/pympress/releases/download/v${tag}/python3-pympress-${tag}-1.src.rpm" 229 | curl -L "$url" -o "python3-pympress-${tag}-1.src.rpm" 230 | 231 | - name: Upload to COPR 232 | if: needs.pypi.outputs.tag != steps.info.outputs.version 233 | run: | 234 | trap 'rm -f ./copr-config' EXIT && echo "$COPR_TOKEN_CONFIG" > ./copr-config 235 | copr-cli --config ./copr-config build --nowait cimbali/pympress "python3-pympress-${tag}-1.src.rpm" 236 | env: 237 | tag: ${{ needs.pypi.outputs.tag }} 238 | COPR_TOKEN_CONFIG: ${{ secrets.COPR_TOKEN_CONFIG }} 239 | 240 | 241 | brew: 242 | name: Request Homebrew pull new version 243 | needs: pypi 244 | runs-on: macos-latest 245 | 246 | steps: 247 | - name: Install dependencies 248 | continue-on-error: true 249 | run: | 250 | brew update 251 | brew upgrade 252 | brew install pipgrip gnu-sed 253 | 254 | - name: Configure brew repo 255 | run: | 256 | cd "`brew --repo homebrew/core`" 257 | # Credentials and remotes 258 | git config user.name Cimbali 259 | git config user.email me@cimba.li 260 | git config credential.helper store 261 | echo -e "protocol=https\nhost=github.com\nusername=Cimbali\npassword=$PASSWORD" | git credential-store store 262 | git remote add gh "https://github.com/Cimbali/homebrew-core/" 263 | git fetch gh 264 | # Attempt a rebase of changes in our repo copy 265 | git checkout --detach 266 | git rebase origin/master gh/master && git branch -f master HEAD || git rebase --abort 267 | # Now use master and update remote so we can use the bump-formula-pr 268 | git checkout master 269 | git push gh -f master:master 270 | env: 271 | PASSWORD: ${{ secrets.PYMPRESS_ACTION_PAT }} 272 | 273 | - name: Get info from repo 274 | id: info 275 | run: | 276 | cd "`brew --repo homebrew/core`" 277 | 278 | version=`gsed -rn 's,^\s*url "[a-z]+://([a-z0-9_.-]+/)*pympress-(.*)\.tar\.gz"$,\2,p' Formula/p/pympress.rb` 279 | echo version=$version | tee -a $GITHUB_OUTPUT 280 | 281 | curl -sL -u "Cimbali:$GITHUB_TOKEN" -H "X-GitHub-Api-Version: 2022-11-28" -H "Accept: application/vnd.github+json" -o - \ 282 | 'https://api.github.com/repos/Homebrew/homebrew-core/pulls?state=open&per_page=100' > pulls.json 283 | jq -r '.[] | [.number, .title] | join(",")' > pulls.csv 284 | 285 | pr=`jq -r "map(select(.title == \"pympress $tag\")) | first.number? " pulls.json` 286 | echo pr=$pr | tee -a $GITHUB_OUTPUT 287 | 288 | if [ "$pr" != "null" ]; then 289 | echo "::warning:: Pull request already open at https://github.com/Homebrew/homebrew-core/pull/$pr" 290 | fi 291 | 292 | env: 293 | tag: ${{ needs.pypi.outputs.tag }} 294 | 295 | - name: Make a brew PR from pypi’s metadata 296 | if: needs.pypi.outputs.release == 'final' && ( steps.info.outputs.version != needs.pypi.outputs.tag ) && ! steps.info.outputs.pr 297 | run: | 298 | brew bump-formula-pr --strict --no-browse --url="${{needs.pypi.outputs.url}}" --sha256="${{needs.pypi.outputs.sha256}}" pympress 299 | env: 300 | HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.PYMPRESS_ACTION_PAT }} 301 | HUB_REMOTE: https://github.com/Cimbali/homebrew-core/ 302 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | build/ 3 | dist/ 4 | 5 | pympress.egg-info/ 6 | 7 | pympress/share/locale/*/LC_MESSAGES/pympress.mo 8 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | ```{include} ../README.md 2 | :relative-docs: docs/ 3 | ``` 4 | -------------------------------------------------------------------------------- /docs/_template/breadcrumbs.html: -------------------------------------------------------------------------------- 1 | {% extends "!breadcrumbs.html" %} 2 | 3 | {# do the setup for the github links to appear correctly #} 4 | 5 | {% set display_github = True %} 6 | {% set github_user = 'Cimbali' %} 7 | {% set github_repo = 'pympress' %} 8 | {% set github_version = 'master' %} 9 | 10 | {% if pagename == 'README' %} 11 | {% set conf_py_path = '/' %} 12 | {% else %} 13 | {% set conf_py_path = '/docs/source/' %} 14 | {% endif %} 15 | 16 | -------------------------------------------------------------------------------- /docs/_template/layout.html: -------------------------------------------------------------------------------- 1 | {% extends "!layout.html" %} 2 | {% block sidebartitle %} 3 | Pympress on GitHub 4 |
5 | Docs home 6 | 7 |
8 | v{{ version }} 9 |
10 | 11 | {% include "searchbox.html" %} 12 | {% endblock %} 13 | {% block menu %} 14 | {{ super() }} 15 | {% if document_api %} 16 |
17 | Index 18 | Module index 19 | {% endif %} 20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Welcome to pympress's documentation! 2 | 3 | ## Contents 4 | 5 | ```{toctree} 6 | :maxdepth: 2 7 | 8 | README.md 9 | options.md 10 | pympress.md 11 | ``` 12 | 13 | 14 | ## Indices and tables 15 | 16 | ```{eval-rst} 17 | * :ref:`genindex` 18 | * :ref:`modindex` 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/options.md: -------------------------------------------------------------------------------- 1 | # Configuration file 2 | 3 | Pympress has a number of options available from its configuration file. 4 | 5 | This file is usually located in: 6 | - `~/.config/pympress` on Linux, 7 | - `%APPDATA%/pympress.ini` on Windows, 8 | - `~/Library/Preferences/pympress` on macOS, 9 | - in the top-level of the pympress install directory for portable installations. 10 | 11 | The path to the currently used configuration file can be checked in the `Help > About` information window. 12 | 13 | ## Shortcuts 14 | 15 | The shortcuts are parsed using [`Gtk.accelerator_parse()`](https://lazka.github.io/pgi-docs/#Gtk-3.0/functions.html#Gtk.accelerator_parse): 16 | 17 | > The format looks like “\a” or “\\F1” or “\z” (the last one is for key release). 18 | > 19 | > The parser is fairly liberal and allows lower or upper case, and also abbreviations such as “\” and “\”. Key names are parsed using [`Gdk.keyval_from_name()`](https://lazka.github.io/pgi-docs/#Gdk-3.0/functions.html#Gdk.keyval_from_name). For character keys the name is not the symbol, but the lowercase name, e.g. one would use “\minus” instead of “\-”. 20 | 21 | This means that any value in this [list of key constants](https://lazka.github.io/pgi-docs/#Gdk-3.0/constants.html#Gdk.KEY_0) is valid (removing the initial `Gdk.KEY_` part). You can verify that this value is parsed correctly from the `Help > Shortcuts` information window. 22 | 23 | ## Layouts 24 | 25 | The panes (current slide, next slide, notes, annotations, etc.) can be rearranged arbitrarily by setting the entries of the `layout` section in the configuration file. 26 | Here are a couple examples of layouts, with `Cu` the current slide, `No` the notes half of the slide, `Nx` the next slide: 27 | 28 | - All-horizontal layout: 29 | 30 | +----+----+----+ 31 | | Cu | No | Nx | 32 | +----+----+----+ 33 | 34 | Setting: 35 | 36 | notes = {"children": ["current", "notes", "next"], "proportions": [0.33, 0.33, 0.33], "orientation": "horizontal", "resizeable": true} 37 | 38 | - All-vertical layout: 39 | 40 | +----+ 41 | | Cu | 42 | +----+ 43 | | No | 44 | +----+ 45 | | Nx | 46 | +----+ 47 | 48 | Setting: 49 | 50 | notes = {"children": ["current", "notes", "next"], "proportions": [0.33, 0.33, 0.33], "orientation": "vertical", "resizeable": true} 51 | 52 | - Vertical layout with horizontally divided top pane: 53 | 54 | +----+----+ 55 | | Cu | No | 56 | +----+----+ 57 | | Nx | 58 | +---------+ 59 | 60 | Setting: 61 | 62 | notes = {"children": [ 63 | {"children": ["current", "notes"], "proportions": [0.5, 0.5], "orientation": "horizontal", "resizeable": true}, 64 | "next" 65 | ], "proportions": [0.5, 0.5], "orientation": "vertical", "resizeable": true} 66 | 67 | 68 | - Horizontal layout with horizontally divided right pane: 69 | 70 | +----+----+ 71 | | | Nx | 72 | + Cu +----+ 73 | | | No | 74 | +---------+ 75 | 76 | Setting: 77 | 78 | notes = {"children": [ 79 | "current", 80 | {"children": ["next", "notes"], "proportions": [0.5, 0.5], "orientation": "vertical", "resizeable": true} 81 | ], "proportions": [0.5, 0.5], "orientation": "horizontal", "resizeable": true} 82 | 83 | And so on. You can play with the items, their nesting, their order, and the orientation in which a set of widgets appears. 84 | 85 | For each entry the widgets (strings that are leaves of "children" nodes in this representation) must be: 86 | 87 | - for `notes`: "current", "notes", "next" 88 | - for `plain`: "current", "next" and "annotations" (the annotations widget is toggled with the `A` key by default) 89 | - for `highlight`: same as `plain` with "highlight" instead of "current" 90 | 91 | A few further remarks: 92 | 93 | - If you set "resizeable" to `false`, the panes won’t be resizeable dynamically with a handle in the middle 94 | - "proportions" are normalized, and saved on exit if you resize panes during the execution. If you set them to `4` and `1`, the panes will be `4 / (4 + 1) = 20%` and `1 / (4 + 1) = 100%`, so the ini will contain something like `0.2` and `0.8` after executing pympress. 95 | 96 | ## Themes on Windows 97 | 98 | Pympress uses the default Gtk theme of your system, which makes it easy to change on many OSs either globally via your Gtk preferences or [per application](https://www.linuxuprising.com/2019/10/how-to-use-different-gtk-3-theme-for.html). 99 | Here’s the way to do it on windows: 100 | 101 | 1. Install a theme 102 | 103 | There are 2 locations, either install the theme for all your gtk apps, e.g. in `C:\Users\%USERNAME%\AppData\Local\themes`, or just for pympress, so in `%INSTALLDIR%\share\themes` (for me that’s `C:\Users\%USERNAME%\AppData\Local\Programs\pympress\share\themes`) 104 | 105 | Basically pick a theme [e.g. from this list of dark themes](https://www.gnome-look.org/browse/cat/135/ord/rating/?tag=dark) and make sure to unpack it in the selected directory, it needs at least `%THEMENAME%\gtk-3.0\gtk.css` and `%THEMENAME%\index.theme`, where `THEMENAME` is the name of the theme. 106 | 107 | There are 2 pitfalls to be aware of, to properly install a theme: 108 | - themes that are not self-contained (relying on re-using css from default linux themes that you might not have), and 109 | - linux links (files under gtk-3.0/ that point to a directory above and that need to be replaced by a directory containing the contents of the target directory that has the same name as the link file). 110 | 111 | 2. Set the theme as default 112 | 113 | Create a `settings.ini` file, either under `C:\Users\%USERNAME%\AppData\Local\gtk-3.0` (global setting) or `%INSTALLDIR%\etc\gtk-3.0` (just pympress) and set the contents: 114 | 115 | ```ini 116 | [Settings] 117 | gtk-theme-name=THEMENAME 118 | ``` 119 | 120 | Here’s what it looks like with the [Obscure-Orange](https://www.gnome-look.org/p/1254680/) theme. 121 | ![VirtualBox_Win64_16_05_2021_01_23_19](https://user-images.githubusercontent.com/6126377/118380851-70d3d080-b5e5-11eb-97ac-65961f343a2d.png) 122 | 123 | In testing this found these 2 stackoverflow questions useful: 124 | - [Change GTK+3 look on Windows](https://stackoverflow.com/a/39041558/1387346) which contains a list of all interesting directories 125 | - [How to get native windows decorations on GTK3 on Windows 7+ and MSYS2](https://stackoverflow.com/a/37060369/1387346) which details the process 126 | -------------------------------------------------------------------------------- /docs/pympress.md: -------------------------------------------------------------------------------- 1 | # Pympress package 2 | 3 | This page contains the inline documentation, generated from the code using sphinx. 4 | 5 | The code is documented in the source using the [Google style](https://google.github.io/styleguide/pyguide.html) for docstrings. Sphinx has gathered a [set of examples](http://www.sphinx-doc.org/en/latest/ext/example_google.html) which serves as a better crash course than the full style reference. 6 | 7 | Retructured text (rst) can be used inside the comments and docstrings. 8 | 9 | ## Modules 10 | 11 | ```{eval-rst} 12 | .. automodule:: pympress.__main__ 13 | :members: 14 | :undoc-members: 15 | :show-inheritance: 16 | 17 | .. automodule:: pympress.app 18 | :members: 19 | :undoc-members: 20 | :show-inheritance: 21 | 22 | .. automodule:: pympress.ui 23 | :members: 24 | :undoc-members: 25 | :show-inheritance: 26 | 27 | .. automodule:: pympress.document 28 | :members: 29 | :undoc-members: 30 | :show-inheritance: 31 | 32 | .. automodule:: pympress.builder 33 | :members: 34 | :undoc-members: 35 | :show-inheritance: 36 | 37 | .. automodule:: pympress.surfacecache 38 | :members: 39 | :undoc-members: 40 | :show-inheritance: 41 | 42 | .. automodule:: pympress.scribble 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | .. automodule:: pympress.pointer 48 | :members: 49 | :undoc-members: 50 | :show-inheritance: 51 | 52 | .. automodule:: pympress.editable_label 53 | :members: 54 | :undoc-members: 55 | :show-inheritance: 56 | 57 | .. automodule:: pympress.talk_time 58 | :members: 59 | :undoc-members: 60 | :show-inheritance: 61 | 62 | .. automodule:: pympress.config 63 | :members: 64 | :undoc-members: 65 | :show-inheritance: 66 | 67 | .. automodule:: pympress.dialog 68 | :members: 69 | :undoc-members: 70 | :show-inheritance: 71 | 72 | .. automodule:: pympress.extras 73 | :members: 74 | :undoc-members: 75 | :show-inheritance: 76 | 77 | .. automodule:: pympress.deck 78 | :members: 79 | :undoc-members: 80 | :show-inheritance: 81 | 82 | .. automodule:: pympress.util 83 | :members: 84 | :undoc-members: 85 | :show-inheritance: 86 | 87 | .. automodule:: pympress.media_overlays.base 88 | :members: 89 | :undoc-members: 90 | :show-inheritance: 91 | 92 | .. automodule:: pympress.media_overlays.gif_backend 93 | :members: 94 | :undoc-members: 95 | :show-inheritance: 96 | 97 | .. automodule:: pympress.media_overlays.gst_backend 98 | :members: 99 | :undoc-members: 100 | :show-inheritance: 101 | 102 | .. automodule:: pympress.media_overlays.vlc_backend 103 | :members: 104 | :undoc-members: 105 | :show-inheritance: 106 | ``` 107 | 108 | -------------------------------------------------------------------------------- /pympress/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # __init__.py 4 | # 5 | # Copyright 2015 Cimbali 6 | # 7 | # This program is free software; you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation; either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with this program; if not, write to the Free Software 19 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, 20 | # MA 02110-1301, USA. 21 | 22 | """ A simple and powerful dual-screen PDF reader designed for presentations. 23 | """ 24 | 25 | # 26 | # DON'T IMPORT ANYTHING HERE (OR YOU WILL BREAK setup.py) 27 | # 28 | 29 | __version__ = '1.8.6' 30 | __author__ = """2009, 2010 Thomas Jost 31 | 2015-2023 Cimbali 32 | 2016 Christoph Rath 33 | 2016 Epithumia 34 | """ 35 | 36 | __all__ = ['app', 'builder', 'config', 'document', 'editable_label', 'extras', 'media_overlays', 'pointer', 'scribble', 37 | 'surfacecache', 'talk_time', 'ui', 'util'] 38 | -------------------------------------------------------------------------------- /pympress/__main__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # pympress 4 | # 5 | # Copyright 2009, 2010 Thomas Jost 6 | # Copyright 2015 Cimbali 7 | # 8 | # This program is free software; you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License as published by 10 | # the Free Software Foundation; either version 2 of the License, or 11 | # (at your option) any later version. 12 | # 13 | # This program is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU General Public License for more details. 17 | # 18 | # You should have received a copy of the GNU General Public License 19 | # along with this program; if not, write to the Free Software 20 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, 21 | # MA 02110-1301, USA. 22 | """ 23 | :mod:`pympress.__main__` -- The entry point of pympress 24 | ------------------------------------------------------- 25 | """ 26 | 27 | import logging 28 | import os 29 | import sys 30 | import locale 31 | 32 | from pympress import util 33 | 34 | 35 | # Setup logging, and catch all uncaught exceptions in the log file. 36 | # Load pympress.util early (OS and path-specific things) to load and setup gettext translation asap. 37 | logger = logging.getLogger(__name__) 38 | logging.basicConfig(filename=util.get_log_path(), level=logging.DEBUG) 39 | 40 | 41 | def uncaught_handler(*exc_info): 42 | """ Exception handler, to log uncaught exceptions to our log file. 43 | """ 44 | logger.critical('Uncaught exception:\n{}'.format(logging.Formatter().formatException(exc_info))) 45 | sys.__excepthook__(*exc_info) 46 | 47 | 48 | sys.excepthook = uncaught_handler 49 | 50 | 51 | if util.IS_WINDOWS: 52 | if os.getenv('LANG') is None: 53 | lang, enc = locale.getdefaultlocale() 54 | os.environ['LANG'] = lang 55 | 56 | # Before any initialisation or imports 57 | util.make_windows_dpi_aware() 58 | 59 | 60 | try: 61 | loaded_locale = locale.setlocale(locale.LC_ALL, '') 62 | except locale.Error as err: 63 | logger.exception('Failed loading locale: {}'.format(err)) 64 | print('Failed loading locale: {}'.format(err), file=sys.stderr) 65 | 66 | util.get_translation('pympress').install() 67 | 68 | 69 | try: 70 | # python <3.6 does not have this 71 | ModuleNotFoundError 72 | except NameError: 73 | ModuleNotFoundError = ImportError # noqa: A001 -- not shadowing ModuleNotFoundError if it doesn’t exist 74 | 75 | 76 | # Load python bindings for gobject introspections, aka pygobject, aka gi, and pycairo. 77 | # These are dependencies that are not specified in the setup.py, so we need to start here. 78 | # They are not specified because: 79 | # - installing those via pip requires compiling (always for pygobject, if no compatible wheels exist for cairo), 80 | # - compiling requires a compiling toolchain, development packages of the libraries, etc., 81 | # - all of this makes more sense to be handled by the OS package manager, 82 | # - it is hard to make pretty error messages pointing this out at `pip install` time, 83 | # as they would have to be printed when the dependency resolution happens. 84 | # See https://github.com/Cimbali/pympress/issues/100 85 | try: 86 | import gi 87 | gi.require_version('Gtk', '3.0') 88 | from gi.repository import Gtk, Gdk, GLib, Gio 89 | import cairo 90 | except ModuleNotFoundError: 91 | logger.critical('Gobject Introspections and/or pycairo module is missing', exc_info = True) 92 | print(_(""" 93 | ERROR: Gobject Introspections and/or pycairo module is missing. Make sure Gtk, pygobject and pycairo are installed. 94 | 95 | Try your operating system’s package manager, and ensure you installed pympress with access to system packages. 96 | Typically, this means having installed with: 97 | pipx install --system-site-packages pympress 98 | 99 | Alternately, ask pip to download and compile pygobject and pycairo, for which you may need the Gtk and cairo headers 100 | (or development packages): 101 | pipx inject pympress pygobject pycairo 102 | 103 | For further instructions, refer to https://github.com/Cimbali/pympress/blob/master/README.md#dependencies 104 | """)) 105 | exit(1) 106 | 107 | 108 | 109 | # Finally the real deal: load pympress modules, handle command line args, and start up 110 | from pympress import app 111 | 112 | 113 | def main(argv = sys.argv[:]): 114 | """ Entry point of pympress. Parse command line arguments, instantiate the UI, and start the main loop. 115 | """ 116 | app.Pympress().run(argv) 117 | 118 | 119 | if __name__ == "__main__": 120 | main() 121 | 122 | ## 123 | # Local Variables: 124 | # mode: python 125 | # indent-tabs-mode: nil 126 | # py-indent-offset: 4 127 | # fill-column: 80 128 | # end: 129 | -------------------------------------------------------------------------------- /pympress/media_overlays/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # media_overlays/__init__.py 4 | # 5 | # Copyright 2018 Cimbali 6 | # 7 | # This program is free software; you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation; either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with this program; if not, write to the Free Software 19 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, 20 | # MA 02110-1301, USA. 21 | 22 | """ Backends for video overlay widgets. 23 | """ 24 | 25 | __all__ = ['base', 'gif_backend', 'gst_backend', 'vlc_backend'] 26 | -------------------------------------------------------------------------------- /pympress/media_overlays/base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # media_overlays/base.py 4 | # 5 | # Copyright 2015 Cimbali 6 | # 7 | # Vaguely inspired from: 8 | # gtk example/widget for VLC Python bindings 9 | # Copyright (C) 2009-2010 the VideoLAN team 10 | # 11 | # This program is free software; you can redistribute it and/or modify 12 | # it under the terms of the GNU General Public License as published by 13 | # the Free Software Foundation; either version 2 of the License, or 14 | # (at your option) any later version. 15 | # 16 | # This program is distributed in the hope that it will be useful, 17 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 18 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 19 | # GNU General Public License for more details. 20 | # 21 | # You should have received a copy of the GNU General Public License 22 | # along with this program; if not, write to the Free Software 23 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA. 24 | # 25 | """ 26 | :mod:`pympress.media_overlays.base` -- base widget to play videos with an unspecified backend 27 | --------------------------------------------------------------------------------------------- 28 | """ 29 | 30 | import logging 31 | logger = logging.getLogger(__name__) 32 | 33 | import gi 34 | gi.require_version('Gtk', '3.0') 35 | from gi.repository import GLib, Gio 36 | 37 | from pympress import document, builder 38 | 39 | 40 | class VideoOverlay(builder.Builder): 41 | """ Simple Video widget. 42 | 43 | All do_X() functions are meant to be called from the main thread, through e.g. :func:`~GLib.idle_add`, 44 | for thread-safety in the handling of video backends. 45 | 46 | Args: 47 | container (:class:`~Gtk.Overlay`): The container with the slide, at the top of which we add the movie area 48 | page_type (:class:`~pympress.document.PdfPage`): the part of the page to display 49 | action_map (:class:`~Gio.ActionMap`): the action map that contains the actions for this media 50 | media (:class:`~pympress.document.Media`): the object defining the properties of the video such as position etc. 51 | """ 52 | #: :class:`~Gtk.Overlay` that is the parent of the VideoOverlay widget. 53 | parent = None 54 | #: :class:`~Gtk.VBox` that contains all the elements to be overlaid. 55 | media_overlay = None 56 | #: A :class:`~Gtk.HBox` containing a toolbar with buttons and :attr:`~progress` the progress bar 57 | toolbar = None 58 | #: :class:`~Gtk.Scale` that is the progress bar in the controls toolbar - if we have one. 59 | progress = None 60 | #: :class:`~Gtk.DrawingArea` where the media is rendered. 61 | movie_zone = None 62 | #: `tuple` containing the left/top/right/bottom coordinates of the drawing area in the PDF page 63 | relative_rect = None 64 | #: `tuple` containing the left/top/right/bottom coordinates of the drawing area in the visible slide 65 | rect = None 66 | #: `bool` that tracks whether we should play automatically 67 | autoplay = False 68 | #: `bool` that tracks whether we should play after we finished playing 69 | repeat = False 70 | #: `str` representing the mime type of the media file 71 | media_type = '' 72 | #: `float` giving the initial starting position for playback 73 | start_pos = 0. 74 | #: `float` giving the end position for playback, if any 75 | end_pos = None 76 | #: `float` representing the last know timestamp at which the progress bar updated 77 | last_timestamp = 0. 78 | 79 | #: `bool` that tracks whether the user is dragging the position 80 | dragging_position = False 81 | #: `bool` that tracks whether the playback was paused when the user started dragging the position 82 | dragging_paused = False 83 | #: Format of the video time, defaults to m:ss, changed to m:ss / m:ss when the max time is known 84 | time_format = '{:01}:{:02}' 85 | #: `float` holding the max time in s 86 | maxval = 1 87 | 88 | #: :class:`~Gio.ActionMap` containing the actios for this video overlay 89 | action_map = None 90 | 91 | def __init__(self, container, page_type, action_map, media): 92 | super(VideoOverlay, self).__init__() 93 | 94 | self.parent = container 95 | self.relative_rect = (media.x1, media.y1, media.x2, media.y2) 96 | self.update_margins_for_page(page_type) 97 | 98 | self.load_ui('media_overlay') 99 | self.toolbar.set_visible(media.show_controls) 100 | 101 | self.connect_signals(self) 102 | 103 | # medias, here the actions are scoped to the current widget 104 | self.action_map = action_map 105 | self.media_overlay.insert_action_group('media', self.action_map) 106 | if media.type: 107 | self.media_type = media.type 108 | else: 109 | content_type, uncertain = Gio.content_type_guess(media.filename.as_uri()) 110 | self.media_type = Gio.content_type_get_mime_type(content_type) 111 | self.start_pos = media.start_pos 112 | self.end_pos = float('inf') if media.duration == 0 else media.start_pos + media.duration 113 | self._set_file(media.filename) 114 | 115 | self.autoplay = media.autoplay 116 | self.repeat = media.repeat 117 | # TODO: handle poster 118 | 119 | 120 | def handle_embed(self, mapped_widget): 121 | """ Handler to embed the video player in the window, connected to the :attr:`~.Gtk.Widget.signals.map` signal. 122 | """ 123 | return False 124 | 125 | 126 | def format_millis(self, sc, prog): 127 | """ Callback to format the current timestamp (in milliseconds) as minutes:seconds. 128 | 129 | Args: 130 | sc (:class:`~Gtk.Scale`): The scale whose position we are formatting 131 | prog (`float`): The position of the :class:`~Gtk.Scale`, i.e. the number of seconds elapsed 132 | """ 133 | return self.time_format.format(*divmod(int(round(prog)), 60)) 134 | 135 | 136 | def update_range(self, max_time): 137 | """ Update the toolbar slider size. 138 | 139 | Args: 140 | max_time (`float`): The maximum time in this video in s 141 | """ 142 | self.maxval = max_time 143 | self.progress.set_range(0, self.maxval) 144 | self.progress.set_increments(min(5., self.maxval / 10.), min(60., self.maxval / 10.)) 145 | sec = round(self.maxval) if self.maxval > .5 else 1. 146 | self.time_format = '{{:01}}:{{:02}} / {:01}:{:02}'.format(*divmod(int(sec), 60)) 147 | 148 | 149 | def update_progress(self, time): 150 | """ Update the toolbar slider to the current time. 151 | 152 | Args: 153 | time (`float`): The time in this video in s 154 | """ 155 | self.progress.set_value(time) 156 | 157 | 158 | def progress_moved(self, rng, sc, val): 159 | """ Callback to update the position of the video when the user moved the progress bar. 160 | 161 | Args: 162 | rng (:class:`~Gtk.Range`): The range corresponding to the scale whose position we are formatting 163 | sc (:class:`~Gtk.Scale`): The scale whose position we are updating 164 | val (`float`): The position of the :class:`~Gtk.Scale`, which is the number of seconds elapsed in the video 165 | """ 166 | return self.action_map.lookup_action('set_time').activate(GLib.Variant.new_double(val)) 167 | 168 | 169 | def play_pause(self, *args): 170 | """ Callback to toggle play/pausing from clicking on the DrawingArea 171 | """ 172 | return self.action_map.lookup_action('pause').activate() 173 | 174 | 175 | def handle_end(self): 176 | """ End of the stream reached: restart if looping, otherwise hide overlay 177 | """ 178 | if not self.repeat: 179 | self.action_map.lookup_action('stop').activate() 180 | else: 181 | self.action_map.lookup_action('set_time').activate(GLib.Variant.new_double(self.start_pos)) 182 | 183 | 184 | def update_margins_for_page(self, page_type): 185 | """ Recalculate the margins around the media in the event of a page type change. 186 | 187 | Arguments: 188 | page_type (:class:`~pympress.document.PdfPage`): the part of the page to display 189 | """ 190 | left, top, right, bot = page_type.to_screen(*self.relative_rect) 191 | 192 | # Some configurations generate incorrect media positions. Assume no one intentionally puts media on notes pages. 193 | if page_type == document.PdfPage.RIGHT and -1 <= left < right <= 0: 194 | left, right = left + 1, right + 1 195 | logger.warning('Shifting media from LEFT notes page to RIGHT content page') 196 | 197 | if page_type == document.PdfPage.TOP and 1 <= top < bot <= 2: 198 | top, bot = top - 1, bot - 1 199 | logger.warning('Shifting media from BOTTOM notes page to TOP content page') 200 | 201 | self.rect = left, top, right, bot 202 | if min(self.rect) < 0 or max(self.rect) > 1: 203 | logger.warning('Negative margin(s) clipped to 0 (might alter the aspect ratio?): ' + 204 | 'LTRB = {}'.format(self.rect)) 205 | 206 | 207 | def resize(self): 208 | """ Adjust the position and size of the media overlay. 209 | """ 210 | if not self.is_shown(): 211 | return 212 | 213 | pw, ph = self.parent.get_allocated_width(), self.parent.get_allocated_height() 214 | left, top, right, bot = self.rect 215 | self.media_overlay.props.margin_left = pw * max(left, 0) 216 | self.media_overlay.props.margin_right = pw * min(1 - right, 1) 217 | self.media_overlay.props.margin_bottom = ph * min(1 - bot, 1) 218 | self.media_overlay.props.margin_top = ph * max(top, 0) 219 | 220 | 221 | def is_shown(self): 222 | """ Returns whether the media overlay is currently added to the overlays, or hidden. 223 | 224 | Returns: 225 | `bool`: `True` iff the overlay is currently displayed. 226 | """ 227 | return self.media_overlay.get_parent() is not None 228 | 229 | 230 | def is_playing(self): 231 | """ Returns whether the media is currently playing (and not paused). 232 | 233 | Returns: 234 | `bool`: `True` iff the media is playing. 235 | """ 236 | raise NotImplementedError 237 | 238 | 239 | def do_stop(self): 240 | """ Stops playing in the backend player. 241 | """ 242 | raise NotImplementedError 243 | 244 | 245 | def _set_file(self, filepath): 246 | """ Sets the media file to be played by the widget. 247 | 248 | Args: 249 | filepath (`pathlib.Path`): The path to the media file path 250 | """ 251 | raise NotImplementedError 252 | 253 | 254 | def show(self): 255 | """ Bring the widget to the top of the overlays if necessary. 256 | """ 257 | if min(self.rect) < 0 or max(self.rect) > 1: 258 | logger.warning('Negative margin(s) clipped to 0 (might alter the aspect ratio?): ' + 259 | 'LTRB = {}'.format(self.rect)) 260 | 261 | if not self.media_overlay.get_parent(): 262 | self.parent.add_overlay(self.media_overlay) 263 | self.parent.reorder_overlay(self.media_overlay, 2) 264 | self.resize() 265 | self.parent.queue_draw() 266 | self.media_overlay.show() 267 | 268 | 269 | def do_hide(self, *args): 270 | """ Remove widget from overlays. Needs to be called via :func:`~GLib.idle_add`. 271 | 272 | Returns: 273 | `bool`: `True` iff this function should be run again (:func:`~GLib.idle_add` convention) 274 | """ 275 | self.do_stop() 276 | self.media_overlay.hide() 277 | 278 | if self.media_overlay.get_parent(): 279 | self.parent.remove(self.media_overlay) 280 | self.parent.queue_draw() 281 | return False 282 | 283 | 284 | def do_play(self): 285 | """ Start playing the media file. 286 | 287 | Should run on the main thread to ensure we avoid reentrency problems. 288 | 289 | Returns: 290 | `bool`: `True` iff this function should be run again (:meth:`~GLib.idle_add` convention) 291 | """ 292 | raise NotImplementedError 293 | 294 | 295 | def do_play_pause(self): 296 | """ Toggle pause mode of the media. 297 | 298 | Should run on the main thread to ensure we avoid reentrency problems. 299 | 300 | Returns: 301 | `bool`: `True` iff this function should be run again (:meth:`~GLib.idle_add` convention) 302 | """ 303 | raise NotImplementedError 304 | 305 | 306 | def do_set_time(self, t): 307 | """ Set the player at time t. 308 | 309 | Should run on the main thread to ensure we avoid vlc plugins' reentrency problems. 310 | 311 | Args: 312 | t (`float`): the timestamp, in s 313 | 314 | Returns: 315 | `bool`: `True` iff this function should be run again (:meth:`~GLib.idle_add` convention) 316 | """ 317 | raise NotImplementedError 318 | -------------------------------------------------------------------------------- /pympress/media_overlays/gif_backend.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # media_overlays/gif.py 4 | # 5 | # Copyright 2018 Cimbali 6 | # 7 | # This program is free software; you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation; either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with this program; if not, write to the Free Software 19 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA. 20 | # 21 | """ 22 | :mod:`pympress.media_overlays.gif` -- widget to play gif images as videos 23 | ------------------------------------------------------------------------- 24 | """ 25 | 26 | import logging 27 | logger = logging.getLogger(__name__) 28 | 29 | import gi 30 | import cairo 31 | gi.require_version('Gtk', '3.0') 32 | from gi.repository import Gdk, GLib, GdkPixbuf 33 | 34 | 35 | from pympress.media_overlays import base 36 | 37 | 38 | class GifOverlay(base.VideoOverlay): 39 | """ A simple overlay mimicking the functionality of showing videos, but showing gifs instead. 40 | """ 41 | #: A :class:`~GdkPixbuf.PixbufAnimation` containing all the frames and their timing for the displayed gif 42 | anim = None 43 | #: A :class:`~GdkPixbuf.PixbufAnimationIter` which will provide the timely access to the frames in `~anim` 44 | anim_iter = None 45 | #: A `tuple` of (`int`, `int`) indicating the size of the bounding box of the gif 46 | base_size = None 47 | #: The :class:`~cairo.Matrix` defining the zoom & shift to scale the gif 48 | transform = None 49 | 50 | def __init__(self, *args, **kwargs): 51 | super(GifOverlay, self).__init__(*args, **kwargs) 52 | 53 | # override: no toolbar or interactive stuff for a gif, replace the whole widget area with a GdkPixbuf 54 | self.autoplay = True 55 | self.toolbar.set_visible(False) 56 | 57 | # we'll manually draw on the movie zone 58 | self.movie_zone.connect('draw', self.draw) 59 | self.movie_zone.connect('configure-event', self.set_transform) 60 | 61 | 62 | def _set_file(self, filepath): 63 | """ Sets the media file to be played by the widget. 64 | 65 | Args: 66 | filepath (`pathlib.Path`): The path to the media file path 67 | """ 68 | self.anim = GdkPixbuf.PixbufAnimation.new_from_file(str(filepath)) 69 | self.base_size = (self.anim.get_width(), self.anim.get_height()) 70 | self.anim_iter = self.anim.get_iter(None) 71 | 72 | self.set_transform() 73 | self.advance_gif() 74 | 75 | 76 | def set_transform(self, *args): 77 | """ Compute the transform to scale (not stretch nor crop) the gif. 78 | """ 79 | widget_size = (self.movie_zone.get_allocated_width(), self.movie_zone.get_allocated_height()) 80 | scale = min(widget_size[0] / self.base_size[0], widget_size[1] / self.base_size[1]) 81 | dx = widget_size[0] - scale * self.base_size[0] 82 | dy = widget_size[1] - scale * self.base_size[1] 83 | 84 | self.transform = cairo.Matrix(xx = scale, yy = scale, x0 = dx / 2, y0 = dy / 2) 85 | 86 | 87 | def draw(self, widget, ctx): 88 | """ Simple resized drawing: get the pixbuf, set the transform, draw the image. 89 | """ 90 | if self.anim_iter is None: 91 | return False 92 | 93 | try: 94 | ctx.transform(self.transform) 95 | Gdk.cairo_set_source_pixbuf(ctx, self.anim_iter.get_pixbuf(), 0, 0) 96 | ctx.paint() 97 | except cairo.Error: 98 | logger.error(_('Cairo can not draw gif'), exc_info = True) 99 | 100 | 101 | def advance_gif(self): 102 | """ Advance the gif, queue redrawing if the frame changed, and schedule the next frame. 103 | """ 104 | if self.anim_iter.advance(): 105 | self.movie_zone.queue_draw() 106 | 107 | delay = self.anim_iter.get_delay_time() 108 | if delay >= 0: 109 | GLib.timeout_add(delay, self.advance_gif) 110 | 111 | 112 | def do_set_time(self, t): 113 | """ Set the player at time t. 114 | 115 | Should run on the main thread to ensure we avoid reentrency problems. 116 | 117 | Args: 118 | t (`int`): the timestamp, in ms 119 | 120 | Returns: 121 | `bool`: `True` iff this function should be run again (:meth:`~GLib.idle_add` convention) 122 | """ 123 | start = GLib.TimeVal() 124 | GLib.DateTime.new_now_local().to_timeval(start) 125 | start.add(-t) 126 | self.anim_iter = self.anim.get_iter(start) 127 | self.advance_gif() 128 | return False 129 | 130 | 131 | # a bunch of inherited functions that do nothing, for gifs 132 | def mute(self, *args): pass 133 | def is_playing(self): return True 134 | def do_stop(self): pass 135 | def do_play(self): return False 136 | def do_play_pause(self): return False 137 | 138 | @classmethod 139 | def setup_backend(cls): 140 | """ Returns the name of this backend. 141 | """ 142 | return _('GdkPixbuf gif player') 143 | -------------------------------------------------------------------------------- /pympress/media_overlays/gst_backend.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # media_overlays/gst.py 4 | # 5 | # Copyright 2018 Cimbali 6 | # 7 | # This program is free software; you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation; either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with this program; if not, write to the Free Software 19 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA. 20 | # 21 | """ 22 | :mod:`pympress.media_overlays.gst` -- widget to play videos using Gstreamer's Gst 23 | --------------------------------------------------------------------------------------- 24 | """ 25 | 26 | import logging 27 | logger = logging.getLogger(__name__) 28 | 29 | import gi 30 | gi.require_version('Gst', '1.0') 31 | gi.require_version('Gtk', '3.0') 32 | from gi.repository import GLib, Gst 33 | 34 | 35 | from pympress import util 36 | from pympress.media_overlays import base 37 | 38 | 39 | class GstOverlay(base.VideoOverlay): 40 | """ Simple Gstramer widget. 41 | 42 | Wraps a simple gstreamer playbin. 43 | """ 44 | 45 | #: A :class:`~Gst.Playbin` to be play videos 46 | playbin = None 47 | #: A :class:`~Gst.Base.Sink` to display video content 48 | sink = None 49 | 50 | #: `int` number of milliseconds between updates 51 | update_freq = 200 52 | 53 | def __init__(self, *args, **kwargs): 54 | # Create GStreamer playbin 55 | self.playbin = Gst.ElementFactory.make('playbin', None) 56 | self.sink = Gst.ElementFactory.make('gtksink', None) 57 | self.playbin.set_property('video-sink', self.sink) 58 | 59 | super(GstOverlay, self).__init__(*args, **kwargs) 60 | 61 | self.media_overlay.remove(self.movie_zone) 62 | self.media_overlay.pack_start(self.sink.props.widget, True, True, 0) 63 | self.media_overlay.reorder_child(self.sink.props.widget, 0) 64 | self.sink.props.widget.hide() 65 | 66 | # Create bus to get events from GStreamer playin 67 | bus = self.playbin.get_bus() 68 | bus.add_signal_watch() 69 | bus.enable_sync_message_emission() 70 | bus.connect('message::eos', lambda *args: GLib.idle_add(self.handle_end)) 71 | bus.connect('message::error', lambda _, msg: logger.error('{} {}'.format(*msg.parse_error()))) 72 | bus.connect('message::state-changed', self.on_state_changed) 73 | bus.connect('message::duration-changed', lambda *args: GLib.idle_add(self.do_update_duration)) 74 | 75 | 76 | def is_playing(self): 77 | """ Returns whether the media is currently playing (and not paused). 78 | 79 | Returns: 80 | `bool`: `True` iff the media is playing. 81 | """ 82 | return self.playbin.get_state(0).state == Gst.State.PLAYING 83 | 84 | 85 | def _set_file(self, filepath): 86 | """ Sets the media file to be played by the widget. 87 | 88 | Args: 89 | filepath (`pathlib.Path`): The path to the media file path 90 | """ 91 | self.playbin.set_property('uri', filepath.as_uri()) 92 | self.playbin.set_state(Gst.State.READY) 93 | 94 | 95 | def mute(self, value): 96 | """ Mutes or unmutes the player. 97 | 98 | Args: 99 | value (`bool`): `True` iff this player should be muted 100 | """ 101 | flags = self.playbin.get_property('flags') 102 | # Fall back to the documented value if introspection fails, 103 | # see https://gstreamer.freedesktop.org/documentation/playback/playsink.html?gi-language=python#GstPlayFlags 104 | audio_flag = util.introspect_flag_value(type(flags), 'audio', 0x02) 105 | if value: 106 | flags = flags & ~audio_flag 107 | else: 108 | flags = flags | audio_flag 109 | self.playbin.set_property('flags', flags) 110 | return False 111 | 112 | 113 | def on_state_changed(self, bus, msg): 114 | """ Callback triggered by playbin state changes. 115 | 116 | Args: 117 | bus (:class:`~Gst.Bus`): the bus that we are connected to 118 | msg (:class:`~Gst.Message`): the "state-changed" message 119 | """ 120 | if msg.src != self.playbin: 121 | # ignore the playbin's children 122 | return 123 | old, new, pending = msg.parse_state_changed() 124 | if old == Gst.State.READY and new == Gst.State.PAUSED: 125 | # the playbin goes from READY (= stopped) to PLAYING (via PAUSED) 126 | self.on_initial_play() 127 | 128 | 129 | def on_initial_play(self): 130 | """ Set starting position, start scrollbar updates, unhide overlay. """ 131 | # set starting position, if needed 132 | if self.start_pos: 133 | self.do_set_time(self.start_pos) 134 | # ensure the scroll bar is updated 135 | GLib.idle_add(self.do_update_duration) 136 | GLib.timeout_add(self.update_freq, self.do_update_time) 137 | # ensure the overlay is visible (if needed) 138 | if not self.media_type.startswith('audio'): 139 | self.sink.props.widget.show() 140 | 141 | 142 | def do_update_duration(self, *args): 143 | """ Transmit the change of file duration to the UI to adjust the scroll bar. 144 | """ 145 | changed, time_ns = self.playbin.query_duration(Gst.Format.TIME) 146 | self.update_range(max(0, time_ns) / 1e9) 147 | 148 | 149 | def do_update_time(self): 150 | """ Update the current position in the progress bar. 151 | 152 | Returns: 153 | `bool`: `True` iff this function should be run again (:func:`~GLib.timeout_add` convention) 154 | """ 155 | changed, time_ns = self.playbin.query_position(Gst.Format.TIME) 156 | time = time_ns / 1e9 157 | self.update_progress(time) 158 | if self.last_timestamp <= self.end_pos <= time: 159 | self.handle_end() 160 | self.last_timestamp = time 161 | return self.playbin.get_state(0).state in {Gst.State.PLAYING, Gst.State.PAUSED} 162 | 163 | 164 | def do_play(self): 165 | """ Start playing the media file. 166 | 167 | Returns: 168 | `bool`: `True` iff this function should be run again (:func:`~GLib.idle_add` convention) 169 | """ 170 | self.playbin.set_state(Gst.State.PLAYING) 171 | 172 | return False 173 | 174 | 175 | def do_play_pause(self): 176 | """ Toggle pause mode of the media. 177 | 178 | Should run on the main thread to ensure we avoid reentrency problems. 179 | 180 | Returns: 181 | `bool`: `True` iff this function should be run again (:func:`~GLib.idle_add` convention) 182 | """ 183 | self.playbin.set_state(Gst.State.PLAYING if not self.is_playing() else Gst.State.PAUSED) 184 | 185 | return False 186 | 187 | 188 | def do_stop(self): 189 | """ Stops playing in the backend player. 190 | """ 191 | self.playbin.set_state(Gst.State.NULL) 192 | self.playbin.set_state(Gst.State.READY) 193 | self.sink.props.widget.hide() 194 | 195 | return False 196 | 197 | 198 | def do_set_time(self, time): 199 | """ Set the player at time `~time`. 200 | 201 | Should run on the main thread to ensure we avoid reentrency problems. 202 | 203 | Args: 204 | time (`float`): the timestamp, in s 205 | 206 | Returns: 207 | `bool`: `True` iff this function should be run again (:func:`~GLib.idle_add` convention) 208 | """ 209 | self.last_timestamp = time 210 | self.playbin.seek_simple(Gst.Format.TIME, Gst.SeekFlags.FLUSH, time * Gst.SECOND) 211 | return False 212 | 213 | 214 | @classmethod 215 | def setup_backend(cls, gst_opts = []): 216 | """ Prepare/check the Gst backend. 217 | 218 | Returns: 219 | `str`: the version of Gst used by the backend 220 | """ 221 | Gst.init(gst_opts) 222 | 223 | if Gst.ElementFactory.make('gtksink', None) is None: 224 | logger.warning('Can not create a gtksink. Check the gtk plugin for GStreamer is installed.') 225 | logger.warning('See https://github.com/Cimbali/pympress/issues/240') 226 | raise ValueError('Can not create a gtksink.') 227 | 228 | return Gst.version_string() 229 | -------------------------------------------------------------------------------- /pympress/media_overlays/vlc_backend.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # media_overlays/vlc.py 4 | # 5 | # Copyright 2018 Cimbali 6 | # 7 | # This program is free software; you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation; either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with this program; if not, write to the Free Software 19 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA. 20 | # 21 | """ 22 | :mod:`pympress.media_overlays.vlc` -- widget to play videos using VLC 23 | --------------------------------------------------------------------- 24 | """ 25 | 26 | import logging 27 | logger = logging.getLogger(__name__) 28 | 29 | import os 30 | import vlc 31 | import ctypes 32 | 33 | import gi 34 | gi.require_version('Gtk', '3.0') 35 | from gi.repository import GLib 36 | 37 | from pympress.util import IS_WINDOWS 38 | from pympress.media_overlays import base 39 | 40 | 41 | def get_window_handle(window): 42 | """ Uses ctypes to call gdk_win32_window_get_handle which is not available in python gobject introspection porting. 43 | 44 | Solution from http://stackoverflow.com/a/27236258/1387346 45 | 46 | Args: 47 | window (:class:`~Gdk.Window`): The window for which we want to get the handle 48 | 49 | Returns: 50 | The handle to the win32 window 51 | """ 52 | # get the c gpointer of the gdk window 53 | ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p 54 | ctypes.pythonapi.PyCapsule_GetPointer.argtypes = [ctypes.py_object] 55 | drawingarea_gpointer = ctypes.pythonapi.PyCapsule_GetPointer(window.__gpointer__, None) 56 | # get the win32 handle 57 | gdkdll = ctypes.CDLL('libgdk-3-0.dll') 58 | handle_getter = gdkdll.gdk_win32_window_get_handle 59 | handle_getter.restype = ctypes.c_void_p 60 | handle_getter.argtypes = [ctypes.c_void_p] 61 | return handle_getter(drawingarea_gpointer) 62 | 63 | 64 | class VlcOverlay(base.VideoOverlay): 65 | """ Simple VLC widget. 66 | 67 | Its player can be controlled through the 'player' attribute, which is a :class:`~vlc.MediaPlayer` instance. 68 | """ 69 | 70 | #: A single vlc.Instance() to be shared by (possible) multiple players. 71 | _instance = None 72 | 73 | def __init__(self, *args, **kwargs): 74 | self.player = self._instance.media_player_new() # before loading UI, needed to connect "map" signal 75 | 76 | super(VlcOverlay, self).__init__(*args, **kwargs) 77 | # Simple black background painting to avoid glitching outside of video area 78 | if not self.media_type.startswith('audio'): 79 | self.movie_zone.connect('draw', self.paint_backdrop) 80 | 81 | event_manager = self.player.event_manager() 82 | event_manager.event_attach(vlc.EventType.MediaPlayerEndReached, lambda e: GLib.idle_add(self.handle_end)) 83 | event_manager.event_attach(vlc.EventType.MediaPlayerLengthChanged, 84 | lambda e: self.update_range(self.player.get_length() / 1000. or 1.)) 85 | event_manager.event_attach(vlc.EventType.MediaPlayerTimeChanged, self.time_changed) 86 | 87 | 88 | def handle_embed(self, mapped_widget): 89 | """ Handler to embed the VLC player in the window, connected to the :attr:`~.Gtk.Widget.signals.map` signal. 90 | """ 91 | # Do we need to be on the main thread? (especially for the mess from the win32 window handle) 92 | # assert(isinstance(threading.current_thread(), threading._MainThread)) 93 | window = self.movie_zone.get_window() 94 | if window is None: 95 | logger.error('No window in which to embed the VLC player!') 96 | return False 97 | elif IS_WINDOWS: 98 | self.player.set_hwnd(get_window_handle(window)) # get_property('window') 99 | else: 100 | self.player.set_xwindow(window.get_xid()) 101 | self.movie_zone.queue_draw() 102 | return False 103 | 104 | 105 | def is_playing(self): 106 | """ Returns whether the media is currently playing (and not paused). 107 | 108 | Returns: 109 | `bool`: `True` iff the media is playing. 110 | """ 111 | return self.player.is_playing() 112 | 113 | 114 | def _set_file(self, filepath): 115 | """ Sets the media file to be played by the widget. 116 | 117 | Args: 118 | filepath (`pathlib.Path`): The path to the media file path 119 | """ 120 | self.player.set_media(self._instance.media_new(filepath.resolve().as_uri())) 121 | 122 | 123 | def handle_end(self): 124 | """ End of the stream reached: restart if looping, otherwise hide overlay 125 | 126 | Overridden because, to implement looping, vlc plugin needs to be told to start on stream end, not to seek 127 | """ 128 | if self.repeat: 129 | self.action_map.lookup_action('play').activate() 130 | else: 131 | self.action_map.lookup_action('stop').activate() 132 | 133 | 134 | def mute(self, value): 135 | """ Mutes the player. 136 | 137 | Args: 138 | value (`bool`): `True` iff this player should be muted 139 | """ 140 | GLib.idle_add(self.player.audio_set_volume, 0 if value else 100) 141 | return False 142 | 143 | 144 | def do_play(self): 145 | """ Start playing the media file. 146 | 147 | Should run on the main thread to ensure we avoid vlc plugins' reentrency problems. 148 | 149 | Returns: 150 | `bool`: `True` iff this function should be run again (:func:`~GLib.idle_add` convention) 151 | """ 152 | play_from_state = self.player.get_state() 153 | if play_from_state in {vlc.State.Ended, vlc.State.Playing}: 154 | self.player.stop() 155 | play_from_state = vlc.State.Stopped 156 | 157 | self.player.play() 158 | 159 | if play_from_state in {vlc.State.NothingSpecial, vlc.State.Stopped}: 160 | self.do_set_time(self.start_pos) 161 | 162 | self.movie_zone.queue_draw() 163 | return False 164 | 165 | 166 | def paint_backdrop(self, widget, context): 167 | """ Draw behind/around the video, aka the black bars 168 | 169 | Args: 170 | widget (:class:`~Gtk.Widget`): the widget to update 171 | context (:class:`~cairo.Context`): the Cairo context (or `None` if called directly) 172 | """ 173 | context.save() 174 | context.set_source_rgb(0, 0, 0) 175 | context.fill() 176 | context.paint() 177 | context.restore() 178 | 179 | 180 | def show(self): 181 | """ Bring the widget to the top of the overlays if necessary − also force redraw of movie zone 182 | """ 183 | super(VlcOverlay, self).show() 184 | self.movie_zone.queue_draw() 185 | 186 | 187 | def do_play_pause(self): 188 | """ Toggle pause mode of the media. 189 | 190 | Should run on the main thread to ensure we avoid vlc plugins' reentrency problems. 191 | 192 | Returns: 193 | `bool`: `True` iff this function should be run again (:func:`~GLib.idle_add` convention) 194 | """ 195 | self.player.pause() if self.player.is_playing() else self.player.play() 196 | return False 197 | 198 | 199 | def do_stop(self): 200 | """ Stops playing in the backend player. 201 | """ 202 | self.player.stop() 203 | 204 | 205 | def time_changed(self, event): 206 | """ Handle time passing 207 | 208 | Args: 209 | event (:class:`~vlc.Event`): The event that triggered the handler 210 | """ 211 | time = self.player.get_time() / 1000. or 1. 212 | if self.last_timestamp <= self.end_pos <= time: 213 | self.handle_end() 214 | self.last_timestamp = time 215 | self.update_progress(time) 216 | 217 | 218 | def do_set_time(self, time): 219 | """ Set the player at time `~time`. 220 | 221 | Should run on the main thread to ensure we avoid vlc plugins' reentrency problems. 222 | 223 | Args: 224 | `~time` (`float`): the timestamp, in s 225 | 226 | Returns: 227 | `bool`: `True` iff this function should be run again (:func:`~GLib.idle_add` convention) 228 | """ 229 | # Update last_timestamp first, as seeking should bypass auto stop after duration 230 | self.last_timestamp = time 231 | self.player.set_time(int(round(time * 1000.))) 232 | return False 233 | 234 | 235 | @classmethod 236 | def setup_backend(cls, vlc_opts = ['--no-video-title-show']): 237 | """ Prepare/check the VLC backend. 238 | 239 | Args: 240 | vlc_opts (`list`): the arguments for starting vlc 241 | 242 | Returns: 243 | `str`: the version of VLC used by the backend 244 | """ 245 | if IS_WINDOWS and vlc.plugin_path: 246 | # let python find the DLLs 247 | os.environ['PATH'] = vlc.plugin_path + ';' + os.environ['PATH'] 248 | 249 | VlcOverlay._instance = vlc.Instance(vlc_opts) 250 | return 'VLC {}'.format(vlc.libvlc_get_version().decode('ascii')) 251 | -------------------------------------------------------------------------------- /pympress/pointer.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # pointer.py 4 | # 5 | # Copyright 2017 Cimbali 6 | # 7 | # This program is free software; you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation; either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with this program; if not, write to the Free Software 19 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, 20 | # MA 02110-1301, USA. 21 | """ 22 | :mod:`pympress.pointer` -- Manage when and where to draw a software-emulated laser pointer on screen 23 | ---------------------------------------------------------------------------------------------------- 24 | """ 25 | 26 | import logging 27 | logger = logging.getLogger(__name__) 28 | 29 | import enum 30 | 31 | import gi 32 | gi.require_version('Gtk', '3.0') 33 | from gi.repository import Gdk, GdkPixbuf, GLib 34 | 35 | from pympress import util, extras 36 | 37 | 38 | class PointerMode(enum.Enum): 39 | """ Possible values for the pointer. 40 | """ 41 | #: Pointer switched on continuously 42 | CONTINUOUS = 2 43 | #: Pointer switched on only manual 44 | MANUAL = 1 45 | #: Pointer never switched on 46 | DISABLED = 0 47 | 48 | 49 | class Pointer(object): 50 | """ Manage and draw the software “laser pointer” to point at the slide. 51 | 52 | Displays a pointer of chosen color on the current slide (in both windows), either on all the time or only when 53 | clicking while ctrl pressed. 54 | 55 | Args: 56 | config (:class:`~pympress.config.Config`): A config object containing preferences 57 | builder (:class:`~pympress.builder.Builder`): A builder from which to load widgets 58 | """ 59 | #: :class:`~GdkPixbuf.Pixbuf` to read XML descriptions of GUIs and load them. 60 | pointer = GdkPixbuf.Pixbuf() 61 | #: `(float, float)` of position relative to slide, where the pointer should appear 62 | pointer_pos = (.5, .5) 63 | #: `bool` indicating whether we should show the pointer 64 | show_pointer = False 65 | #: :class:`~pympress.pointer.PointerMode` indicating the pointer mode 66 | pointer_mode = PointerMode.MANUAL 67 | #: The :class:`~pympress.pointer.PointerMode` to which we toggle back 68 | old_pointer_mode = PointerMode.CONTINUOUS 69 | #: A reference to the UI's :class:`~pympress.config.Config`, to update the pointer preference 70 | config = None 71 | #: :class:`~Gtk.DrawingArea` Slide in the Presenter window, used to reliably set cursors. 72 | p_da_cur = None 73 | #: :class:`~Gtk.DrawingArea` Slide in the Contents window, used to reliably set cursors. 74 | c_da = None 75 | #: :class:`~Gtk.AspectFrame` Frame of the Contents window, used to reliably set cursors. 76 | c_frame = None 77 | #: a `dict` of the :class:`~Gtk.RadioMenuItem` selecting the pointer mode 78 | pointermode_radios = {} 79 | 80 | #: callback, to be connected to :func:`~pympress.ui.UI.redraw_current_slide` 81 | redraw_current_slide = lambda *args: None 82 | #: callback, to be connected to :meth:`~pympress.app.Pympress.set_action_state` 83 | set_action_state = None 84 | 85 | def __init__(self, config, builder): 86 | super(Pointer, self).__init__() 87 | self.config = config 88 | 89 | builder.load_widgets(self) 90 | 91 | self.redraw_current_slide = builder.get_callback_handler('redraw_current_slide') 92 | self.set_action_state = builder.get_callback_handler('app.set_action_state') 93 | 94 | default_mode = config.get('presenter', 'pointer_mode') 95 | default_color = config.get('presenter', 'pointer') 96 | 97 | try: 98 | default_mode = PointerMode[default_mode.upper()] 99 | except KeyError: 100 | default_mode = PointerMode.MANUAL 101 | 102 | self.activate_pointermode(default_mode) 103 | self.load_pointer(default_color) 104 | 105 | self.action_map = builder.setup_actions({ 106 | 'pointer-color': dict(activate=self.change_pointercolor, state=default_color, parameter_type=str), 107 | 'pointer-mode': dict(activate=self.change_pointermode, state=default_mode.name.lower(), parameter_type=str), 108 | }) 109 | 110 | 111 | def load_pointer(self, name): 112 | """ Perform the change of pointer using its color name. 113 | 114 | Args: 115 | name (`str`): Name of the pointer to load 116 | """ 117 | if name not in ['red', 'green', 'blue']: 118 | raise ValueError('Wrong color name') 119 | path = util.get_icon_path('pointer_' + name + '.png') 120 | try: 121 | self.pointer = GdkPixbuf.Pixbuf.new_from_file(path) 122 | except Exception: 123 | logger.exception(_('Failed loading pixbuf for pointer "{}" from: {}'.format(name, path))) 124 | 125 | 126 | def change_pointercolor(self, action, target): 127 | """ Callback for a radio item selection as pointer mode (continuous, manual, none). 128 | 129 | Args: 130 | action (:class:`~Gio.Action`): The action activatd 131 | target (:class:`~GLib.Variant`): The selected mode 132 | """ 133 | color = target.get_string() 134 | self.load_pointer(color) 135 | self.config.set('presenter', 'pointer', color) 136 | action.change_state(target) 137 | 138 | 139 | def activate_pointermode(self, mode=None): 140 | """ Activate the pointer as given by mode. 141 | 142 | Depending on the given mode, shows or hides the laser pointer and the normal mouse pointer. 143 | 144 | Args: 145 | mode (:class:`~pympress.pointer.PointerMode`): The mode to activate 146 | """ 147 | # Set internal variables, unless called without mode (from ui, after windows have been mapped) 148 | if mode == self.pointer_mode: 149 | return 150 | elif mode is not None: 151 | self.old_pointer_mode, self.pointer_mode = self.pointer_mode, mode 152 | self.config.set('presenter', 'pointer_mode', self.pointer_mode.name.lower()) 153 | 154 | 155 | # Set mouse pointer and cursors on/off, if windows are already mapped 156 | self.show_pointer = False 157 | for slide_widget in [self.p_da_cur, self.c_da]: 158 | ww, wh = slide_widget.get_allocated_width(), slide_widget.get_allocated_height() 159 | if max(ww, wh) == 1: 160 | continue 161 | 162 | pointer_x, pointer_y = -1, -1 163 | window = slide_widget.get_window() 164 | if window is not None: 165 | pointer_coords = window.get_pointer() 166 | pointer_x, pointer_y = pointer_coords.x, pointer_coords.y 167 | 168 | if 0 < pointer_x < ww and 0 < pointer_y < wh \ 169 | and self.pointer_mode == PointerMode.CONTINUOUS: 170 | # Laser activated right away 171 | self.pointer_pos = (pointer_x / ww, pointer_y / wh) 172 | self.show_pointer = True 173 | extras.Cursor.set_cursor(slide_widget, 'invisible') 174 | else: 175 | extras.Cursor.set_cursor(slide_widget, 'parent') 176 | 177 | self.redraw_current_slide() 178 | 179 | 180 | def change_pointermode(self, action, target): 181 | """ Callback for a radio item selection as pointer mode (continuous, manual, none). 182 | 183 | Args: 184 | action (:class:`~Gio.Action`): The action activatd 185 | target (:class:`~GLib.Variant`): The selected mode 186 | """ 187 | if target is None or target.get_string() == 'toggle': 188 | mode = self.old_pointer_mode if self.pointer_mode == PointerMode.CONTINUOUS else PointerMode.CONTINUOUS 189 | else: 190 | mode = PointerMode[target.get_string().upper()] 191 | self.activate_pointermode(mode) 192 | 193 | action.change_state(GLib.Variant.new_string(mode.name.lower())) 194 | 195 | 196 | def render_pointer(self, cairo_context, ww, wh): 197 | """ Draw the laser pointer on screen. 198 | 199 | Args: 200 | cairo_context (:class:`~cairo.Context`): The canvas on which to render the pointer 201 | ww (`int`): The widget width 202 | wh (`int`): The widget height 203 | """ 204 | if self.show_pointer: 205 | x = ww * self.pointer_pos[0] - self.pointer.get_width() / 2 206 | y = wh * self.pointer_pos[1] - self.pointer.get_height() / 2 207 | Gdk.cairo_set_source_pixbuf(cairo_context, self.pointer, x, y) 208 | cairo_context.paint() 209 | 210 | 211 | def track_pointer(self, widget, event): 212 | """ Move the laser pointer at the mouse location. 213 | 214 | Args: 215 | widget (:class:`~Gtk.Widget`): the widget which has received the event. 216 | event (:class:`~Gdk.Event`): the GTK event. 217 | 218 | Returns: 219 | `bool`: whether the event was consumed 220 | """ 221 | if self.show_pointer: 222 | ww, wh = widget.get_allocated_width(), widget.get_allocated_height() 223 | ex, ey = event.get_coords() 224 | self.pointer_pos = (ex / ww, ey / wh) 225 | self.redraw_current_slide() 226 | return True 227 | 228 | else: 229 | return False 230 | 231 | 232 | def track_enter_leave(self, widget, event): 233 | """ Switches laser off/on in continuous mode on leave/enter slides. 234 | 235 | In continuous mode, the laser pointer is switched off when the mouse leaves the slide 236 | (otherwise the laser pointer "sticks" to the edge of the slide). 237 | It is switched on again when the mouse reenters the slide. 238 | 239 | Args: 240 | widget (:class:`~Gtk.Widget`): the widget which has received the event. 241 | event (:class:`~Gdk.Event`): the GTK event. 242 | 243 | Returns: 244 | `bool`: whether the event was consumed 245 | """ 246 | # Only handle enter/leave events on one of the current slides 247 | if self.pointer_mode != PointerMode.CONTINUOUS or widget not in [self.c_da, self.p_da_cur]: 248 | return False 249 | 250 | if event.type == Gdk.EventType.ENTER_NOTIFY: 251 | self.show_pointer = True 252 | extras.Cursor.set_cursor(widget, 'invisible') 253 | 254 | elif event.type == Gdk.EventType.LEAVE_NOTIFY: 255 | self.show_pointer = False 256 | extras.Cursor.set_cursor(widget, 'parent') 257 | 258 | self.redraw_current_slide() 259 | return True 260 | 261 | 262 | def toggle_pointer(self, widget, event): 263 | """ Track events defining when the laser is pointing. 264 | 265 | Args: 266 | widget (:class:`~Gtk.Widget`): the widget which has received the event. 267 | event (:class:`~Gdk.Event`): the GTK event. 268 | 269 | Returns: 270 | `bool`: whether the event was consumed 271 | """ 272 | if self.pointer_mode in {PointerMode.DISABLED, PointerMode.CONTINUOUS}: 273 | return False 274 | 275 | ctrl_pressed = event.get_state() & Gdk.ModifierType.CONTROL_MASK 276 | 277 | if ctrl_pressed and event.type == Gdk.EventType.BUTTON_PRESS: 278 | self.show_pointer = True 279 | extras.Cursor.set_cursor(widget, 'invisible') 280 | 281 | # Immediately place & draw the pointer 282 | return self.track_pointer(widget, event) 283 | 284 | elif self.show_pointer and event.type == Gdk.EventType.BUTTON_RELEASE: 285 | self.show_pointer = False 286 | extras.Cursor.set_cursor(widget, 'parent') 287 | self.redraw_current_slide() 288 | return True 289 | 290 | else: 291 | return False 292 | -------------------------------------------------------------------------------- /pympress/share/applications/io.github.pympress.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Categories=Office;Viewer;Presentation;GTK; 3 | Keywords=Presentation;Dual-Screen;Beamer; 4 | Comment=A simple yet powerful PDF reader designed for dual-screen presentations 5 | Exec=pympress %f 6 | Icon=pympress 7 | MimeType=application/pdf; 8 | Name=pympress 9 | Terminal=false 10 | Type=Application 11 | Version=1.0 12 | -------------------------------------------------------------------------------- /pympress/share/css/default.css: -------------------------------------------------------------------------------- 1 | #p_win { 2 | } 3 | 4 | #c_win.black, #c_frame.black { 5 | background-color: #000; 6 | } 7 | 8 | #c_win.white , #c_frame.white { 9 | background-color: #fff; 10 | } 11 | 12 | #annotations_textview { 13 | font-size: 1.4em; 14 | } 15 | 16 | #p_win GtkPaned .pane-separator { 17 | background-color: #ccc; 18 | border-radius: 2px; 19 | } 20 | 21 | #p_win GtkFrame { 22 | } 23 | 24 | #p_win GtkTreeView { 25 | border-style: none; 26 | background-color: transparent; 27 | font-size: 16pt; 28 | } 29 | 30 | #label_time, #label_ett, #frame_clock { 31 | font-family: "monospace"; 32 | } 33 | 34 | #frame_ett { 35 | /* similar to button padding to add some room */ 36 | margin-left: 3px; 37 | margin-right: 3px; 38 | } 39 | #frame_cur { 40 | min-width: 15em; 41 | } 42 | #frame_ett { 43 | min-width: 6em; 44 | } 45 | #frame_clock { 46 | min-width: 8em; 47 | } 48 | 49 | .toolbar { 50 | background: @theme_base_color 51 | } 52 | 53 | #scribble_overlay { 54 | background: @theme_bg_color 55 | } 56 | 57 | #scribble_overlay modelbutton.pen-preset radio { 58 | margin: 0 30px 25px 0; 59 | } 60 | 61 | .frame-label { 62 | font-size:1.2em; 63 | } 64 | 65 | .big-info-label { 66 | font-size: 1.6em; 67 | } 68 | 69 | .info-label { 70 | font-size: 1.4em; 71 | opacity: 0.80; 72 | } 73 | 74 | .big-info-label > .frame-label { 75 | font-size:0.7em; 76 | } 77 | 78 | .info-label > .frame-label { 79 | font-size:0.8em; 80 | } 81 | 82 | .ett-reached { 83 | color: @success_color; 84 | } 85 | .ett-info { 86 | color: @warning_color; 87 | } 88 | .ett-warn { 89 | color: @error_color; 90 | } 91 | .time-warn { 92 | animation: blinker 250ms cubic-bezier(.5, 0, 1, 1) infinite alternate; 93 | } 94 | @keyframes blinker { 95 | from { opacity: 1; } 96 | to { opacity: 0; } 97 | } 98 | 99 | .grid-frame > *:hover { 100 | border-color: @theme_selected_bg_color; 101 | } 102 | -------------------------------------------------------------------------------- /pympress/share/defaults.conf: -------------------------------------------------------------------------------- 1 | [content] 2 | xalign = 0.5 3 | yalign = 0.5 4 | geometry = 800x600 5 | start_blanked = off 6 | start_fullscreen = on 7 | white_blanking = off 8 | 9 | [presenter] 10 | geometry = 800x600 11 | start_fullscreen = off 12 | pointer = red 13 | pointer_mode = manual 14 | show_bigbuttons = off 15 | show_annotations = off 16 | scroll_number = off 17 | slide_ratio = 0.75 18 | next_slide_count = 1 19 | 20 | [deck-overview] 21 | max-slides-per-row = 6 22 | distinct-labels-only = on 23 | 24 | [layout] 25 | plain = { 26 | "resizeable": true, 27 | "children": [ 28 | "current", 29 | { 30 | "resizeable": true, 31 | "children": [ 32 | "next", 33 | "annotations" 34 | ], 35 | "proportions": [ 36 | 0.55, 37 | 0.44999999999999996 38 | ], 39 | "orientation": "vertical" 40 | } 41 | ], 42 | "proportions": [ 43 | 0.6697916666666667, 44 | 0.3302083333333333 45 | ], 46 | "orientation": "horizontal" 47 | } 48 | 49 | highlight_tools = vertical 50 | highlight = { 51 | "resizeable": true, 52 | "orientation": "horizontal", 53 | "children": [ 54 | "highlight", 55 | { 56 | "resizeable": true, 57 | "orientation": "vertical", 58 | "children": [ 59 | "next", 60 | "annotations" 61 | ], 62 | "proportions": [ 63 | 0.55, 64 | 0.45 65 | ] 66 | } 67 | ], 68 | "proportions": [ 69 | 0.67, 70 | 0.33 71 | ] 72 | } 73 | 74 | notes = { 75 | "resizeable": true, 76 | "children": [ 77 | "notes", 78 | { 79 | "resizeable": false, 80 | "children": [ 81 | "current", 82 | "next" 83 | ], 84 | "orientation": "vertical" 85 | } 86 | ], 87 | "proportions": [ 88 | 0.6, 89 | 0.4 90 | ], 91 | "orientation": "horizontal" 92 | } 93 | 94 | highlight_notes_tools = vertical 95 | highlight_notes = { 96 | "resizeable": true, 97 | "orientation": "horizontal", 98 | "children": [ 99 | "highlight", 100 | { 101 | "resizeable": true, 102 | "orientation": "vertical", 103 | "children": [ 104 | "notes", 105 | "next", 106 | "annotations" 107 | ], 108 | "proportions": [ 109 | 0.55, 110 | 0.35, 111 | 0.20 112 | ] 113 | } 114 | ], 115 | "proportions": [ 116 | 0.67, 117 | 0.33 118 | ] 119 | } 120 | 121 | note_pages = { 122 | "resizeable": true, 123 | "children": [ 124 | "notes", 125 | { 126 | "resizeable": false, 127 | "children": [ 128 | "next", 129 | "annotations" 130 | ], 131 | "orientation": "vertical" 132 | } 133 | ], 134 | "proportions": [ 135 | 0.6, 136 | 0.4 137 | ], 138 | "orientation": "horizontal" 139 | } 140 | 141 | [notes position] 142 | horizontal = right 143 | vertical = bottom 144 | 145 | [cache] 146 | maxpages = 200 147 | 148 | [highlight] 149 | width_eraser = 90 150 | color_1 = rgba(255,255,0,0.5) 151 | width_1 = 90 152 | color_2 = rgba(128,255,0,0.5) 153 | width_2 = 90 154 | color_3 = rgba(255,0,128,0.5) 155 | width_3 = 90 156 | color_4 = rgba(48,48,255,0.5) 157 | width_4 = 90 158 | color_5 = rgb(239,41,41) 159 | width_5 = 5 160 | color_6 = rgb(0,96,255) 161 | width_6 = 5 162 | color_7 = rgb(0,187,0) 163 | width_7 = 5 164 | color_8 = rgb(0,0,0) 165 | width_8 = 5 166 | color_9 = rgb(136,136,136) 167 | width_9 = 48 168 | active_pen = 9 169 | mode = single-page 170 | page_change_exits = on 171 | 172 | [gstreamer] 173 | enabled = on 174 | init_options = 175 | mime_types = 176 | 177 | [vlc] 178 | enabled = on 179 | init_options = --no-video-title-show,--no-xlib 180 | mime_types = 181 | 182 | [shortcuts] 183 | next-page = Right Down Page_Down space 184 | prev-page = Left Up Page_Up 185 | first-page = Home 186 | last-page = End 187 | next-label = Right Down Page_Down space 188 | prev-label = Left Up Page_Up 189 | hist-back = Left 190 | hist-forward = Right 191 | goto-page = g 192 | jumpto-label = j 193 | 194 | content-fullscreen = F11 f F5 l 195 | presenter-fullscreen = f 196 | zoom = z 197 | unzoom = u 198 | notes-mode = n 199 | annotations = a 200 | highlight = h 201 | deck-overview = d 202 | swap-screens = s 203 | blank-screen = b 204 | quit = q 205 | 206 | validate-input = Return KP_Enter 207 | cancel-input = Escape 208 | 209 | # prompt to open file 210 | pick-file = o 211 | close-file = w 212 | save-file = s 213 | save-file-as = s 214 | 215 | pointer-mode::toggle = l 216 | pause-timer = p Pause 217 | reset-timer = r 218 | edit-talk-time = t 219 | timing-report = 220 | 221 | 222 | highlight-undo = z 223 | highlight-redo = r 224 | highlight-use-pen::1 = 1 225 | highlight-use-pen::2 = 2 226 | highlight-use-pen::3 = 3 227 | highlight-use-pen::4 = 4 228 | highlight-use-pen::5 = 5 229 | highlight-use-pen::6 = 6 230 | highlight-use-pen::7 = 7 231 | highlight-use-pen::8 = 8 232 | highlight-use-pen::9 = 9 233 | highlight-use-pen::eraser = 0 234 | highlight-hold-to-erase = 235 | highlight-clear = 236 | -------------------------------------------------------------------------------- /pympress/share/locale/babel_mapping.cfg: -------------------------------------------------------------------------------- 1 | [python: **.py] 2 | keywords = _ 3 | charset = utf-8 4 | 5 | [glade: **.xml] 6 | keywords = translatable 7 | charset = utf-8 8 | 9 | [glade: **.glade] 10 | keywords = translatable 11 | charset = utf-8 12 | -------------------------------------------------------------------------------- /pympress/share/locale/pympress.pot: -------------------------------------------------------------------------------- 1 | msgid "(and optionally seconds)" 2 | msgstr "" 3 | 4 | msgid "(none, left, right, top, bottom, after, odd, or prefix)." 5 | msgstr "" 6 | 7 | msgid "(paused)" 8 | msgstr "" 9 | 10 | msgid "10th next slide" 11 | msgstr "" 12 | 13 | msgid "11th next slide" 14 | msgstr "" 15 | 16 | msgid "12th next slide" 17 | msgstr "" 18 | 19 | msgid "13th next slide" 20 | msgstr "" 21 | 22 | msgid "14th next slide" 23 | msgstr "" 24 | 25 | msgid "15th next slide" 26 | msgstr "" 27 | 28 | msgid "16th next slide" 29 | msgstr "" 30 | 31 | msgid "2nd next slide" 32 | msgstr "" 33 | 34 | msgid "3rd next slide" 35 | msgstr "" 36 | 37 | msgid "4th next slide" 38 | msgstr "" 39 | 40 | msgid "5th next slide" 41 | msgstr "" 42 | 43 | msgid "6th next slide" 44 | msgstr "" 45 | 46 | msgid "7th next slide" 47 | msgstr "" 48 | 49 | msgid "8th next slide" 50 | msgstr "" 51 | 52 | msgid "9th next slide" 53 | msgstr "" 54 | 55 | msgid "Additional features" 56 | msgstr "" 57 | 58 | msgid "Adjust alignment of slides in projector screen" 59 | msgstr "" 60 | 61 | msgid "Align _content" 62 | msgstr "" 63 | 64 | msgid "All files" 65 | msgstr "" 66 | 67 | msgid "Annotations" 68 | msgstr "" 69 | 70 | msgid "Auto" 71 | msgstr "" 72 | 73 | msgid "Big buttons" 74 | msgstr "" 75 | 76 | msgid "Blank screen" 77 | msgstr "" 78 | 79 | msgid "Blank/unblank content screen" 80 | msgstr "" 81 | 82 | msgid "Cairo can not draw gif" 83 | msgstr "" 84 | 85 | msgid "Cancel goto/jump/highlighting/zooming" 86 | msgstr "" 87 | 88 | msgid "Caused by " 89 | msgstr "" 90 | 91 | msgid "Choose parameters for automatically playing slides" 92 | msgstr "" 93 | 94 | msgid "Clear and restore per page label" 95 | msgstr "" 96 | 97 | msgid "Clear and restore per page number" 98 | msgstr "" 99 | 100 | msgid "Clear on page change" 101 | msgstr "" 102 | 103 | msgid "Clock" 104 | msgstr "" 105 | 106 | msgid "Close file" 107 | msgstr "" 108 | 109 | msgid "Close opened pympress instance" 110 | msgstr "" 111 | 112 | msgid "Content and presenter window must not be on the same monitor if you start full screen!" 113 | msgstr "" 114 | 115 | msgid "Content blanked" 116 | msgstr "" 117 | 118 | msgid "Content fullscreen" 119 | msgstr "" 120 | 121 | msgid "Contributors:" 122 | msgstr "" 123 | 124 | msgid "Could not find the file \"{}\"" 125 | msgstr "" 126 | 127 | msgid "Current slide" 128 | msgstr "" 129 | 130 | msgid "ERROR: Gobject Introspections and/or pycairo module is missing, make sure Gtk, pygobject and pycairo are installed on your system." 131 | msgstr "" 132 | 133 | msgid "Edit layout" 134 | msgstr "" 135 | 136 | msgid "Error loading icon for about window" 137 | msgstr "" 138 | 139 | msgid "Error opening the file \"{}\"" 140 | msgstr "" 141 | 142 | msgid "Error parsing option from config file {}.{} \"{}\" to bool" 143 | msgstr "" 144 | 145 | msgid "Error parsing option from config file {}.{} \"{}\" to float" 146 | msgstr "" 147 | 148 | msgid "Error parsing option from config file {}.{} \"{}\" to int" 149 | msgstr "" 150 | 151 | msgid "Exit highlight on page change" 152 | msgstr "" 153 | 154 | msgid "First slide" 155 | msgstr "" 156 | 157 | msgid "For instructions, refer to https://github.com/Cimbali/pympress/blob/master/README.md#dependencies" 158 | msgstr "" 159 | 160 | msgid "Fullscreen Presentation running" 161 | msgstr "" 162 | 163 | msgid "GdkPixbuf gif player" 164 | msgstr "" 165 | 166 | msgid "Go back in slide history" 167 | msgstr "" 168 | 169 | msgid "Go forward in slide history" 170 | msgstr "" 171 | 172 | msgid "Go to page number" 173 | msgstr "" 174 | 175 | msgid "Gtk.Application.inhibit failed preventing screensaver, trying hard disabling" 176 | msgstr "" 177 | 178 | msgid "Highlight" 179 | msgstr "" 180 | 181 | msgid "Highlight mode" 182 | msgstr "" 183 | 184 | msgid "Highlighting" 185 | msgstr "" 186 | 187 | msgid "If using a virtualenv or anaconda, you can also try allowing system site packages." 188 | msgstr "" 189 | 190 | msgid "Invalid log level \"{}\", try one of {}" 191 | msgstr "" 192 | 193 | msgid "Invalid time (mm or mm:ss expected), got \"{}\"" 194 | msgstr "" 195 | 196 | msgid "Jump to page label" 197 | msgstr "" 198 | 199 | msgid "Last slide" 200 | msgstr "" 201 | 202 | msgid "Layout for beamer notes on second screen (no current slide preview in notes)" 203 | msgstr "" 204 | 205 | msgid "Layout for libreoffice notes on separate pages (with current slide preview in notes)" 206 | msgstr "" 207 | 208 | msgid "Layout to draw on the current slide" 209 | msgstr "" 210 | 211 | msgid "Loop" 212 | msgstr "" 213 | 214 | msgid "Manage files" 215 | msgstr "" 216 | 217 | msgid "Media support uses {}." 218 | msgstr "" 219 | 220 | msgid "Media support using {} is disabled." 221 | msgstr "" 222 | 223 | msgid "Media support: " 224 | msgstr "" 225 | 226 | msgid "Missing dependency: python \"{}\" package" 227 | msgstr "" 228 | 229 | msgid "Monitoring of changes to reload files automatically is not available" 230 | msgstr "" 231 | 232 | msgid "Navigating" 233 | msgstr "" 234 | 235 | msgid "Never clear (manually only)" 236 | msgstr "" 237 | 238 | msgid "Next slide" 239 | msgstr "" 240 | 241 | msgid "Next slide with different label" 242 | msgstr "" 243 | 244 | msgid "Not starting content or presenter window full screen because there is only one monitor" 245 | msgstr "" 246 | 247 | msgid "Note pages" 248 | msgstr "" 249 | 250 | msgid "Notes" 251 | msgstr "" 252 | 253 | msgid "Notes position" 254 | msgstr "" 255 | 256 | msgid "Open _Recent" 257 | msgstr "" 258 | 259 | msgid "Open file" 260 | msgstr "" 261 | 262 | msgid "Open..." 263 | msgstr "" 264 | 265 | msgid "Overrides the detection from the file." 266 | msgstr "" 267 | 268 | msgid "Overwrite" 269 | msgstr "" 270 | 271 | msgid "Overwrite changes instead of reloading?" 272 | msgstr "" 273 | 274 | msgid "PDF files" 275 | msgstr "" 276 | 277 | msgid "Pause" 278 | msgstr "" 279 | 280 | msgid "Plain" 281 | msgstr "" 282 | 283 | msgid "Plain layout, without note slides" 284 | msgstr "" 285 | 286 | msgid "Play" 287 | msgstr "" 288 | 289 | msgid "Play/pause timer" 290 | msgstr "" 291 | 292 | msgid "Pointer" 293 | msgstr "" 294 | 295 | msgid "Portable installation" 296 | msgstr "" 297 | 298 | msgid "Presentation" 299 | msgstr "" 300 | 301 | msgid "Presentation timing breakdown" 302 | msgstr "" 303 | 304 | msgid "Presenter fullscreen" 305 | msgstr "" 306 | 307 | msgid "Previous slide" 308 | msgstr "" 309 | 310 | msgid "Previous slide with different label" 311 | msgstr "" 312 | 313 | msgid "Print version and exit" 314 | msgstr "" 315 | 316 | msgid "Pympress Content" 317 | msgstr "" 318 | 319 | msgid "Pympress Presenter" 320 | msgstr "" 321 | 322 | msgid "Pympress can not extract attached file" 323 | msgstr "" 324 | 325 | msgid "Pympress can not extract embedded media" 326 | msgstr "" 327 | 328 | msgid "Pympress can not find file " 329 | msgstr "" 330 | 331 | msgid "Pympress can not interpret annotation of type:" 332 | msgstr "" 333 | 334 | msgid "Pympress does not recognize link type \"{}\"" 335 | msgstr "" 336 | 337 | msgid "Pympress does not recognize link type \"{}\" to \"{}\"" 338 | msgstr "" 339 | 340 | msgid "Pympress does not yet support link type \"{}\"" 341 | msgstr "" 342 | 343 | msgid "Pympress does not yet support link type \"{}\" to \"{}\"" 344 | msgstr "" 345 | 346 | msgid "Python version {}" 347 | msgstr "" 348 | 349 | msgid "Quit" 350 | msgstr "" 351 | 352 | msgid "Redo highlight stroke" 353 | msgstr "" 354 | 355 | msgid "Reload" 356 | msgstr "" 357 | 358 | msgid "Reset talk timer" 359 | msgstr "" 360 | 361 | msgid "Reset timer" 362 | msgstr "" 363 | 364 | msgid "Resources are loaded from " 365 | msgstr "" 366 | 367 | msgid "Save as..." 368 | msgstr "" 369 | 370 | msgid "Save changes before closing?" 371 | msgstr "" 372 | 373 | msgid "Save file" 374 | msgstr "" 375 | 376 | msgid "Save file as" 377 | msgstr "" 378 | 379 | msgid "Saving changes will overwrite the changed file!" 380 | msgstr "" 381 | 382 | msgid "Set estimated talk time" 383 | msgstr "" 384 | 385 | msgid "Set level of verbosity in log file:" 386 | msgstr "" 387 | 388 | msgid "Set talk _Time" 389 | msgstr "" 390 | 391 | msgid "Set the position of notes on the pdf page" 392 | msgstr "" 393 | 394 | msgid "Should not require hard enable/disable screensaver on Linux" 395 | msgstr "" 396 | 397 | msgid "Slide number" 398 | msgstr "" 399 | 400 | msgid "Some preferences are saved in " 401 | msgstr "" 402 | 403 | msgid "Stop" 404 | msgstr "" 405 | 406 | msgid "Swap windows" 407 | msgstr "" 408 | 409 | msgid "The estimated (intended) talk time in minutes" 410 | msgstr "" 411 | 412 | msgid "The log is written to " 413 | msgstr "" 414 | 415 | msgid "The open file was modified outside of pympress but you have made unsaved changes." 416 | msgstr "" 417 | 418 | msgid "Time elapsed" 419 | msgstr "" 420 | 421 | msgid "Time estimation" 422 | msgstr "" 423 | 424 | msgid "Time per slide (s):" 425 | msgstr "" 426 | 427 | msgid "Timers" 428 | msgstr "" 429 | 430 | msgid "Timing breakdown" 431 | msgstr "" 432 | 433 | msgid "Toggle annotations" 434 | msgstr "" 435 | 436 | msgid "Toggle fullscreen" 437 | msgstr "" 438 | 439 | msgid "Toggle highlighting" 440 | msgstr "" 441 | 442 | msgid "Toggle laserpointer" 443 | msgstr "" 444 | 445 | msgid "Toggle notes mode" 446 | msgstr "" 447 | 448 | msgid "Toggle pause of talk timer" 449 | msgstr "" 450 | 451 | msgid "Try your operating system’s package manager, or try running: pip install pygobject pycairo" 452 | msgstr "" 453 | 454 | msgid "Undo highlight stroke" 455 | msgstr "" 456 | 457 | msgid "Unexpected action in index \"{}\"" 458 | msgstr "" 459 | 460 | msgid "Unexpected missing page to draw for widget \"{}\"" 461 | msgstr "" 462 | 463 | msgid "Unknow widget {} to be fullscreened, aborting." 464 | msgstr "" 465 | 466 | msgid "Unknown widget \"{}\" to draw" 467 | msgstr "" 468 | 469 | msgid "Unrecognized named destination: " 470 | msgstr "" 471 | 472 | msgid "Unsaved changes" 473 | msgstr "" 474 | 475 | msgid "Unsaved changes will be lost" 476 | msgstr "" 477 | 478 | msgid "Unsaved changes will be lost." 479 | msgstr "" 480 | 481 | msgid "Unsupported OS: can't enable/disable screensaver" 482 | msgstr "" 483 | 484 | msgid "Unsupported link clicked. " 485 | msgstr "" 486 | 487 | msgid "Unzoom" 488 | msgstr "" 489 | 490 | msgid "Validate goto/jump destination" 491 | msgstr "" 492 | 493 | msgid "Windows" 494 | msgstr "" 495 | 496 | msgid "Zoom" 497 | msgstr "" 498 | 499 | msgid "_About" 500 | msgstr "" 501 | 502 | msgid "_After slide pages" 503 | msgstr "" 504 | 505 | msgid "_Annotations" 506 | msgstr "" 507 | 508 | msgid "_Automatic navigation" 509 | msgstr "" 510 | 511 | msgid "_Blank screen" 512 | msgstr "" 513 | 514 | msgid "_Blue" 515 | msgstr "" 516 | 517 | msgid "_Bottom half of slide" 518 | msgstr "" 519 | 520 | msgid "_Close" 521 | msgstr "" 522 | 523 | msgid "_Disabled" 524 | msgstr "" 525 | 526 | msgid "_Discard" 527 | msgstr "" 528 | 529 | msgid "_Every second page" 530 | msgstr "" 531 | 532 | msgid "_File" 533 | msgstr "" 534 | 535 | msgid "_First" 536 | msgstr "" 537 | 538 | msgid "_Fullscreen" 539 | msgstr "" 540 | 541 | msgid "_Go to..." 542 | msgstr "" 543 | 544 | msgid "_Green" 545 | msgstr "" 546 | 547 | msgid "_Help" 548 | msgstr "" 549 | 550 | msgid "_Highlight" 551 | msgstr "" 552 | 553 | msgid "_Jump to label" 554 | msgstr "" 555 | 556 | msgid "_Last" 557 | msgstr "" 558 | 559 | msgid "_Left half of slide" 560 | msgstr "" 561 | 562 | msgid "_Manual" 563 | msgstr "" 564 | 565 | msgid "_Navigation" 566 | msgstr "" 567 | 568 | msgid "_Next" 569 | msgstr "" 570 | 571 | msgid "_Notes mode" 572 | msgstr "" 573 | 574 | msgid "_Open" 575 | msgstr "" 576 | 577 | msgid "_Pause timer" 578 | msgstr "" 579 | 580 | msgid "_Permanent" 581 | msgstr "" 582 | 583 | msgid "_Pointer" 584 | msgstr "" 585 | 586 | msgid "_Prefixed labels" 587 | msgstr "" 588 | 589 | msgid "_Presentation" 590 | msgstr "" 591 | 592 | msgid "_Previous" 593 | msgstr "" 594 | 595 | msgid "_Quit" 596 | msgstr "" 597 | 598 | msgid "_Red" 599 | msgstr "" 600 | 601 | msgid "_Reset timer" 602 | msgstr "" 603 | 604 | msgid "_Right half of slide" 605 | msgstr "" 606 | 607 | msgid "_Save" 608 | msgstr "" 609 | 610 | msgid "_Save as" 611 | msgstr "" 612 | 613 | msgid "_Shortcuts" 614 | msgstr "" 615 | 616 | msgid "_Starting Configuration" 617 | msgstr "" 618 | 619 | msgid "_Swap screens" 620 | msgstr "" 621 | 622 | msgid "_Top half of slide" 623 | msgstr "" 624 | 625 | msgid "_Undo zoom" 626 | msgstr "" 627 | 628 | msgid "_Zoom in" 629 | msgstr "" 630 | 631 | msgid "access denied when trying to access screen saver settings in registry!" 632 | msgstr "" 633 | 634 | msgid "annotations" 635 | msgstr "" 636 | 637 | msgid "annotations (hideable)" 638 | msgstr "" 639 | 640 | msgid "box" 641 | msgstr "" 642 | 643 | msgid "current slide" 644 | msgstr "" 645 | 646 | msgid "duration" 647 | msgstr "" 648 | 649 | msgid "highlighting" 650 | msgstr "" 651 | 652 | msgid "horizontal" 653 | msgstr "" 654 | 655 | msgid "name" 656 | msgstr "" 657 | 658 | msgid "next slide(s)" 659 | msgstr "" 660 | 661 | msgid "next slides count" 662 | msgstr "" 663 | 664 | msgid "no action defined for this link!" 665 | msgstr "" 666 | 667 | msgid "notes" 668 | msgstr "" 669 | 670 | msgid "orientation" 671 | msgstr "" 672 | 673 | msgid "page label" 674 | msgstr "" 675 | 676 | msgid "pip will then download and compile pygobject and pycairo, for which you need the Gtk and cairo headers (or development packages)." 677 | msgstr "" 678 | 679 | msgid "pympress is a little PDF reader written in Python using Poppler for PDF rendering and GTK for the GUI.\n" 680 | msgstr "" 681 | 682 | msgid "resizeable" 683 | msgstr "" 684 | 685 | msgid "slide" 686 | msgstr "" 687 | 688 | msgid "slide #" 689 | msgstr "" 690 | 691 | msgid "time" 692 | msgstr "" 693 | 694 | msgid "vertical" 695 | msgstr "" 696 | 697 | msgid "widget" 698 | msgstr "" 699 | 700 | msgid "{}, {}, {}, {}, or {}" 701 | msgstr "" 702 | 703 | -------------------------------------------------------------------------------- /pympress/share/pixmaps/eraser.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cimbali/pympress/efc7899851170d1d1d9720c557257690f499ec05/pympress/share/pixmaps/eraser.png -------------------------------------------------------------------------------- /pympress/share/pixmaps/make-png: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | for SIZE in 16 22 24 32 48 64 128; do 4 | inkscape -w $SIZE -e pympress-$SIZE.png pympress.svg 5 | done 6 | -------------------------------------------------------------------------------- /pympress/share/pixmaps/marker_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cimbali/pympress/efc7899851170d1d1d9720c557257690f499ec05/pympress/share/pixmaps/marker_1.png -------------------------------------------------------------------------------- /pympress/share/pixmaps/marker_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cimbali/pympress/efc7899851170d1d1d9720c557257690f499ec05/pympress/share/pixmaps/marker_2.png -------------------------------------------------------------------------------- /pympress/share/pixmaps/marker_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cimbali/pympress/efc7899851170d1d1d9720c557257690f499ec05/pympress/share/pixmaps/marker_3.png -------------------------------------------------------------------------------- /pympress/share/pixmaps/marker_fill_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cimbali/pympress/efc7899851170d1d1d9720c557257690f499ec05/pympress/share/pixmaps/marker_fill_1.png -------------------------------------------------------------------------------- /pympress/share/pixmaps/marker_fill_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cimbali/pympress/efc7899851170d1d1d9720c557257690f499ec05/pympress/share/pixmaps/marker_fill_2.png -------------------------------------------------------------------------------- /pympress/share/pixmaps/marker_fill_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cimbali/pympress/efc7899851170d1d1d9720c557257690f499ec05/pympress/share/pixmaps/marker_fill_3.png -------------------------------------------------------------------------------- /pympress/share/pixmaps/pointer.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 24 | 26 | 30 | 34 | 38 | 42 | 46 | 47 | 57 | 58 | 80 | 82 | 83 | 85 | image/svg+xml 86 | 88 | 89 | 90 | 91 | 92 | 97 | 108 | 109 | 110 | -------------------------------------------------------------------------------- /pympress/share/pixmaps/pointer_blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cimbali/pympress/efc7899851170d1d1d9720c557257690f499ec05/pympress/share/pixmaps/pointer_blue.png -------------------------------------------------------------------------------- /pympress/share/pixmaps/pointer_green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cimbali/pympress/efc7899851170d1d1d9720c557257690f499ec05/pympress/share/pixmaps/pointer_green.png -------------------------------------------------------------------------------- /pympress/share/pixmaps/pointer_red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cimbali/pympress/efc7899851170d1d1d9720c557257690f499ec05/pympress/share/pixmaps/pointer_red.png -------------------------------------------------------------------------------- /pympress/share/pixmaps/pympress-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cimbali/pympress/efc7899851170d1d1d9720c557257690f499ec05/pympress/share/pixmaps/pympress-16.png -------------------------------------------------------------------------------- /pympress/share/pixmaps/pympress-22.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cimbali/pympress/efc7899851170d1d1d9720c557257690f499ec05/pympress/share/pixmaps/pympress-22.png -------------------------------------------------------------------------------- /pympress/share/pixmaps/pympress-24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cimbali/pympress/efc7899851170d1d1d9720c557257690f499ec05/pympress/share/pixmaps/pympress-24.png -------------------------------------------------------------------------------- /pympress/share/pixmaps/pympress-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cimbali/pympress/efc7899851170d1d1d9720c557257690f499ec05/pympress/share/pixmaps/pympress-32.png -------------------------------------------------------------------------------- /pympress/share/pixmaps/pympress-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cimbali/pympress/efc7899851170d1d1d9720c557257690f499ec05/pympress/share/pixmaps/pympress-48.png -------------------------------------------------------------------------------- /pympress/share/pixmaps/pympress-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cimbali/pympress/efc7899851170d1d1d9720c557257690f499ec05/pympress/share/pixmaps/pympress-64.png -------------------------------------------------------------------------------- /pympress/share/pixmaps/pympress.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cimbali/pympress/efc7899851170d1d1d9720c557257690f499ec05/pympress/share/pixmaps/pympress.ico -------------------------------------------------------------------------------- /pympress/share/pixmaps/pympress.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cimbali/pympress/efc7899851170d1d1d9720c557257690f499ec05/pympress/share/pixmaps/pympress.png -------------------------------------------------------------------------------- /pympress/share/pixmaps/pympress.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 23 | 25 | 27 | 31 | 35 | 36 | 45 | 48 | 52 | 53 | 56 | 60 | 61 | 70 | 71 | 89 | 91 | 92 | 94 | image/svg+xml 95 | 97 | 98 | 99 | 100 | 105 | 108 | 118 | 121 | 126 | 130 | 131 | 132 | 133 | 134 | -------------------------------------------------------------------------------- /pympress/share/xml/autoplay.glade: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 99 7 | 99 8 | 1 9 | 10 10 | 11 | 12 | 0.10 13 | 100 14 | 3 15 | 1 16 | 10 17 | 18 | 19 | 1 20 | 100 21 | 100 22 | 1 23 | 10 24 | 25 | 26 | False 27 | dialog 28 | 29 | 30 | False 31 | vertical 32 | 2 33 | 34 | 35 | False 36 | end 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | False 46 | False 47 | 0 48 | 49 | 50 | 51 | 52 | True 53 | False 54 | Choose parameters for automatically playing slides 55 | 56 | 57 | True 58 | False 59 | 0 60 | 61 | 62 | 63 | 64 | True 65 | False 66 | 67 | 68 | True 69 | False 70 | First slide 71 | right 72 | 1 73 | 74 | 75 | True 76 | True 77 | 0 78 | 79 | 80 | 81 | 82 | True 83 | True 84 | 3 85 | 3 86 | autoplay_lower 87 | True 88 | 89 | 90 | 91 | True 92 | False 93 | 1 94 | 95 | 96 | 97 | 98 | True 99 | False 100 | Last slide 101 | right 102 | 1 103 | 104 | 105 | True 106 | True 107 | 2 108 | 109 | 110 | 111 | 112 | True 113 | True 114 | 3 115 | 3 116 | autoplay_upper 117 | True 118 | 119 | 120 | 121 | True 122 | False 123 | 3 124 | 125 | 126 | 127 | 128 | True 129 | False 130 | 1 131 | 132 | 133 | 134 | 135 | True 136 | False 137 | 138 | 139 | True 140 | False 141 | 5 142 | 5 143 | Time per slide (s): 144 | True 145 | 1 146 | 147 | 148 | True 149 | True 150 | 0 151 | 152 | 153 | 154 | 155 | True 156 | True 157 | 5 158 | 5 159 | autoplay_time 160 | 1 161 | True 162 | 163 | 164 | True 165 | False 166 | 1 167 | 168 | 169 | 170 | 171 | Loop 172 | True 173 | True 174 | False 175 | 20 176 | 0 177 | True 178 | True 179 | 180 | 181 | True 182 | True 183 | 2 184 | 185 | 186 | 187 | 188 | True 189 | False 190 | 3 191 | 192 | 193 | 194 | 195 | 196 | 197 | -------------------------------------------------------------------------------- /pympress/share/xml/content.glade: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | True 7 | GDK_KEY_PRESS_MASK | GDK_STRUCTURE_MASK | GDK_SCROLL_MASK 8 | Pympress Content 9 | content 10 | center 11 | True 12 | False 13 | True 14 | False 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | True 23 | False 24 | 0 25 | none 26 | 1.3300000429153442 27 | False 28 | 29 | 30 | True 31 | False 32 | True 33 | True 34 | 35 | 36 | True 37 | False 38 | GDK_POINTER_MOTION_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK | GDK_ENTER_NOTIFY_MASK | GDK_LEAVE_NOTIFY_MASK | GDK_STRUCTURE_MASK 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -1 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /pympress/share/xml/deck.glade: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 100 7 | 1 8 | 10 9 | 10 | 11 | False 12 | 13 | 14 | True 15 | True 16 | vadjustment 17 | in 18 | 19 | 20 | deck_viewport 21 | True 22 | False 23 | vadjustment 24 | 25 | 26 | 27 | deck_grid 28 | True 29 | False 30 | 5 31 | 5 32 | True 33 | True 34 | 35 | 36 | True 37 | False 38 | 0 39 | none 40 | 1.7699999809265137 41 | 42 | 43 | deck0 44 | True 45 | True 46 | True 47 | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK | GDK_ENTER_NOTIFY_MASK | GDK_LEAVE_NOTIFY_MASK | GDK_STRUCTURE_MASK | GDK_TOUCH_MASK 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 58 | 59 | 60 | 0 61 | 0 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /pympress/share/xml/layout_dialog.glade: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 1 25 | 16 26 | 1 27 | 1 28 | 1 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | horizontal 40 | horizontal 41 | 42 | 43 | vertical 44 | vertical 45 | 46 | 47 | 48 | 49 | False 50 | Presentation timing breakdown 51 | 250 52 | True 53 | normal 54 | north-east 55 | 56 | 57 | 400 58 | False 59 | vertical 60 | 2 61 | 62 | 63 | False 64 | True 65 | end 66 | 67 | 68 | False 69 | False 70 | 2 71 | 72 | 73 | 74 | 75 | True 76 | False 77 | 78 | 79 | True 80 | False 81 | 1 82 | True 83 | plain 84 | 85 | Notes 86 | Plain 87 | Note pages 88 | Highlighting 89 | Highlighting with notes 90 | 91 | 92 | 93 | 94 | False 95 | 96 | 97 | 98 | 99 | False 100 | True 101 | 0 102 | 103 | 104 | 105 | 106 | True 107 | False 108 | True 109 | 110 | 111 | True 112 | True 113 | 1 114 | 115 | 116 | 117 | 118 | False 119 | True 120 | 0 121 | 122 | 123 | 124 | 125 | True 126 | True 127 | natural 128 | layout_treemodel 129 | True 130 | False 131 | vertical 132 | True 133 | 134 | 135 | 136 | 137 | 138 | 139 | True 140 | widget 141 | 142 | 143 | 144 | 6 145 | 146 | 147 | 148 | 149 | 150 | 151 | resizeable 152 | 153 | 154 | 155 | 156 | 157 | 1 158 | 2 159 | 160 | 161 | 162 | 163 | 164 | 165 | orientation 166 | 167 | 168 | True 169 | False 170 | orientations_model 171 | 0 172 | 173 | 174 | 175 | 3 176 | 4 177 | 178 | 179 | 180 | 181 | 182 | 183 | next slides count 184 | 185 | 186 | True 187 | next_count_adjustment 188 | 189 | 190 | 191 | 5 192 | 5 193 | 194 | 195 | 196 | 197 | 198 | 199 | True 200 | True 201 | 1 202 | 203 | 204 | 205 | 206 | 207 | 208 | -------------------------------------------------------------------------------- /pympress/share/xml/media_overlay.glade: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | True 7 | False 8 | vertical 9 | 10 | 11 | 12 | True 13 | False 14 | 15 | 16 | True 17 | False 18 | 19 | 20 | True 21 | False 22 | media.play 23 | Play 24 | True 25 | gtk-media-play 26 | 27 | 28 | False 29 | True 30 | 31 | 32 | 33 | 34 | True 35 | False 36 | media.pause 37 | Pause 38 | True 39 | gtk-media-pause 40 | 41 | 42 | False 43 | True 44 | 45 | 46 | 47 | 48 | True 49 | False 50 | media.stop 51 | Stop 52 | True 53 | gtk-media-stop 54 | 55 | 56 | False 57 | True 58 | 59 | 60 | 61 | 62 | False 63 | True 64 | 0 65 | 66 | 67 | 68 | 69 | True 70 | False 71 | right 72 | 73 | 74 | 75 | 76 | True 77 | True 78 | 5 79 | 1 80 | 81 | 82 | 85 | 86 | 87 | False 88 | False 89 | end 90 | 0 91 | 92 | 93 | 94 | 95 | True 96 | False 97 | GDK_BUTTON_PRESS_MASK | GDK_STRUCTURE_MASK 98 | 99 | 100 | 101 | True 102 | True 103 | end 104 | 1 105 | 106 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /pympress/share/xml/menu_bar.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | _File 6 |
7 | 8 | _Open 9 | app.pick-file 10 | 11 | 12 | 13 | Open _Recent 14 | app.list-recent-files 15 | 16 | 17 | _Close 18 | app.close-file 19 | 20 | 21 | _Save 22 | app.save-file 23 | 24 | 25 | _Save as 26 | app.save-file-as 27 | 28 | 29 | _Quit 30 | app.quit 31 | 32 |
33 |
34 | 35 | 36 | _Presentation 37 | 38 | _Pause timer 39 | app.pause-timer 40 | 41 | 42 | _Reset timer 43 | app.reset-timer 44 | 45 | 46 | Set talk _Time 47 | app.edit-talk-time 48 | 49 | 50 | _Fullscreen 51 | app.content-fullscreen 52 | 53 | 54 | _Swap screens 55 | app.swap-screens 56 | 57 | 58 | _Notes mode 59 | app.notes-mode 60 | 61 | 62 | _Blank screen 63 | app.blank-screen 64 | 65 | 66 | Align _content 67 | app.align-content 68 | 69 | 70 | _Annotations 71 | app.annotations 72 | 73 | 74 | Big buttons 75 | app.big-buttons 76 | 77 | 78 | _Highlight 79 | app.highlight 80 | 81 | 82 | _Deck overview 83 | app.deck-overview 84 | 85 | 86 | _Pointer 87 |
88 | 89 | _Permanent 90 | app.pointer-mode 91 | continuous 92 | 93 | 94 | _Manual 95 | app.pointer-mode 96 | manual 97 | 98 | 99 | _Disabled 100 | app.pointer-mode 101 | disabled 102 | 103 |
104 |
105 | 106 | _Red 107 | app.pointer-color 108 | red 109 | 110 | 111 | _Green 112 | app.pointer-color 113 | green 114 | 115 | 116 | _Blue 117 | app.pointer-color 118 | blue 119 | 120 |
121 |
122 | 123 | Highlight mode 124 |
125 | 126 | Clear on page change 127 | app.highlight-mode 128 | single-page 129 | 130 | 131 | Never clear (manually only) 132 | app.highlight-mode 133 | global 134 | 135 | 136 | Clear and restore per page number 137 | app.highlight-mode 138 | per-page 139 | 140 | 141 | Clear and restore per page label 142 | app.highlight-mode 143 | per-label 144 | 145 |
146 |
147 | 148 | Exit highlight on page change 149 | app.highlight-page-exit 150 | 151 |
152 |
153 | 154 | 155 | _Zoom in 156 | app.zoom 157 | 158 | 159 | _Undo zoom 160 | app.unzoom 161 | 162 | 163 | 164 | Notes position 165 | 166 | _Right half of slide 167 | app.notes-pos 168 | right 169 | 170 | 171 | _Bottom half of slide 172 | app.notes-pos 173 | bottom 174 | 175 | 176 | _Left half of slide 177 | app.notes-pos 178 | left 179 | 180 | 181 | _Top half of slide 182 | app.notes-pos 183 | top 184 | 185 | 186 | _After slide pages 187 | app.notes-pos 188 | after 189 | 190 | 191 | _Every second page 192 | app.notes-pos 193 | odd 194 | 195 | 196 | _Prefixed labels 197 | app.notes-pos 198 | map 199 | 200 | 201 | 202 | 203 | Timing breakdown 204 | app.timing-report 205 | 206 |
207 | 208 | 209 | _Navigation 210 | 211 | _Next 212 | app.next-page 213 | 214 | 215 | _Previous 216 | app.prev-page 217 | 218 | 219 | _First 220 | app.first-page 221 | 222 | 223 | _Last 224 | app.last-page 225 | 226 | 227 | _Go to... 228 | app.goto-page 229 | 230 | 231 | _Jump to label 232 | app.jumpto-label 233 | 234 | 235 | _Automatic navigation 236 | app.autoplay 237 | 238 | 239 | 240 | 241 | _Starting Configuration 242 | 243 | Content blanked 244 | app.start-blanked 245 | 246 | 247 | Content fullscreen 248 | app.start-content-fullscreen 249 | 250 | 251 | Presenter fullscreen 252 | app.start-presenter-fullscreen 253 | 254 | 255 | Portable installation 256 | app.portable-config 257 | 258 | 259 | Edit layout 260 | app.edit-layout 261 | 262 | 263 | 264 | 265 | _Help 266 | 267 | _About 268 | app.about 269 | 270 | 271 | _Shortcuts 272 | app.show-shortcuts 273 | 274 | 275 | 276 |
277 |
278 | -------------------------------------------------------------------------------- /pympress/share/xml/time_report_dialog.glade: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | False 7 | Presentation timing breakdown 8 | 700 9 | True 10 | normal 11 | north-east 12 | 13 | 14 | 15 | 16 | 17 | 700 18 | False 19 | vertical 20 | 2 21 | 22 | 23 | False 24 | True 25 | end 26 | 27 | 28 | False 29 | False 30 | 0 31 | 32 | 33 | 34 | 35 | True 36 | True 37 | in 38 | 39 | 40 | True 41 | True 42 | natural 43 | False 44 | vertical 45 | True 46 | 47 | 48 | 49 | 50 | 51 | name 52 | True 53 | True 54 | 55 | 56 | 57 | 0 58 | 59 | 60 | 61 | 62 | 63 | 64 | 60 65 | True 66 | time 67 | 68 | 69 | 70 | 1 71 | 72 | 73 | 74 | 75 | 76 | 77 | 60 78 | True 79 | duration 80 | 81 | 82 | 83 | 2 84 | 85 | 86 | 87 | 88 | 89 | 90 | 60 91 | True 92 | slide 93 | 94 | 95 | 1 96 | 97 | 98 | 3 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | True 108 | True 109 | 1 110 | 111 | 112 | 113 | 114 | 115 | 116 | -------------------------------------------------------------------------------- /pympress/surfacecache.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # surfacecache.py 4 | # 5 | # Copyright 2010 Thomas Jost 6 | # Copyright 2015 Cimbali 7 | # 8 | # This program is free software; you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License as published by 10 | # the Free Software Foundation; either version 2 of the License, or 11 | # (at your option) any later version. 12 | # 13 | # This program is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU General Public License for more details. 17 | # 18 | # You should have received a copy of the GNU General Public License 19 | # along with this program; if not, write to the Free Software 20 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, 21 | # MA 02110-1301, USA. 22 | """ 23 | :mod:`pympress.surfacecache` -- pages prerendering and caching 24 | -------------------------------------------------------------- 25 | 26 | This modules contains stuff needed for caching pages and prerendering them. This 27 | is done by the :class:`~pympress.surfacecache.SurfaceCache` class, using several 28 | `dict` of :class:`~cairo.ImageSurface` for storing rendered pages. 29 | 30 | The problem is, neither Gtk+ nor Poppler are particularly threadsafe. 31 | Hence the prerendering isn't really done in parallel in another thread, but 32 | scheduled on the main thread at idle times using GLib.idle_add(). 33 | """ 34 | 35 | import logging 36 | logger = logging.getLogger(__name__) 37 | 38 | import threading 39 | import functools 40 | import collections 41 | 42 | import gi 43 | import cairo 44 | gi.require_version('Gtk', '3.0') 45 | from gi.repository import GLib 46 | 47 | 48 | class SurfaceCache(object): 49 | """ Pages caching and prerendering made (almost) easy. 50 | 51 | Args: 52 | doc (:class:`~pympress.document.Document`): the current document 53 | max_pages (`int`): The maximum page number. 54 | """ 55 | 56 | #: The actual cache. The `dict`s keys are widget names and its values are 57 | #: :class:`~collections.OrderedDict`, whose keys are page numbers 58 | #: and values are instances of :class:`~cairo.ImageSurface`. 59 | #: In each :class:`~collections.OrderedDict` keys are ordered by 60 | #: Least Recently Used (get or set), when the size is beyond 61 | #: :attr:`max_pages`, pages are popped from the start of the cache. 62 | surface_cache = {} 63 | 64 | #: `dict` containing functions that return a :class:`~cairo.Surface` given a :class:`~cairo.Format`, 65 | #: width `int` and height `int`, see :meth:`~Gtk.Window.create_similar_image_surface` 66 | surface_factory = {} 67 | 68 | #: Size of the different managed widgets, as a `dict` of tuples 69 | surface_size = {} 70 | 71 | #: Type of document handled by each widget. It is a `dict`: its keys are 72 | #: widget names and its values are document types from ui. 73 | surface_type = {} 74 | 75 | #: Dictionary of :class:`~threading.Lock` used for managing conccurent 76 | #: accesses to :attr:`surface_cache` and :attr:`surface_size` 77 | locks = {} 78 | 79 | #: Set of widgets for which we ignore the max 80 | unlimited = set() 81 | 82 | #: The current :class:`~pympress.document.Document`. 83 | doc = None 84 | 85 | #: :class:`~threading.Lock` used to manage conccurent accesses to :attr:`doc`. 86 | doc_lock = None 87 | 88 | #: Set of active widgets 89 | active_widgets = set() 90 | 91 | #: maximum number of pages we keep in cache 92 | max_pages = 200 93 | 94 | def __init__(self, doc, max_pages): 95 | self.max_pages = max_pages 96 | self.doc = doc 97 | self.doc_lock = threading.Lock() 98 | 99 | 100 | def add_widget(self, widget, wtype, prerender_enabled = True, zoomed = False, ignore_max = False): 101 | """ Add a widget to the list of widgets that have to be managed (for caching and prerendering). 102 | 103 | This creates new entries for ``widget_name`` in the needed internal data 104 | structures, and creates a new thread for prerendering pages for this widget. 105 | 106 | Args: 107 | widget (:class:`~Gtk.Widget`): The widget for which we need to cache 108 | wtype (`int`): type of document handled by the widget (see :attr:`surface_type`) 109 | prerender_enabled (`bool`): whether this widget is initially in the list of widgets to prerender 110 | zoomed (`bool`): whether we will cache a zoomed portion of the widget 111 | ignore_max (`bool`): whether we will cache an unlimited number of slides 112 | """ 113 | widget_name = widget.get_name().rstrip('0123456789') + ('_zoomed' if zoomed else '') 114 | with self.locks.setdefault(widget_name, threading.Lock()): 115 | self.surface_cache[widget_name] = collections.OrderedDict() 116 | self.surface_size[widget_name] = (-1, -1) 117 | self.surface_type[widget_name] = wtype 118 | self.surface_factory[widget_name] = functools.partial(self._create_surface, widget) 119 | if prerender_enabled and not zoomed: 120 | self.enable_prerender(widget_name) 121 | if ignore_max: 122 | self.unlimited.add(widget_name) 123 | 124 | 125 | def swap_document(self, new_doc): 126 | """ Replaces the current document for which to cache slides with a new one. 127 | 128 | This function also clears the cached pages, since they now belong to an outdated document. 129 | 130 | Args: 131 | new_doc (:class:`~pympress.document.Document`): the new document 132 | """ 133 | with self.doc_lock: 134 | self.doc = new_doc 135 | 136 | self.clear_cache() 137 | 138 | 139 | def disable_prerender(self, widget_name): 140 | """ Remove a widget from the ones to be prerendered. 141 | 142 | Args: 143 | widget_name (`str`): string used to identify a widget 144 | """ 145 | self.active_widgets.discard(widget_name) 146 | 147 | 148 | def enable_prerender(self, widget_name): 149 | """ Add a widget to the ones to be prerendered. 150 | 151 | Args: 152 | widget_name (`str`): string used to identify a widget 153 | """ 154 | self.active_widgets.add(widget_name) 155 | 156 | 157 | def set_widget_type(self, widget_name, wtype): 158 | """ Set the document type of a widget. 159 | 160 | Args: 161 | widget_name (`str`): string used to identify a widget 162 | wtype (`int`): type of document handled by the widget (see :attr:`surface_type`) 163 | """ 164 | with self.locks[widget_name]: 165 | if self.surface_type[widget_name] != wtype: 166 | self.surface_type[widget_name] = wtype 167 | self.surface_cache[widget_name].clear() 168 | 169 | 170 | def get_widget_type(self, widget_name): 171 | """ Get the document type of a widget. 172 | 173 | Args: 174 | widget_name (`str`): string used to identify a widget 175 | 176 | Returns: 177 | `int`: type of document handled by the widget (see :attr:`surface_type`) 178 | """ 179 | return self.surface_type[widget_name] 180 | 181 | 182 | def clear_cache(self, widget_name=None): 183 | """ Remove all cached values for a given widget. Useful for zoomed views. 184 | 185 | Args: 186 | widget_name (`str`): name of the widget that is resized, `None` for all widgets. 187 | """ 188 | for widget in [widget_name] if widget_name is not None else self.locks: 189 | with self.locks[widget]: 190 | self.surface_cache[widget].clear() 191 | 192 | 193 | def resize_widget(self, widget_name, width, height): 194 | """ Change the size of a registered widget, thus invalidating all the cached pages. 195 | 196 | Args: 197 | widget_name (`str`): name of the widget that is resized 198 | width (`int`): new width of the widget 199 | height (`int`): new height of the widget 200 | """ 201 | with self.locks[widget_name]: 202 | if (width, height) != self.surface_size[widget_name]: 203 | self.surface_cache[widget_name].clear() 204 | self.surface_size[widget_name] = (width, height) 205 | 206 | 207 | def get(self, widget_name, page_nb): 208 | """ Fetch a cached, prerendered page for the specified widget. 209 | 210 | Args: 211 | widget_name (`str`): name of the concerned widget 212 | page_nb (`int`): number of the page to fetch in the cache 213 | 214 | Returns: 215 | :class:`~cairo.ImageSurface`: the cached page if available, or `None` otherwise 216 | """ 217 | with self.locks[widget_name]: 218 | pc = self.surface_cache[widget_name] 219 | if page_nb in pc: 220 | pc.move_to_end(page_nb) 221 | return pc[page_nb] 222 | else: 223 | return None 224 | 225 | 226 | def put(self, widget_name, page_nb, val): 227 | """ Store a rendered page in the cache. 228 | 229 | Args: 230 | widget_name (`str`): name of the concerned widget 231 | page_nb (`int`): number of the page to store in the cache 232 | val (:class:`~cairo.ImageSurface`): content to store in the cache 233 | """ 234 | with self.locks[widget_name]: 235 | pc = self.surface_cache[widget_name] 236 | pc[page_nb] = val 237 | pc.move_to_end(page_nb) 238 | 239 | while len(pc) > self.max_pages: 240 | pc.popitem(False) 241 | 242 | 243 | def _create_surface(self, widget, fmt, width, height): 244 | """ Given a widget, create a cairo Image surface with appropriate size and scaling. 245 | 246 | Args: 247 | widget (:class:`~Gtk.Widget`): the widget for which we’re caching data. 248 | fmt (:class:`~cairo.Format`): the format for the new surface 249 | width (`int`): width of the new surface 250 | height (`int`): height of the new surface 251 | 252 | Returns: 253 | :class:`~cairo.ImageSurface`: a new image surface 254 | """ 255 | window = widget.get_window() 256 | scale = window.get_scale_factor() 257 | try: 258 | return window.create_similar_image_surface(fmt, width * scale, height * scale, scale) 259 | except cairo.Error: 260 | logger.warning('Failed creating a {} cache surface sized {}x{} scale {} for widget {}' 261 | .format(fmt, width * scale, height * scale, scale, widget.get_name()), exc_info=True) 262 | # Warn here but handle error at the call site 263 | raise 264 | 265 | 266 | def prerender(self, page_nb): 267 | """ Queue a page for prerendering. 268 | 269 | The specified page will be prerendered for all the registered widgets. 270 | 271 | Args: 272 | page_nb (`int`): number of the page to be prerendered 273 | """ 274 | for name in self.active_widgets: 275 | GLib.idle_add(self.renderer, name, page_nb) 276 | 277 | 278 | def renderer(self, widget_name, page_nb): 279 | """ Rendering function. 280 | 281 | This function is meant to be scheduled on the GLib main loop. When run, 282 | it will go through the following steps: 283 | 284 | - check if the job's result is not already available in the cache 285 | - render it in a new :class:`~cairo.ImageSurface` if necessary 286 | - store it in the cache if it was not added there since the beginning of 287 | the process and the widget configuration is still valid 288 | 289 | Args: 290 | widget_name (`str`): name of the concerned widget 291 | page_nb (`int`): number of the page to store in the cache 292 | """ 293 | with self.locks[widget_name]: 294 | if page_nb in self.surface_cache[widget_name]: 295 | # Already in cache 296 | return GLib.SOURCE_REMOVE 297 | ww, wh = self.surface_size[widget_name] 298 | wtype = self.surface_type[widget_name] 299 | 300 | if ww < 0 or wh < 0: 301 | logger.warning('Widget {} with invalid size {}x{} when rendering'.format(widget_name, ww, wh)) 302 | return GLib.SOURCE_REMOVE 303 | 304 | with self.doc_lock: 305 | page = self.doc.page(page_nb) 306 | if page is None: 307 | return GLib.SOURCE_REMOVE 308 | 309 | # Render to a ImageSurface 310 | try: 311 | surface = self.surface_factory[widget_name](cairo.Format.RGB24, ww, wh) 312 | except AttributeError: 313 | logger.warning('Widget {} was not mapped when rendering'.format(widget_name), exc_info = True) 314 | return GLib.SOURCE_REMOVE 315 | except cairo.Error: 316 | return GLib.SOURCE_REMOVE 317 | 318 | context = cairo.Context(surface) 319 | page.render_cairo(context, ww, wh, wtype) 320 | del context 321 | 322 | # Save if possible and necessary − using PDF page numbering 323 | page_nb = page.page_nb 324 | with self.locks[widget_name]: 325 | pc = self.surface_cache[widget_name] 326 | if (ww, wh) == self.surface_size[widget_name] and page_nb not in pc: 327 | pc[page_nb] = surface 328 | 329 | if widget_name not in self.unlimited: 330 | pc.move_to_end(page_nb) 331 | while len(pc) > self.max_pages: 332 | pc.popitem(False) 333 | 334 | return GLib.SOURCE_REMOVE 335 | 336 | 337 | ## 338 | # Local Variables: 339 | # mode: python 340 | # indent-tabs-mode: nil 341 | # py-indent-offset: 4 342 | # fill-column: 80 343 | # end: 344 | -------------------------------------------------------------------------------- /pympress/talk_time.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # talk_time.py 4 | # 5 | # Copyright 2017 Cimbali 6 | # 7 | # This program is free software; you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation; either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with this program; if not, write to the Free Software 19 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, 20 | # MA 02110-1301, USA. 21 | """ 22 | :mod:`pympress.talk_time` -- Manages the clock of elapsed talk time 23 | ------------------------------------------------------------------- 24 | """ 25 | 26 | import logging 27 | logger = logging.getLogger(__name__) 28 | 29 | import time 30 | 31 | import gi 32 | gi.require_version('Gtk', '3.0') 33 | from gi.repository import Gtk, GLib 34 | 35 | 36 | class TimeLabelColorer(object): 37 | """ Manage the colors of a label with a set of colors between which to fade, based on how much time remains. 38 | 39 | Times are given in seconds (<0 has run out of time). In between timestamps the color will interpolated linearly, 40 | outside of the intervals the closest color will be used. 41 | 42 | Args: 43 | label_time (:class:`Gtk.Label`): the label where the talk time is displayed 44 | """ 45 | 46 | #: The :class:`Gtk.Label` whose colors need updating 47 | label_time = None 48 | 49 | #: :class:`~Gdk.RGBA` The default color of the info labels 50 | label_color_default = None 51 | 52 | #: :class:`~Gtk.CssProvider` affecting the style context of the labels 53 | color_override = None 54 | 55 | #: `list` of tuples (`int`, :class:`~Gdk.RGBA`), which are the desired colors at the corresponding timestamps. 56 | #: Sorted on the timestamps. 57 | color_map = [] 58 | 59 | def __init__(self, label_time): 60 | self.label_time = label_time 61 | 62 | style_context = self.label_time.get_style_context() 63 | self.color_override = Gtk.CssProvider() 64 | style_context.add_provider(self.color_override, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + 1) 65 | 66 | self.label_color_default = self.load_color_from_css(style_context) 67 | label_color_ett_reached = self.load_color_from_css(style_context, "ett-reached") 68 | label_color_ett_info = self.load_color_from_css(style_context, "ett-info") 69 | label_color_ett_warn = self.load_color_from_css(style_context, "ett-warn") 70 | 71 | self.color_map = [ 72 | ( 300, self.label_color_default), 73 | ( 0, label_color_ett_reached), 74 | (-150, label_color_ett_info), 75 | (-300, label_color_ett_warn) 76 | ] 77 | 78 | 79 | def load_color_from_css(self, style_context, class_name = None): 80 | """ Add class class_name to the time label and return its color. 81 | 82 | Args: 83 | label_time (:class:`Gtk.Label`): the label where the talk time is displayed 84 | style_context (:class:`~Gtk.StyleContext`): the CSS context managing the color of the label 85 | class_name (`str` or `None`): The name of the class, if any 86 | 87 | Returns: 88 | :class:`~Gdk.RGBA`: The color of the label with class "class_name" 89 | """ 90 | if class_name: 91 | style_context.add_class(class_name) 92 | 93 | self.label_time.show() 94 | color = style_context.get_color(Gtk.StateType.NORMAL) 95 | 96 | if class_name: 97 | style_context.remove_class(class_name) 98 | 99 | return color 100 | 101 | 102 | def default_color(self): 103 | """ Forces to reset the default colors on the label. 104 | """ 105 | self.color_override.load_from_data(''.encode('ascii')) 106 | 107 | 108 | def update_time_color(self, remaining): 109 | """ Update the color of the time label based on how much time is remaining. 110 | 111 | Args: 112 | remaining (`int`): Remaining time until estimated talk time is reached, in seconds. 113 | """ 114 | if (remaining <= 0 and remaining > -5) or (remaining <= -300 and remaining > -310): 115 | self.label_time.get_style_context().add_class("time-warn") 116 | else: 117 | self.label_time.get_style_context().remove_class("time-warn") 118 | 119 | prev_time, prev_color = None, None 120 | 121 | for timestamp, color in self.color_map: 122 | if remaining >= timestamp: 123 | break 124 | prev_time, prev_color = (timestamp, color) 125 | 126 | else: 127 | # if remaining < all timestamps, use only last color 128 | prev_color = None 129 | 130 | if prev_color: 131 | position = (remaining - prev_time) / (timestamp - prev_time) 132 | color_spec = '* {{color: mix({}, {}, {})}}'.format(prev_color.to_string(), color.to_string(), position) 133 | else: 134 | color_spec = '* {{color: {}}}'.format(color.to_string()) 135 | 136 | self.color_override.load_from_data(color_spec.encode('ascii')) 137 | 138 | 139 | class TimeCounter(object): 140 | """ A double counter, that displays the time elapsed in the talk and a clock. 141 | 142 | Args: 143 | builder (builder.Builder): The builder from which to load widgets. 144 | ett (`int`): the estimated time for the talk, in seconds. 145 | timing_tracker: (:class:`~pympress.extras.TimingReport`): to inform when the slides change 146 | autoplay: (:class:`~pympress.dialog.AutoPlay`): to adjust the timer display if we’re auto-playing/looping slides 147 | """ 148 | #: Elapsed time :class:`~Gtk.Label` 149 | label_time = None 150 | #: Clock :class:`~Gtk.Label` 151 | label_clock = None 152 | 153 | #: Time at which the counter was started, `int` in seconds as returned by :func:`~time.time()` 154 | restart_time = 0 155 | #: Time elapsed since the beginning of the presentation, `int` in seconds 156 | elapsed_time = 0 157 | #: Timer paused status, `bool` 158 | paused = True 159 | 160 | #: :class:`~TimeLabelColorer` that handles setting the colors of :attr:`label_time` 161 | label_colorer = None 162 | 163 | #: :class:`~pympress.editable_label.EstimatedTalkTime` that handles changing the ett 164 | ett = None 165 | 166 | #: The pause-timer :class:`~Gio.Action` 167 | pause_action = None 168 | 169 | #: The :class:`~pympress.extras.TimingReport`, needs to know when the slides change 170 | timing_tracker = None 171 | #: The :class:`~pympress.dialog.AutoPlay`, to adjust the timer display if we’re auto-playing/looping slides 172 | autoplay = None 173 | 174 | def __init__(self, builder, ett, timing_tracker, autoplay): 175 | super(TimeCounter, self).__init__() 176 | 177 | self.label_colorer = TimeLabelColorer(builder.get_object('label_time')) 178 | self.ett = ett 179 | self.timing_tracker = timing_tracker 180 | self.autoplay = autoplay 181 | 182 | builder.load_widgets(self) 183 | 184 | builder.setup_actions({ 185 | 'pause-timer': dict(activate=self.switch_pause, state=self.paused), 186 | 'reset-timer': dict(activate=self.reset_timer), 187 | }) 188 | self.pause_action = builder.get_application().lookup_action('pause-timer') 189 | 190 | # Setup timer for clocks 191 | GLib.timeout_add(250, self.update_time) 192 | 193 | 194 | def switch_pause(self, gaction, param=None): 195 | """ Switch the timer between paused mode and running (normal) mode. 196 | 197 | Returns: 198 | `bool`: whether the clock's pause was toggled. 199 | """ 200 | if self.paused: 201 | self.unpause() 202 | else: 203 | self.pause() 204 | return None 205 | 206 | 207 | def pause(self): 208 | """ Pause the timer if it is not paused, otherwise do nothing. 209 | 210 | Returns: 211 | `bool`: whether the clock's pause was toggled. 212 | """ 213 | if self.paused: 214 | return False 215 | 216 | self.paused = True 217 | self.pause_action.change_state(GLib.Variant.new_boolean(self.paused)) 218 | 219 | self.elapsed_time += time.time() - self.restart_time 220 | self.timing_tracker.end_time = self.elapsed_time 221 | 222 | if self.autoplay.is_looping(): 223 | self.autoplay.pause() 224 | 225 | self.update_time() 226 | return True 227 | 228 | 229 | def unpause(self): 230 | """ Unpause the timer if it is paused, otherwise do nothing. 231 | 232 | Returns: 233 | `bool`: whether the clock's pause was toggled. 234 | """ 235 | if not self.paused: 236 | return False 237 | 238 | self.restart_time = time.time() 239 | 240 | self.paused = False 241 | self.pause_action.change_state(GLib.Variant.new_boolean(self.paused)) 242 | 243 | if self.autoplay.is_looping(): 244 | self.autoplay.unpause() 245 | 246 | self.update_time() 247 | return True 248 | 249 | 250 | def reset_timer(self, *args): 251 | """ Reset the timer. 252 | """ 253 | self.timing_tracker.reset(self.current_time()) 254 | 255 | self.restart_time = time.time() 256 | self.elapsed_time = 0 257 | if self.autoplay.is_looping(): 258 | self.autoplay.start_looping() 259 | self.update_time() 260 | 261 | 262 | def current_time(self): 263 | """ Returns the time elapsed in the presentation. 264 | 265 | Returns: 266 | `int`: the time since the presentation started in seconds. 267 | """ 268 | # Time elapsed since the beginning of the presentation 269 | if self.paused: 270 | return self.elapsed_time 271 | else: 272 | return self.elapsed_time + (time.time() - self.restart_time) 273 | 274 | 275 | def update_time(self): 276 | """ Update the timer and clock labels. 277 | 278 | Returns: 279 | `bool`: `True` (to prevent the timer from stopping) 280 | """ 281 | # Current time 282 | clock = time.strftime('%X') # '%H:%M:%S' 283 | elapsed = self.current_time() 284 | 285 | # Time elapsed since the beginning of the presentation 286 | if self.autoplay.is_looping(): 287 | first, stop, loop, delay = self.autoplay.get_page_range() 288 | display_time = '{} {}-{} / {:.1f}s'.format(_('Loop') if loop else _('Auto'), first + 1, stop, delay / 1000) 289 | else: 290 | display_time = '{:02}:{:02}'.format(*divmod(int(elapsed), 60)) 291 | 292 | if self.paused: 293 | display_time += ' ' + _('(paused)') 294 | 295 | self.label_time.set_text(display_time) 296 | self.label_clock.set_text(clock) 297 | if not self.paused: 298 | self.timing_tracker.end_time = elapsed 299 | 300 | if self.ett.est_time: 301 | self.label_colorer.update_time_color(self.ett.est_time - elapsed) 302 | else: 303 | self.label_colorer.default_color() 304 | 305 | return True 306 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42", "babel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.typos.files] 6 | extend-exclude = ["pympress/share/locale/*/LC_MESSAGES/pympress.po"] 7 | [tool.typos.default.extend-identifiers] 8 | opf = "opf" 9 | [tool.typos.default.extend-words] 10 | gir = "gir" 11 | -------------------------------------------------------------------------------- /scripts/poedit.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | cd "`git rev-parse --show-toplevel 2>/dev/null || readlink -zf "$0" | xargs -0 dirname -z | xargs -0 dirname`" 4 | pot=pympress/share/locale/pympress.pot 5 | 6 | upload() { 7 | printf 'Uploading new strings to poeditor: ' 8 | curl -sX POST https://api.poeditor.com/v2/projects/upload \ 9 | -F api_token="$poeditor_api_token" \ 10 | -F id="301055" -F updating="terms" -F file=@"$pot" \ 11 | -F tags="{\"obsolete\":\"removed\"}" | 12 | jq -r '.response.message' 13 | } 14 | 15 | languages() { 16 | curl -sX POST https://api.poeditor.com/v2/languages/list \ 17 | -F api_token="$poeditor_api_token" \ 18 | -F id="301055" | 19 | jq -r "select(.response.code == \"200\") | .result.languages[] | select(.percentage > $1) | \"\(.code)\t\(.percentage)%\"" 20 | } 21 | 22 | contributors() { 23 | contributors=`mktemp contributors.XXXXXX` 24 | trap 'rm -f $contributors' EXIT 25 | 26 | # Github-only contributors 27 | cat >$contributors <> $contributors 42 | 43 | # Rename contributors on request and/or for de-duplication 44 | sed 's/^FriedrichFroebel$/FriedrichFröbel/;s/^Watanabe$/atsuyaw/' $contributors | 45 | sed 's/$/,/' | sort -fuo $contributors 46 | 47 | # Update README from generated list 48 | sed -ni -e '1,//p;//,$p' \ 49 | -e '//r '$contributors README.md 50 | } 51 | 52 | download() { 53 | lang=$1 54 | printf "Updating %s...\n" "$lang" 55 | # Normalize separator to _ and capitalised locale 56 | norm=`echo "$lang" | sed -E 's/-(\w+)$/_\U\1\E/;s/^zh_HANS$/zh_CN/'` 57 | 58 | url=`curl -sX POST https://api.poeditor.com/v2/projects/export \ 59 | -F api_token="$poeditor_api_token" \ 60 | -F id="301055" -F language="$lang" -F type="po" \ 61 | | jq -r 'select(.response.code == "200") | .result.url'` 62 | 63 | test -n "$url" && mkdir -p "pympress/share/locale/${norm}/LC_MESSAGES" && 64 | curl -so - "$url" | sed "/Language/s/$lang/$norm/" > "pympress/share/locale/${norm}/LC_MESSAGES/pympress.po" 65 | 66 | # test the file 67 | msgfmt --use-fuzzy "pympress/share/locale/${norm}/LC_MESSAGES/pympress.po" -o /dev/null 68 | } 69 | 70 | getpass() { 71 | if test -z "$poeditor_api_token"; then 72 | poeditor_api_token=`$SSH_ASKPASS "Password for 'https://api.poeditor.com/projects/v2/': "` 73 | fi 74 | } 75 | 76 | 77 | if [ $# -eq 0 ]; then 78 | echo "Usage: $0 " 79 | echo "Where command is one of: upload, languages, download, contributors" 80 | echo 81 | echo "MIN_LANG_COMPLETE can be set to override minimum percentage of completion. Requires curl and jq." 82 | fi 83 | 84 | 85 | while [ $# -gt 0 ]; do 86 | if test "$1" = "upload"; then 87 | getpass 88 | upload 89 | elif test "$1" = "languages"; then 90 | getpass 91 | languages ${MIN_LANG_COMPLETE:-0} 92 | elif test "$1" = "download"; then 93 | getpass 94 | avail_lang=`languages ${MIN_LANG_COMPLETE:-5} | cut -f1` 95 | for lang in $avail_lang; do 96 | download $lang 97 | done 98 | contributors $avail_lang 99 | elif test "$1" = "contributors"; then 100 | getpass 101 | avail_lang=`languages ${MIN_LANG_COMPLETE:-5} | cut -f1` 102 | contributors $avail_lang 103 | else 104 | echo "Unrecognised command $1 use one of: upload, languages, download, contributors" 105 | exit 1 106 | fi 107 | shift 108 | done 109 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = pympress 3 | version = attr: pympress.__version__ 4 | keywords = pdf-viewer, beamer, presenter, slide, projector, pdf-reader, presentation, python, poppler, gtk, pygi, vlc 5 | description = A simple and powerful dual-screen PDF reader designed for presentations 6 | long_description = file: README.md 7 | long_description_content_type = text/markdown 8 | author = Cimbali, Thomas Jost, Christof Rath, Epithumia 9 | author_email = me@cimba.li 10 | url = https://github.com/Cimbali/pympress/ 11 | download_url = https://github.com/Cimbali/pympress/releases/latest 12 | project_urls = 13 | Issues = https://github.com/Cimbali/pympress/issues/ 14 | Documentation = https://pympress.github.io/ 15 | Source Code = https://github.com/Cimbali/pympress/ 16 | license = GPL-2.0-or-later 17 | license_files = LICENSE.txt 18 | classifiers = 19 | Development Status :: 5 - Production/Stable 20 | Environment :: X11 Applications :: GTK 21 | Intended Audience :: Education 22 | Intended Audience :: End Users/Desktop 23 | Intended Audience :: Information Technology 24 | Intended Audience :: Science/Research 25 | Natural Language :: English 26 | Natural Language :: French 27 | Natural Language :: German 28 | Natural Language :: Italian 29 | Natural Language :: Polish 30 | Natural Language :: Spanish 31 | Natural Language :: Czech 32 | Natural Language :: Chinese (Simplified) 33 | Natural Language :: Chinese (Traditional) 34 | Operating System :: OS Independent 35 | Programming Language :: Python 36 | Topic :: Multimedia :: Graphics :: Presentation 37 | Topic :: Multimedia :: Graphics :: Viewers 38 | 39 | [options] 40 | packages = 41 | pympress 42 | pympress.media_overlays 43 | python_requires = >=3.4 44 | install_requires = 45 | watchdog 46 | importlib_resources >= 1.3;python_version<"3.9" 47 | build_requires = 48 | setuptools 49 | babel 50 | 51 | [options.extras_require] 52 | build_sphinx = 53 | Sphinx 54 | myst-parser 55 | sphinxcontrib-napoleon 56 | sphinx-rtd-theme 57 | babel = 58 | babel 59 | babelgladeextractor 60 | setuptools 61 | vlc_video = 62 | python-vlc 63 | 64 | [options.package_data] 65 | pympress = 66 | share/defaults.conf 67 | share/xml/*.glade 68 | share/xml/*.xml 69 | share/css/*.css 70 | share/pixmaps/*.png 71 | share/locale/*/LC_MESSAGES/pympress.mo 72 | 73 | [options.entry_points] 74 | gui_scripts = 75 | pympress = pympress.__main__:main 76 | 77 | [style] 78 | based_on_style = pep8 79 | column_limit = 120 80 | split_complex_comprehension = on 81 | split_penalty_comprehension = 5000 82 | split_penalty_excess_character = 40 83 | use_tabs = off 84 | indent_width = 4 85 | 86 | [flake8] 87 | docstring_convention = google 88 | max_line_length = 120 89 | builtins = _, unicode, tags 90 | exclude = 91 | .git 92 | .eggs 93 | __pycache__ 94 | build/ 95 | dist/ 96 | 97 | ignore = 98 | # never complain about those 99 | D107, D200, D210, D413, E251, E302, E303, W504, 100 | # allow sometimes, e.g. aligning code etc. 101 | D205, D212, D415, E201, E221, E241, E266, E301, E402, E701, E731 102 | 103 | per_file_ignores = 104 | # do not complain about dummy functions 105 | pympress/media_overlays/gif_backend.py: D102, E704 106 | pympress/__main__.py: F401 107 | docs/conf.py: F401 108 | 109 | [extract_messages] 110 | no_location = true 111 | no_wrap = true 112 | sort_output = true 113 | omit_header = true 114 | output_file = pympress/share/locale/pympress.pot 115 | mapping_file = pympress/share/locale/babel_mapping.cfg 116 | 117 | [compile_catalog] 118 | domain = pympress 119 | directory = pympress/share/locale/ 120 | use_fuzzy = false 121 | statistics = true 122 | 123 | [pysrpm] 124 | flavour = pympress 125 | extract_dependencies = no 126 | requires = gobject-introspection %%{py3_dist watchdog} 127 | 128 | typelib_deps = typelib(cairo) typelib(GLib) typelib(DBus) typelib(DBusGLib) typelib(GObject) typelib(Gdk) typelib(GdkPixbuf) typelib(Gio) typelib(Gtk) typelib(Poppler) typelib(Gst) typelib(GstAllocators) typelib(GstApp) typelib(GstAudio) typelib(GstVideo) typelib(GstGL) 129 | typelib_recommends = typelib(GstMpegts) typelib(GstWebRTC) typelib(GstBadAudio) typelib(GstCodecs) 130 | 131 | requires_suse = gtk3 libpoppler-glib8 libgdk_pixbuf-2_0-0 gstreamer gstreamer-plugins-base gstreamer-plugins-good gstreamer-plugins-good-gtk libgstvideo-1_0-0 132 | recommends_suse = gstreamer-plugins-ugly gstreamer-plugins-bad 133 | 134 | requires_mandriva-mga = gtk+3.0 (libpoppler-glib8 or lib64poppler-glib8) (libgdk_pixbuf2.0 or libgdk_pixbuf2.0_0) libgstreamer1.0 gstreamer1.0-plugins-base gstreamer1.0-plugins-good 135 | recommends_mandriva-mga = gstreamer1.0-plugins-ugly gstreamer1.0-plugins-bad 136 | 137 | requires_fedora-centos = gtk3 poppler-glib gdk-pixbuf2 gstreamer1 gstreamer1-plugins-base gstreamer1-plugins-good gstreamer1-plugins-good-gtk gstreamer1-plugins-bad-free gstreamer1-plugins-ugly-free 138 | recommends_fedora-centos = gstreamer1-plugins-good-extras gstreamer1-plugins-bad-free-extras gstreamer1-plugins-ugly gstreamer1-plugins-bad-free 139 | 140 | [pysrpm.pympress] 141 | preamble = 142 | %%define normalize() %%(echo %%* | tr "[:upper:]_ " "[:lower:]--") 143 | %%{{?!py3_dist:%%define py3_dist() (python%%{{python3_version}}dist(%%{{normalize %%1}}) or python3-%%1)}} 144 | 145 | ${base:preamble} 146 | %%if %%{{?!rhel:8}}%%{{?rhel}} >= 8 147 | Requires: (%%{{py3_dist pygobject}} or python3-gobject) 148 | %%else 149 | Requires: python3%%{{suffix:%%{{python3_version}}}}-gobject 150 | %%endif 151 | %%if 0%%{{?suse_version}} 152 | Requires: ${requires_suse} 153 | Recommends: ${recommends_suse} 154 | %%endif 155 | %%if 0%%{{?mdkversion}}%%{{?mga_version}} 156 | Requires: ${requires_mandriva-mga} 157 | Recommends: ${recommends_mandriva-mga} 158 | %%endif 159 | %%if 0%%{{?fedora}}%%{{?centos_version}}%%{{?scientificlinux_version}}%%{{?rhel}} 160 | Requires: ${requires_fedora-centos} 161 | %%if %%{{?!rhel:8}}%%{{?rhel}} >= 8 162 | Recommends: ${recommends_fedora-centos} 163 | %%endif 164 | %%endif 165 | %%if 0%%{{?suse_version}}%%{{?mga_version}}%%{{?mdkversion}} 166 | Requires: ${typelib_deps} 167 | Recommends: ${typelib_recommends} 168 | %%endif 169 | 170 | install = ${base:install} 171 | # Add “well-know” name to files searched 172 | find "%%{{buildroot}}%%{{_prefix}}" -wholename "%%{{buildroot}}%%{{python3_sitelib}}" -prune -o -name 'io.github.pympress.*' -printf '%%{{_prefix}}/%%%%P\n' -prune >> INSTALLED_FILES 173 | 174 | post = ${base:post} 175 | if [ $$1 -gt 1 ]; then 176 | # On update, remove directories incorrectly left behind by previous versions 177 | find "%%{{python3_sitelib}}" -maxdepth 1 -name 'pympress-1.5.*' -print0 | xargs -0 --no-run-if-empty rm -r 178 | fi 179 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # setup.py 4 | # 5 | # Copyright 2009 Thomas Jost 6 | # Copyright 2015 Cimbali 7 | # 8 | # This program is free software; you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License as published by 10 | # the Free Software Foundation; either version 2 of the License, or 11 | # (at your option) any later version. 12 | # 13 | # This program is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU General Public License for more details. 17 | # 18 | # You should have received a copy of the GNU General Public License 19 | # along with this program; if not, write to the Free Software 20 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, 21 | # MA 02110-1301, USA. 22 | 23 | """ pympress setup script. 24 | 25 | Mostly wrapping logic for freezing (with cx_Freeze for windows builds). 26 | All configuration is in setup.cfg. 27 | """ 28 | 29 | import os 30 | import re 31 | import sys 32 | import pathlib 33 | import subprocess 34 | from ctypes.util import find_library 35 | import setuptools 36 | 37 | from setuptools import Command 38 | from setuptools.command.build_py import build_py 39 | 40 | 41 | def find_index_startstring(haystack, needle, start=0, stop=sys.maxsize): 42 | """ Return the index of the first string in haystack starting with needle, or raise ValueError if none match. 43 | """ 44 | try: 45 | return next(n for n, v in enumerate(haystack[start:stop], start) if v.startswith(needle)) 46 | except StopIteration: 47 | raise ValueError('No string starts with ' + needle) 48 | 49 | 50 | class GettextBuildCatalog(Command): 51 | """ Patched build command to generate translations .mo files using gettext’s msgfmt 52 | 53 | This is used for build systems that do not have easy access to Babel 54 | """ 55 | user_options = [ 56 | ('domain=', 'D', "domains of PO files (space separated list, default 'messages')"), 57 | ('directory=', 'd', 'path to base directory containing the catalogs'), 58 | ('use-fuzzy', 'f', 'also include fuzzy translations'), 59 | ('statistics', None, 'print statistics about translations') 60 | ] 61 | 62 | def initialize_options(self): 63 | """ Initialize options 64 | """ 65 | self.domain = None 66 | self.directory = None 67 | self.use_fuzzy = False 68 | self.statistics = True 69 | 70 | 71 | def finalize_options(self): 72 | """ Finalize options 73 | """ 74 | assert self.domain is not None and self.directory is not None 75 | 76 | 77 | def run(self): 78 | """ Run msgfmt before running (parent) develop command 79 | """ 80 | po_wildcard = pathlib.Path(self.directory).glob(str(pathlib.Path('*', 'LC_MESSAGES', self.domain + '.po'))) 81 | for po in po_wildcard: 82 | print(po) 83 | mo = po.with_suffix('.mo') 84 | 85 | cmd = ['msgfmt', str(po), '-o', str(mo)] 86 | if self.use_fuzzy: 87 | cmd.insert(1, '--use-fuzzy') 88 | if self.statistics: 89 | cmd.insert(1, '--statistics') 90 | 91 | subprocess.check_output(cmd) 92 | 93 | 94 | 95 | class BuildWithCatalogs(build_py): 96 | """ Patched build command to generate translations .mo files using Babel 97 | 98 | This is what we use by default, e.g. when distributing through PyPI 99 | """ 100 | def run(self): 101 | """ Run compile_catalog before running (parent) develop command 102 | """ 103 | try: 104 | self.distribution.run_command('compile_catalog') 105 | except Exception as err: 106 | if err.args == ('no message catalogs found',): 107 | pass # Running from a source tarball − compiling already done 108 | else: 109 | raise 110 | build_py.run(self) 111 | 112 | 113 | # All functions listing resources return a list of pairs: (system path, distribution relative path) 114 | def gtk_resources(): 115 | """ Returns a list of the non-DLL Gtk resources to include in a frozen/binary package. 116 | """ 117 | include_path = pathlib.Path(find_library('libgtk-3-0')).parent 118 | include_path = include_path.parent if include_path.name in {'bin', 'lib', 'lib64'} else include_path 119 | 120 | include_files = [] 121 | resources = [ 122 | pathlib.Path('etc'), 123 | pathlib.Path('lib', 'girepository-1.0'), 124 | pathlib.Path('lib', 'gtk-3.0'), 125 | pathlib.Path('lib', 'gdk-pixbuf-2.0'), 126 | pathlib.Path('share', 'poppler'), 127 | pathlib.Path('share', 'themes'), 128 | pathlib.Path('share', 'fonts'), 129 | pathlib.Path('share', 'icons'), 130 | pathlib.Path('share', 'glib-2.0'), 131 | pathlib.Path('share', 'xml') 132 | ] 133 | 134 | for f in resources: 135 | p = include_path.joinpath(f) 136 | if p.exists(): 137 | include_files.append((str(p), str(f))) 138 | else: 139 | print('WARNING: Can not find {} (at {})'.format(f, p)) 140 | 141 | return include_files 142 | 143 | 144 | def dlls(): 145 | """ Returns a list of all DLL files we need to include, in a frozen/binary package on windows. 146 | 147 | Relies on a hardcoded list tested for the appveyor build setup. 148 | """ 149 | if os.name != 'nt': 150 | return [] 151 | 152 | libs = 'libatk-1.0-0.dll libbrotlicommon.dll libbrotlidec.dll libcurl-4.dll libdatrie-1.dll \ 153 | libepoxy-0.dll libfribidi-0.dll libgdk-3-0.dll libgdk_pixbuf-2.0-0.dll libgif-7.dll \ 154 | libgio-2.0-0.dll libgirepository-1.0-1.dll libglib-2.0-0.dll libgobject-2.0-0.dll libgtk-3-0.dll \ 155 | libidn2-0.dll libjpeg-8.dll liblcms2-2.dll libnghttp2-14.dll libnspr4.dll libopenjp2-7.dll \ 156 | libpango-1.0-0.dll libpangocairo-1.0-0.dll libpangoft2-1.0-0.dll libpangowin32-1.0-0.dll \ 157 | libplc4.dll libplds4.dll libpoppler-105.dll libpoppler-cpp-0.dll libpoppler-glib-8.dll libpsl-5.dll \ 158 | libpython{0.major}.{0.minor}.dll libstdc++-6.dll libthai-0.dll libtiff-5.dll libunistring-2.dll \ 159 | libwinpthread-1.dll libzstd.dll nss3.dll nssutil3.dll smime3.dll'.format(sys.version_info) 160 | # these appear superfluous, though unexpectedly so: 161 | # libcairo-2.dll libcairo-gobject-2.dll libfontconfig-1.dll libfreetype-6.dll libiconv-2.dll 162 | # libgettextlib-0-19-8-1.dll libgettextpo-0.dll libgettextsrc-0-19-8-1.dll libintl-8.dll libjasper-4.dll 163 | 164 | lib_gtk_dir = pathlib.Path(find_library('libgtk-3-0')).parent 165 | 166 | gdbus = pathlib.Path(find_library('gdbus.exe')) 167 | include_files = [(str(gdbus), str(pathlib.Path('lib', 'gi', 'gdbus.exe'))), (str(gdbus), 'gdbus.exe')] 168 | for lib in libs.split(): 169 | path = find_library(lib) 170 | path = pathlib.Path(path) if path is not None else path 171 | if path is not None and path.exists(): 172 | include_files.append((str(path), lib)) 173 | else: 174 | lib = pathlib.Path(lib) 175 | # Look in other directories? 176 | for path in lib_gtk_dir.glob(re.sub('-[0-9.]*$', '-*', lib.stem) + lib.suffix): 177 | include_files.append((str(path), path.name)) 178 | print('WARNING: Can not find library {}, including {} instead'.format(lib, path.name)) 179 | else: 180 | print('WARNING: Can not find library {}'.format(lib)) 181 | 182 | return include_files 183 | 184 | 185 | def check_cli_arg(val): 186 | """ Check whether an argument was passed, and clear it from sys.argv 187 | 188 | Returns (bool): whether the argument was present 189 | """ 190 | if val in sys.argv[1:]: 191 | sys.argv.remove(val) 192 | return True 193 | 194 | return False 195 | 196 | 197 | def pympress_resources(): 198 | """ Return pympress resources. Only for frozen packages, as this is redundant with package_data. 199 | """ 200 | share = pathlib.Path('pympress', 'share') 201 | dirs = [share.joinpath('xml'), share.joinpath('pixmaps'), share.joinpath('css'), share.joinpath('defaults.conf')] 202 | translations = share.glob(str(pathlib.Path('*', 'LC_MESSAGES', 'pympress.mo'))) 203 | 204 | return [(str(f), str(f.relative_to('pympress'))) for f in dirs + list(translations)] 205 | 206 | 207 | if __name__ == '__main__': 208 | 209 | try: 210 | from babel.messages.frontend import compile_catalog 211 | except ImportError: 212 | compile_catalog = GettextBuildCatalog 213 | 214 | options = {'cmdclass': { 215 | 'build_py': BuildWithCatalogs, 216 | 'compile_catalog': compile_catalog, 217 | }} 218 | 219 | # subtle tweak: don’t put an install section in installed packages 220 | with open('README.md', encoding='utf-8') as f: 221 | readme = f.readlines() 222 | 223 | install_section = find_index_startstring(readme, '# Install') 224 | next_section = find_index_startstring(readme, '# ', install_section + 1) 225 | del readme[install_section:next_section] 226 | 227 | options['long_description'] = ''.join(readme) 228 | 229 | 230 | # Check whether to create a frozen distribution 231 | if check_cli_arg('--freeze'): 232 | print('Using cx_Freeze.setup():', file=sys.stderr) 233 | from cx_Freeze import setup, Executable 234 | 235 | setup(**{ 236 | **options, 237 | 'options': { 238 | 'build_exe': { 239 | 'includes': [], 240 | 'excludes': ['tkinter'], 241 | 'packages': ['codecs', 'gi', 'vlc', 'watchdog'], 242 | 'include_files': gtk_resources() + dlls() + pympress_resources(), 243 | 'silent': True 244 | }, 245 | 'bdist_msi': { 246 | 'add_to_path': True, 247 | 'all_users': False, 248 | 'summary_data': { 249 | 'comments': 'https://github.com/Cimbali/pympress/', 250 | 'keywords': 'pdf-viewer, beamer, presenter, slide, projector, pdf-reader, \ 251 | presentation, python, poppler, gtk, pygi, vlc', 252 | }, 253 | 'upgrade_code': '{5D156784-ED69-49FF-A972-CBAD312187F7}', 254 | 'install_icon': str(pathlib.Path('pympress', 'share', 'pixmaps', 'pympress.ico')), 255 | 'extensions': [{ 256 | 'extension': 'pdf', 257 | 'verb': 'open', 258 | 'executable': 'pympress-gui.exe', 259 | 'argument': '"%1"', 260 | 'mime': 'application/pdf', 261 | 'context': 'Open with p&ympress', 262 | }], 263 | } 264 | }, 265 | 'executables': [ 266 | Executable(str(pathlib.Path('pympress', '__main__.py')), target_name='pympress-gui.exe', 267 | base='Win32GUI', shortcut_dir='ProgramMenuFolder', shortcut_name='pympress', 268 | icon=str(pathlib.Path('pympress', 'share', 'pixmaps', 'pympress.ico'))), 269 | Executable(str(pathlib.Path('pympress', '__main__.py')), target_name='pympress.exe', 270 | base='Console', icon=str(pathlib.Path('pympress', 'share', 'pixmaps', 'pympress.ico'))), 271 | ] 272 | }) 273 | else: 274 | # Normal behaviour: use setuptools, load options from setup.cfg 275 | print('Using setuptools.setup():', file=sys.stderr) 276 | 277 | setuptools_version = tuple(int(n) for n in setuptools.__version__.split('.')[:2]) 278 | # older versions are missing out! 279 | if setuptools_version >= (30, 5): 280 | options['data_files'] = [ 281 | ('share/pixmaps/', ['pympress/share/pixmaps/pympress.png']), 282 | ('share/applications/', ['pympress/share/applications/io.github.pympress.desktop']), 283 | ] 284 | 285 | setuptools.setup(**options) 286 | 287 | 288 | ## 289 | # Local Variables: 290 | # mode: python 291 | # indent-tabs-mode: nil 292 | # py-indent-offset: 4 293 | # fill-column: 80 294 | # end: 295 | --------------------------------------------------------------------------------