├── .github └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── examples ├── fzfimg.sh └── mastodon.sh ├── meson.build ├── pyproject.toml └── ueberzug ├── X ├── X.c ├── X.h ├── Xshm.c ├── Xshm.h ├── display.c ├── display.h ├── math.h ├── python.h ├── util.h ├── window.c └── window.h ├── __init__.py ├── __main__.py ├── action.py ├── batch.py ├── conversion.py ├── files.py ├── geometry.py ├── layer.py ├── lib ├── __init__.py ├── lib.sh └── v0 │ └── __init__.py ├── library.py ├── loading.py ├── parser.py ├── pattern.py ├── process.py ├── query_windows.py ├── scaling.py ├── terminal.py ├── thread.py ├── tmux_util.py ├── ui.py ├── version.py └── xutil.py /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Wheel builder 2 | 3 | on: 4 | release: 5 | types: [published,edited] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | if: contains(github.event.release.body, '') 11 | strategy: 12 | fail-fast: true 13 | matrix: 14 | python-version: [3.7, 3.8, 3.9] 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Creating environment variables 23 | run: | 24 | github_tag="${GITHUB_REF#refs/tags/}" 25 | echo "GITHUB_TAG=${github_tag}" >> "$GITHUB_ENV" 26 | echo "GITHUB_RELEASE_API_URL=${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/${github_tag}" >> "$GITHUB_ENV" 27 | echo "GITHUB_TAG_FOLDER=tag_data" >> "$GITHUB_ENV" 28 | - name: Install dependencies 29 | run: | 30 | sudo apt install -y libx11-dev libxext-dev libxres-dev jq 31 | python -m pip install --upgrade pip 32 | python -m pip install --upgrade wheel 33 | - name: Download release and extracting 34 | run: | 35 | code_archive_url="$(curl --header "Accept: application/vnd.github.v3+json" "${GITHUB_RELEASE_API_URL}" | jq --raw-output .zipball_url)" 36 | echo downloading "${code_archive_url}" 37 | wget --output-document="release.zip" "${code_archive_url}" 38 | # the folder name in the archive may change 39 | # so create a new folder so we can use globs 40 | # without having to fear the existence of other folders 41 | mkdir "${GITHUB_TAG_FOLDER}" 42 | cd "${GITHUB_TAG_FOLDER}" 43 | unzip ../release.zip 44 | - name: building 45 | run: | 46 | cd "${GITHUB_TAG_FOLDER}"/*/ 47 | python setup.py bdist_wheel 48 | - name: uploading 49 | run: | 50 | upload_url="$(curl --header "Accept: application/vnd.github.v3+json" "${GITHUB_RELEASE_API_URL}" | jq --raw-output .upload_url)" 51 | upload_url="${upload_url%{*}" 52 | 53 | cd "${GITHUB_TAG_FOLDER}"/*/ 54 | for filename in dist/*; do 55 | echo uploading "${filename}" 56 | filename_encoded="$(printf '%s' "${filename#*/}" | jq --slurp --raw-input --raw-output @uri)" 57 | echo encoded filename "${filename_encoded}" 58 | curl --request POST \ 59 | --header "Accept: application/vnd.github.v3+json" \ 60 | --header 'authorization: Bearer ${{ secrets.GITHUB_TOKEN }}' \ 61 | --header "Content-Type: application/octet-stream" \ 62 | --data-binary @"${filename}" \ 63 | "${upload_url}?name=${filename_encoded}" 64 | done 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # other 107 | il.tar 108 | test.sh 109 | test.sh.bak 110 | 111 | !ueberzug/lib/ 112 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include ueberzug/lib/lib.sh 2 | include ueberzug/X/*.h 3 | include ueberzug/X/*.c 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Überzug 2 | 3 | Überzug is a command line util 4 | which allows one to draw images on terminals by using child windows. 5 | 6 | Advantages over the w3mimgdisplay program: 7 | - no race conditions as a new window is created to display images 8 | - expose events will be processed, 9 | so images will be redrawn on switch workspaces 10 | - tmux support (excluding multi pane windows) 11 | - terminals without the WINDOWID environment variable are supported 12 | - chars are used as position - and size unit 13 | - no memory leak (/ unlimited cache) 14 | 15 | ## Overview 16 | 17 | - [Dependencies](#dependencies) 18 | - [Installation](#installation) 19 | - [Communication](#communication) 20 | * [Command formats](#command-formats) 21 | * [Actions](#actions) 22 | + [Add](#add) 23 | + [Remove](#remove) 24 | * [Libraries](#libraries) 25 | + [~~Bash~~ (deprecated)](#bash) 26 | + [Python](#python) 27 | * [Examples](#examples) 28 | 29 | 30 | ## Dependencies 31 | 32 | Libraries used in the c extension: 33 | 34 | - python 35 | - X11 36 | - Xext 37 | - XRes 38 | 39 | There are also other direct dependencies, 40 | but they will be installed by pip. 41 | 42 | ## Installation 43 | 44 | - Install by using pip: 45 | The package is named `ueberzug` 46 | - Linux distribution packages: 47 | ueberzug is packaged on most popular distros debian, arch, fedora, gentoo 48 | and void, please request the package maintainer of your favourite distro to 49 | package the latest release of ueberzug. 50 | 51 | - Install from source example: 52 | ```sh 53 | python -m build 54 | pip3 install dist/ueberzug-18.3.0-cp312-cp312-linux_x86_64.whl 55 | ``` 56 | - NOTICE TO PACKAGE MAINTAINERS: 57 | we are now using mesonpy to build the C extensions, so be sure to add: 58 | - meson 59 | - meson-python 60 | 61 | As build time dependencies to your build dependencies definition, also adjust your build 62 | scripts accordingly as the `python -m build` will produce a wheel that is correctly 63 | linked to the current cpython version, i.e. for debian testing/unstable 64 | `cpython-312-x86_64-linux-gnu.so` at the time of writing which will lend a wheel with the 65 | name `ueberzug-18.3.0-cp312-cp312-linux_x86_64.whl` 66 | 67 | [![Packaging status](https://repology.org/badge/vertical-allrepos/ueberzug.svg)](https://repology.org/project/ueberzug/versions) 68 | 69 | Original author rant: 70 | At least one packager applies patches to my code. 71 | So if there are issues uninstall it and install it via pip. 72 | Actually I think it's not fine that they call it ueberzug after changing the code. 73 | As bugs introduced by them look like they are part of my work. 74 | 75 | Note: You can improve the performance of image manipulation functions 76 | by using [pillow-simd](https://github.com/uploadcare/pillow-simd) instead of pillow. 77 | 78 | ## Communication 79 | 80 | The communication is realised via stdin. 81 | A command is a request to execute a specific action with the passed arguments. 82 | (Therefore a command has to contain a key value pair "action": action_name) 83 | Commands are separated with a line break. 84 | 85 | ### Command formats 86 | 87 | - json: Command as json object 88 | - simple: 89 | Key-value pairs separated by a tab, 90 | pairs are also separated by a tab 91 | **:warning: ONLY FOR TESTING!** 92 | Simple was never intended for the usage in production! 93 | It doesn't support paths containing tabs or line breaks 94 | which makes it error prone. 95 | - bash: dump of an associative array (`declare -p variable_name`) 96 | 97 | ### Actions 98 | 99 | #### Add 100 | 101 | Name: add 102 | Description: 103 | Adds an image to the screen. 104 | If there's already an image with the same identifier 105 | it will be replaced. 106 | 107 | | Key | Type | Description | Optional | 108 | |---------------|--------------|--------------------------------------------------------------------|----------| 109 | | identifier | String | a freely chosen identifier of the image | No | 110 | | x | Integer | x position | No | 111 | | y | Integer | y position | No | 112 | | path | String | path to the image | No | 113 | | width | Integer | desired width; original width will be used if not set | Yes | 114 | | height | Integer | desired height; original width will be used if not set | Yes | 115 | | ~~max_width~~ | Integer | **Deprecated: replaced by scalers (this behavior is implemented by the default scaler contain)**
image will be resized (while keeping it's aspect ratio) if it's width is bigger than max width | Yes | image width | 116 | | ~~max_height~~| Integer | **Deprecated: replaced by scalers (this behavior is implemented by the default scaler contain)**
image will be resized (while keeping it's aspect ratio) if it's height is bigger than max height | Yes | image height | 117 | | draw | Boolean | redraw window after adding the image, default True | Yes | True | 118 | | synchronously_draw | Boolean | redraw window immediately | Yes | False | 119 | | scaler | String | name of the image scaler
(algorithm which resizes the image to fit into the placement) | Yes | contain | 120 | | scaling_position_x | Float | the centered position, if possible
Specified as factor of the image size,
so it should be an element of [0, 1]. | Yes | 0 | 121 | | scaling_position_y | Float | analogous to scaling_position_x | Yes | 0 | 122 | 123 | 124 | ImageScalers: 125 | 126 | | Name | Description | 127 | |---------------|----------------------------------------------------------------------------------| 128 | | crop | Crops out an area of the size of the placement size. | 129 | | distort | Distorts the image to the placement size. | 130 | | fit_contain | Resizes the image that either the width matches the maximum width or the height matches the maximum height while keeping the image ratio. | 131 | | contain | Resizes the image to a size <= the placement size while keeping the image ratio. | 132 | | forced_cover | Resizes the image to cover the entire area which should be filled
while keeping the image ratio.
If the image is smaller than the desired size
it will be stretched to reach the desired size.
If the ratio of the area differs
from the image ratio the edges will be cut off. | 133 | | cover | The same as forced_cover but images won't be stretched
if they are smaller than the area which should be filled. | 134 | 135 | #### Remove 136 | 137 | Name: remove 138 | Description: 139 | Removes an image from the screen. 140 | 141 | | Key | Type | Description | Optional | 142 | |---------------|--------------|--------------------------------------------------------------------|----------| 143 | | identifier | String | a previously used identifier | No | 144 | | draw | Boolean | redraw window after removing the image, default True | Yes | 145 | 146 | 147 | ### Libraries 148 | 149 | Just a reminder: This is a GPLv3 licensed project, so if you use any of these libraries you also need to license it with a GPLv3 compatible license. 150 | 151 | #### Bash 152 | 153 | The library is deprecated. 154 | Dump associative arrays if you want to use ueberzug with bash. 155 | 156 | ~~First of all the library doesn't follow the posix standard, 157 | so you can't use it in any other shell than bash.~~ 158 | 159 | ~~Executing `ueberzug library` will print the path to the library to stdout.~~ 160 | 161 | ~~**Functions**~~: 162 | 163 | - ~~`ImageLayer` starts the ueberzug process and uses bashs associative arrays to transfer commands.~~ 164 | - ~~Also there will be a function named `ImageLayer::{action_name}` declared for each action.~~ 165 | ~~Each of this function takes the key values pairs of the respective action as arguments.~~ 166 | ~~Every argument of these functions has to be an associative key value pair.~~ 167 | ~~`ImageLayer::{action_name} [{key0}]="{value0}" [{key1}]="{value1}" ...`~~ 168 | ~~Executing such a function builds the desired command string according to the passed arguments and prints it to stdout.~~ 169 | 170 | #### Python 171 | 172 | First of all everything which isn't mentioned here isn't safe to use and 173 | won't necessarily shipped with new coming versions. 174 | 175 | The library is included in ueberzug's package. 176 | ```python 177 | import ueberzug.lib.v0 as ueberzug 178 | ``` 179 | 180 | **Classes**: 181 | 182 | 1. Visibility: 183 | An enum which contains the visibility states of a placement. 184 | 185 | - VISIBLE 186 | - INVISIBLE 187 | 2. Placement: 188 | A placement to put images on. 189 | 190 | Every key value pair of the add action is an attribute (except identifier). 191 | Changing one of it will lead to building and transmitting an add command *if the placement is visible*. 192 | The identifier key value pair is implemented as a property and not changeable. 193 | 194 | Constructor: 195 | 196 | | Name | Type | Optional | Description | 197 | |---------------|--------------|----------|------------------------------------------------| 198 | | canvas | Canvas | No | the canvas where images should be drawn on | 199 | | identifier | String | No | a unique string used to address this placement | 200 | | visibility | Visibility | Yes | the initial visibility state
(if it's VISIBLE every attribute without a default value needs to be set) | 201 | | \*\*kwargs | dict | Yes | key value pairs of the add action | 202 | 203 | Properties: 204 | 205 | | Name | Type | Setter | Description | 206 | |---------------|--------------|--------|--------------------------------------| 207 | | identifier | String | No | the identifier of this placement | 208 | | canvas | Canvas | No | the canvas this placement belongs to | 209 | | visibility | Visibility | Yes | the visibility state of this placement
- setting it to VISIBLE leads to the transmission of an add command
- setting it to INVISIBLE leads to the transmission of a remove command | 210 | 211 | **Warning**: 212 | The transmission of a command can lead to an IOError. 213 | (A transmission happens on assign a new value to an attribute of a visible Placement. 214 | The transmission is delayed till leaving a with-statement if lazy_drawing is used.) 215 | 3. ScalerOption: 216 | Enum which contains the useable scaler names. 217 | 4. Canvas: 218 | Should either be used with a with-statement or with a decorated function. 219 | (Starts and stops the ueberzug process) 220 | 221 | Constructor: 222 | 223 | | Name | Type | default | Description | 224 | |---------------|--------------|----------|------------------------------------------------| 225 | | debug | bool | False | suppresses printing stderr if it's false | 226 | 227 | Methods: 228 | 229 | | Name | Returns | Description | 230 | |----------------------|--------------|--------------------------------------| 231 | | create_placement | Placement | prevents the use of the same identifier multiple times,
takes the same arguments as the Placement constructor (excluding canvas parameter) | 232 | | \_\_call\_\_ | Function | Decorator which returns a function which calls the decorated function with the keyword parameter canvas=this_canvas_object.
Of course other arguments are also passed through. | 233 | | request_transmission | - | Transmits queued commands if automatic\_transmission is enabled or force=True is passed as keyword argument. | 234 | 235 | Properties / Attributes: 236 | 237 | | Name | Type | Setter | Description | 238 | |---------------|-------------------------|--------|--------------------------------------| 239 | | lazy_drawing | context manager factory | No | prevents the transmission of commands till the with-statement was left
`with canvas.lazy_drawing: pass`| 240 | | synchronous_lazy_drawing | context manager factory | No | Does the same as lazy_drawing. Additionally forces the redrawing of the windows to happen immediately. | 241 | | automatic\_transmission | bool | Yes | Transmit commands instantly on changing a placement. If it's disabled commands won't be transmitted till a lazy_drawing or synchronous_lazy_drawing with-statement was left or request_transmission(force=True) was called. Default: True | 242 | 243 | 244 | 245 | 246 | ### Examples 247 | 248 | Command formats: 249 | 250 | - Json command format: `{"action": "add", "x": 0, "y": 0, "path": "/some/path/some_image.jpg"}` 251 | - Simple command format: `action add x 0 y 0 path /some/path/some_image.jpg` 252 | - Bash command format: `declare -A command=([path]="/some/path/some_image.jpg" [action]="add" [x]="0" [y]="0" )` 253 | 254 | Bash: 255 | 256 | ```bash 257 | # process substitution example: 258 | ueberzug layer --parser bash 0< <( 259 | declare -Ap add_command=([action]="add" [identifier]="example0" [x]="0" [y]="0" [path]="/some/path/some_image0.jpg") 260 | declare -Ap add_command=([action]="add" [identifier]="example1" [x]="10" [y]="0" [path]="/some/path/some_image1.jpg") 261 | sleep 5 262 | declare -Ap remove_command=([action]="remove" [identifier]="example0") 263 | sleep 5 264 | ) 265 | 266 | # group commands example: 267 | { 268 | declare -Ap add_command=([action]="add" [identifier]="example0" [x]="0" [y]="0" [path]="/some/path/some_image0.jpg") 269 | declare -Ap add_command=([action]="add" [identifier]="example1" [x]="10" [y]="0" [path]="/some/path/some_image1.jpg") 270 | read 271 | declare -Ap remove_command=([action]="remove" [identifier]="example0") 272 | read 273 | } | ueberzug layer --parser bash 274 | ``` 275 | 276 | Python library: 277 | 278 | - curses (based on https://docs.python.org/3/howto/curses.html#user-input): 279 | ```python 280 | import curses 281 | import time 282 | from curses.textpad import Textbox, rectangle 283 | import ueberzug.lib.v0 as ueberzug 284 | 285 | 286 | @ueberzug.Canvas() 287 | def main(stdscr, canvas): 288 | demo = canvas.create_placement('demo', x=10, y=0) 289 | stdscr.addstr(0, 0, "Enter IM message: (hit Ctrl-G to send)") 290 | 291 | editwin = curses.newwin(5, 30, 3, 1) 292 | rectangle(stdscr, 2, 0, 2+5+1, 2+30+1) 293 | stdscr.refresh() 294 | 295 | box = Textbox(editwin) 296 | 297 | # Let the user edit until Ctrl-G is struck. 298 | box.edit() 299 | 300 | # Get resulting contents 301 | message = box.gather() 302 | demo.path = ''.join(message.split()) 303 | demo.visibility = ueberzug.Visibility.VISIBLE 304 | time.sleep(2) 305 | 306 | 307 | if __name__ == '__main__': 308 | curses.wrapper(main) 309 | ``` 310 | 311 | - general example: 312 | ```python 313 | import ueberzug.lib.v0 as ueberzug 314 | import time 315 | 316 | if __name__ == '__main__': 317 | with ueberzug.Canvas() as c: 318 | paths = ['/some/path/some_image.png', '/some/path/another_image.png'] 319 | demo = c.create_placement('demo', x=0, y=0, scaler=ueberzug.ScalerOption.COVER.value) 320 | demo.path = paths[0] 321 | demo.visibility = ueberzug.Visibility.VISIBLE 322 | 323 | for i in range(30): 324 | with c.lazy_drawing: 325 | demo.x = i * 3 326 | demo.y = i * 3 327 | demo.path = paths[i % 2] 328 | time.sleep(1/30) 329 | 330 | time.sleep(2) 331 | ``` 332 | 333 | Scripts: 334 | 335 | - fzf with image preview: https://github.com/ueber-devel/ueberzug/blob/master/examples/fzfimg.sh 336 | - Mastodon viewer: https://github.com/ueber-devel/ueberzug/blob/master/examples/mastodon.sh 337 | 338 | 339 | Similar projects: 340 | 341 | - Überzug implementation in c++: https://github.com/jstkdng/ueberzugpp 342 | -------------------------------------------------------------------------------- /examples/fzfimg.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # This is just an example how ueberzug can be used with fzf. 3 | 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program. If not, see . 16 | readonly BASH_BINARY="$(which bash)" 17 | readonly REDRAW_COMMAND="toggle-preview+toggle-preview" 18 | readonly REDRAW_KEY="µ" 19 | declare -r -x DEFAULT_PREVIEW_POSITION="right" 20 | declare -r -x UEBERZUG_FIFO="$(mktemp --dry-run --suffix "fzf-$$-ueberzug")" 21 | declare -r -x PREVIEW_ID="preview" 22 | 23 | 24 | function is_option_key [[ "${@}" =~ ^(\-.*|\+.*) ]] 25 | function is_key_value [[ "${@}" == *=* ]] 26 | 27 | 28 | function map_options { 29 | local -n options="${1}" 30 | local -n options_map="${2}" 31 | 32 | for ((i=0; i < ${#options[@]}; i++)); do 33 | local key="${options[$i]}" next_key="${options[$((i + 1))]:---}" 34 | local value=true 35 | is_option_key "${key}" || \ 36 | continue 37 | if is_key_value "${key}"; then 38 | <<<"${key}" \ 39 | IFS='=' read key value 40 | elif ! is_option_key "${next_key}"; then 41 | value="${next_key}" 42 | fi 43 | options_map["${key}"]="${value}" 44 | done 45 | } 46 | 47 | 48 | function parse_options { 49 | declare -g -a script_options=("${@}") 50 | declare -g -A mapped_options 51 | map_options script_options mapped_options 52 | declare -g -r -x PREVIEW_POSITION="${mapped_options[--preview-window]%%:[^:]*}" 53 | } 54 | 55 | 56 | function start_ueberzug { 57 | mkfifo "${UEBERZUG_FIFO}" 58 | <"${UEBERZUG_FIFO}" \ 59 | ueberzug layer --parser bash --silent & 60 | # prevent EOF 61 | 3>"${UEBERZUG_FIFO}" \ 62 | exec 63 | } 64 | 65 | 66 | function finalise { 67 | 3>&- \ 68 | exec 69 | &>/dev/null \ 70 | rm "${UEBERZUG_FIFO}" 71 | &>/dev/null \ 72 | kill $(jobs -p) 73 | } 74 | 75 | 76 | function calculate_position { 77 | # TODO costs: creating processes > reading files 78 | # so.. maybe we should store the terminal size in a temporary file 79 | # on receiving SIGWINCH 80 | # (in this case we will also need to use perl or something else 81 | # as bash won't execute traps if a command is running) 82 | < <("${UEBERZUG_FIFO}" declare -A -p cmd=( \ 106 | [action]=add [identifier]="${PREVIEW_ID}" \ 107 | [x]="${X}" [y]="${Y}" \ 108 | [width]="${COLUMNS}" [height]="${LINES}" \ 109 | [scaler]=forced_cover [scaling_position_x]=0.5 [scaling_position_y]=0.5 \ 110 | [path]="${@}") 111 | # add [synchronously_draw]=True if you want to see each change 112 | } 113 | 114 | 115 | function print_on_winch { 116 | # print "$@" to stdin on receiving SIGWINCH 117 | # use exec as we will only kill direct childs on exiting, 118 | # also the additional bash process isn't needed 119 | . 16 | source "`ueberzug library`" 17 | 18 | readonly USER_AGENT='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36' 19 | declare -g target_host 20 | 21 | function unpack { 22 | local tmp="${@/[^(]*\(}" 23 | echo -n "${tmp%)}" 24 | } 25 | 26 | function Api::discover { 27 | local -A params="( `unpack "$@"` )" 28 | local -a hosts=( "${params[host]}" ) 29 | local -A added 30 | 31 | declare -p params 32 | declare -p hosts 33 | 34 | for ((i=0; i < ${#hosts[@]} && ${#hosts[@]} < ${params[max]}; i++)); do 35 | #echo 36 | #echo [i=$i, ${#hosts[@]}/${params[max]}] Querying ${hosts[$i]} 37 | local -a results=(`curl --user-agent "${USER_AGENT}" "${hosts[$i]}/api/v1/timelines/public?limit=40" 2>/dev/null | \ 38 | jq -r '.[].url' 2>/dev/null | \ 39 | grep -oP 'https?://[^.]+\.[^/]+'`) 40 | 41 | for host in "${results[@]}"; do 42 | if ! [ ${added["$host"]+exists} ]; then 43 | added["$host"]=True 44 | hosts+=( "$host" ) 45 | fi 46 | done 47 | done 48 | 49 | declare -p hosts 50 | } 51 | 52 | function Api::get_local_timeline { 53 | local host="$1" 54 | local -a urls="( `{ curl --fail --user-agent "${USER_AGENT}" "${host}/api/v1/timelines/public?local=true&only_media=true&limit=40" 2>/dev/null || 55 | curl --user-agent "${USER_AGENT}" "${host}/api/v1/timelines/public?local=true&limit=40" 2>/dev/null ; } | \ 56 | jq -r '.[].media_attachments[].preview_url' 2>/dev/null | \ 57 | sort -u` )" 58 | declare -p urls 59 | } 60 | 61 | function Array::max_length { 62 | local -a items=( "$@" ) 63 | local max=0 64 | 65 | for i in "${items[@]}"; do 66 | if (( ${#i} > max )); then 67 | max=${#i} 68 | fi 69 | done 70 | 71 | echo $max 72 | } 73 | 74 | declare -g screen_counter=0 75 | 76 | function Screen::new { 77 | let screen_counter+=1 78 | tput smcup 1>&2 79 | } 80 | 81 | function Screen::pop { 82 | tput rmcup 1>&2 83 | let screen_counter-=1 84 | } 85 | 86 | function Screen::move_cursor { 87 | tput cup "$1" "$2" 1>&2 88 | } 89 | 90 | function Screen::hide_cursor { 91 | tput civis 1>&2 92 | } 93 | 94 | function Screen::show_cursor { 95 | tput cnorm 1>&2 96 | } 97 | 98 | function Screen::width { 99 | tput cols 100 | } 101 | 102 | function Screen::height { 103 | tput lines 104 | } 105 | 106 | function Screen::cleanup { 107 | while ((0 < screen_counter)); do 108 | Screen::pop 109 | done 110 | Screen::show_cursor 111 | } 112 | 113 | function Screen::popup { 114 | local -a message=( "$@" ) 115 | local line_length="`Array::max_length "${message[@]}"`" 116 | local line_count="${#message[@]}" 117 | local offset_x="$(( `Screen::width` / 2 - line_length / 2 ))" 118 | local offset_y="$(( `Screen::height` / 2 - ( line_count + 1 ) / 2 ))" 119 | 120 | Screen::new 121 | 122 | for ((i=0; i < ${#message[@]}; i++)); do 123 | Screen::move_cursor $(( offset_y + i )) $offset_x 124 | echo "${message[$i]}" 1>&2 125 | Screen::move_cursor $(( offset_y + 1 + i )) $offset_x 126 | done 127 | } 128 | 129 | function Screen::dropdown { 130 | local offset_y="$(( `Screen::height` / 2 - 1 ))" 131 | local title="$1" 132 | shift 133 | 134 | Screen::new 135 | Screen::hide_cursor 136 | Screen::move_cursor $offset_y 1 137 | smenu -m "$title" -M -W' '$'\n' -t 1 -l <<<"${@}" 138 | } 139 | 140 | function Screen::select_host { 141 | local -A params="( `unpack "$@"` )" 142 | Screen::popup 'Searching hosts' \ 143 | "Searching up to ${params[max]} mastodon instances," \ 144 | "using ${params[host]} as entry point." 145 | Screen::hide_cursor 146 | local -A hosts="( $(unpack "`Api::discover "$@"`") )" 147 | Screen::pop 148 | 149 | echo -n "$(Screen::dropdown "Select host:" "${hosts[@]}")" 150 | Screen::pop 151 | } 152 | 153 | function Screen::display_media { 154 | local -a urls=( "$@" ) 155 | 156 | ImageLayer 0< <( 157 | function cleanup { 158 | if [[ "$tmpfolder" == "/tmp/"* ]]; then 159 | rm "${tmpfolder}/"* 160 | rmdir "${tmpfolder}" 161 | fi 162 | } 163 | trap 'cleanup' EXIT 164 | 165 | padding=3 166 | width=40 167 | height=14 168 | page_width=`Screen::width` 169 | page_height=`Screen::height` 170 | full_width=$(( width + 2 * padding )) 171 | full_height=$(( height + 2 * padding )) 172 | cols=$(( page_width / full_width )) 173 | rows=$(( page_height / full_height )) 174 | page_media_count=$(( rows * cols )) 175 | offset_x=$(( (page_width - cols * full_width) / 2 )) 176 | offset_y=$(( (page_height - rows * full_height) / 2 )) 177 | iterations=$(( ${#urls[@]} / ( cols * rows ) )) 178 | tmpfolder=`mktemp --directory` 179 | 180 | for ((i=0; i < iterations; i++)); do 181 | for ((r=0; r < rows; r++)); do 182 | for ((c=0; c < cols; c++)); do 183 | index=$(( i * page_media_count + r * rows + c )) 184 | url="${urls[$index]}" 185 | name="`basename "${url}"`" 186 | path="${tmpfolder}/$name" 187 | curl --user-agent "${USER_AGENT}" "${url}" 2>/dev/null > "${path}" 188 | ImageLayer::add [identifier]="${r},${c}" \ 189 | [x]="$((offset_x + c * full_width))" [y]="$((offset_y + r * full_height))" \ 190 | [max_width]="$width" [max_height]="$height" \ 191 | [path]="$path" 192 | Screen::move_cursor "$((offset_y + (r + 1) * full_height - padding + 1))" "$((offset_x + c * full_width))" 193 | echo -n "$name" 1>&2 194 | done 195 | done 196 | 197 | read 198 | 199 | for ((r=0; r < rows; r++)); do 200 | for ((c=0; c < cols; c++)); do 201 | ImageLayer::remove [identifier]="${r},${c}" 202 | done 203 | done 204 | 205 | clear 1>&2 206 | done 207 | ) 208 | } 209 | 210 | function Screen::display_timeline { 211 | local -A params="( `unpack "$@"` )" 212 | local -A urls="( $(unpack "`Api::get_local_timeline "${params[host]}"`") )" 213 | Screen::new 214 | Screen::hide_cursor 215 | 216 | if (( ${#urls[@]} == 0 )); then 217 | Screen::pop 218 | Screen::popup "There was no image in the current feed." 219 | read 220 | Screen::pop 221 | return 222 | fi 223 | 224 | Screen::display_media "${urls[@]}" 225 | Screen::pop 226 | } 227 | 228 | trap 'Screen::cleanup' EXIT 229 | target_host="$(Screen::select_host [max]=30 [host]="https://mastodon.social")" 230 | 231 | if [ -n "$target_host" ]; then 232 | Screen::display_timeline [host]="$target_host" 233 | fi 234 | -------------------------------------------------------------------------------- /meson.build: -------------------------------------------------------------------------------- 1 | project ('ueberzug', 'c') 2 | 3 | py = import('python').find_installation(pure: false) 4 | 5 | # not sure what your minimum x lib versions are, 6 | # but you could specify them here 7 | x_deps = [ 8 | dependency('x11'), 9 | dependency('xext'), 10 | dependency('xres'), 11 | ] 12 | 13 | x_sources = [ 14 | 'ueberzug/X/X.c', 15 | 'ueberzug/X/Xshm.c', 16 | 'ueberzug/X/display.c', 17 | 'ueberzug/X/window.c', 18 | ] 19 | 20 | # copies the python source directory to the install directory 21 | install_subdir( 22 | 'ueberzug', 23 | install_dir: py.get_install_dir(), 24 | exclude_directories: 'X', 25 | ) 26 | 27 | # also install the c extension to the same install directory 28 | py.extension_module( 29 | 'X', 30 | x_sources, 31 | dependencies: x_deps, 32 | install: true, 33 | subdir: 'ueberzug', 34 | ) 35 | 36 | # print out a summary of settings 37 | # note this won't be directly visible with meon-python 38 | # since that suppresses certain meson output by default 39 | # and deletes its build directory when it is done (so 40 | # you can't find the logfile) 41 | # 42 | # if you just want to see this, try running a meson setup 43 | # command without actually building anything. 44 | # e.g. $ meson setup _build --prefix=/your/prefix 45 | summary( 46 | { 47 | 'prefix': get_option('prefix'), 48 | 'bindir': get_option('bindir'), 49 | 'libdir': get_option('libdir'), 50 | 'datadir': get_option('datadir'), 51 | 'py_install_dir': py.get_install_dir(), 52 | }, 53 | section: 'Directories' 54 | ) 55 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["meson", "meson-python"] 3 | build-backend = "mesonpy" 4 | 5 | [project] 6 | # There are some restrictions on what makes a valid project name 7 | # specification here: 8 | # https://packaging.python.org/specifications/core-metadata/#name 9 | name="ueberzug" # Required 10 | license = {file = "LICENSE"} 11 | # Versions should comply with PEP 440: 12 | # https://www.python.org/dev/peps/pep-0440/ 13 | # 14 | # For a discussion on single-sourcing the version across setup.py and the 15 | # project code, see 16 | # https://packaging.python.org/en/latest/single_source_version.html 17 | version = "18.3.1" 18 | # This is a one-line description or tagline of what your project does. This 19 | # corresponds to the "Summary" metadata field: 20 | # https://packaging.python.org/specifications/core-metadata/#summary 21 | description = "ueberzug is a command line util which allows to display images in combination with X11" 22 | # This should be your name or the name of the organization which owns the 23 | # project. 24 | author = "" # Optional 25 | # Classifiers help users find your project by categorizing it. 26 | # 27 | # For a list of valid classifiers, see 28 | # https://pypi.python.org/pypi?%3Aaction=list_classifiers 29 | classifiers = [ # Optional 30 | "Environment :: Console", 31 | "Environment :: X11 Applications", 32 | "Intended Audience :: Developers", 33 | "Operating System :: POSIX :: Linux", 34 | "Topic :: Software Development :: User Interfaces", 35 | "Topic :: Utilities" 36 | ] 37 | # This field adds keywords for your project which will appear on the 38 | # project page. What does your project relate to? 39 | # 40 | # Note that this is a string of words separated by whitespace, not a list. 41 | keywords=["image", "media", "terminal", "ui", "gui"] # Optional 42 | # This field lists other packages that your project depends on to run. 43 | # Any package you put here will be installed by pip when your project is 44 | # installed, so they must be valid existing projects. 45 | # 46 | # For an analysis of "install_requires" vs pip's requirements files see: 47 | # https://packaging.python.org/en/latest/requirements.html 48 | dependencies = [ # Optional 49 | "pillow", 50 | "docopt", 51 | "attrs>=18.2.0" 52 | ] 53 | requires-python = ">=3.6" 54 | 55 | [project.urls] 56 | # List additional URLs that are relevant to your project as a dict. 57 | # 58 | # This field corresponds to the "Project-URL" metadata fields: 59 | # https://packaging.python.org/specifications/core-metadata/#project-url-multiple-use 60 | # 61 | # Examples listed include a pattern for specifying where the package tracks 62 | # issues, where the source is hosted, where to say thanks to the package 63 | # maintainers, and where to support the project financially. The key is 64 | # what's used to render the link text on PyPI. 65 | # See also: 66 | # https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#urls 67 | "Homepage" = "https://github.com/ueber-devel/ueberzug" 68 | "Source" = "https://github.com/ueber-devel/ueberzug" 69 | "Bug Reports" = "https://github.com/ueber-devel/ueberzug/issues" 70 | 71 | [project.scripts] 72 | ueberzug = "ueberzug.__main__:main" 73 | -------------------------------------------------------------------------------- /ueberzug/X/X.c: -------------------------------------------------------------------------------- 1 | #include "python.h" 2 | 3 | #include 4 | 5 | #include "util.h" 6 | #include "display.h" 7 | #include "window.h" 8 | #include "Xshm.h" 9 | 10 | 11 | static PyObject * 12 | X_init_threads(PyObject *self) { 13 | if (XInitThreads() == 0) { 14 | raise(OSError, "Xlib concurrent threads initialization failed."); 15 | } 16 | Py_RETURN_NONE; 17 | } 18 | 19 | 20 | static PyMethodDef X_methods[] = { 21 | {"init_threads", (PyCFunction)X_init_threads, 22 | METH_NOARGS, 23 | "Initializes Xlib support for concurrent threads."}, 24 | {NULL} /* Sentinel */ 25 | }; 26 | 27 | 28 | static PyModuleDef module = { 29 | PyModuleDef_HEAD_INIT, 30 | .m_name = "ueberzug.X", 31 | .m_doc = "Modul which implements the interaction with the Xshm extension.", 32 | .m_size = -1, 33 | .m_methods = X_methods, 34 | }; 35 | 36 | 37 | PyMODINIT_FUNC 38 | PyInit_X(void) { 39 | PyObject *module_instance; 40 | if (PyType_Ready(&DisplayType) < 0 || 41 | PyType_Ready(&WindowType) < 0 || 42 | PyType_Ready(&ImageType) < 0) { 43 | return NULL; 44 | } 45 | 46 | module_instance = PyModule_Create(&module); 47 | if (module_instance == NULL) { 48 | return NULL; 49 | } 50 | 51 | Py_INCREF(&DisplayType); 52 | Py_INCREF(&WindowType); 53 | Py_INCREF(&ImageType); 54 | PyModule_AddObject(module_instance, "Display", (PyObject*)&DisplayType); 55 | PyModule_AddObject(module_instance, "OverlayWindow", (PyObject*)&WindowType); 56 | PyModule_AddObject(module_instance, "Image", (PyObject*)&ImageType); 57 | return module_instance; 58 | } 59 | -------------------------------------------------------------------------------- /ueberzug/X/X.h: -------------------------------------------------------------------------------- 1 | #ifndef __X_H__ 2 | #define __X_H__ 3 | #include "python.h" 4 | 5 | 6 | PyModuleDef module; 7 | #endif 8 | -------------------------------------------------------------------------------- /ueberzug/X/Xshm.c: -------------------------------------------------------------------------------- 1 | #include "python.h" 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | #include "math.h" 10 | #include "util.h" 11 | #include "display.h" 12 | 13 | #define INVALID_SHM_ID -1 14 | #define INVALID_SHM_ADDRESS (char*)-1 15 | #define BYTES_PER_PIXEL 4 16 | 17 | 18 | typedef struct { 19 | PyObject_HEAD 20 | int width; 21 | int height; 22 | int buffer_size; 23 | DisplayObject *display_pyobject; 24 | XShmSegmentInfo segmentInfo; 25 | XImage *image; 26 | } ImageObject; 27 | 28 | 29 | static inline Display * 30 | get_display(ImageObject *self) { 31 | return self->display_pyobject->event_display; 32 | } 33 | 34 | static bool 35 | Image_init_shared_memory(ImageObject *self) { 36 | self->segmentInfo.shmid = shmget( 37 | IPC_PRIVATE, 38 | self->buffer_size, 39 | IPC_CREAT | 0600); 40 | return self->segmentInfo.shmid != INVALID_SHM_ID; 41 | } 42 | 43 | static bool 44 | Image_map_shared_memory(ImageObject *self) { 45 | // Map the shared memory segment into the address space of this process 46 | self->segmentInfo.shmaddr = (char*)shmat(self->segmentInfo.shmid, 0, 0); 47 | 48 | if (self->segmentInfo.shmaddr != INVALID_SHM_ADDRESS) { 49 | self->segmentInfo.readOnly = true; 50 | // Mark the shared memory segment for removal 51 | // It will be removed even if this program crashes 52 | shmctl(self->segmentInfo.shmid, IPC_RMID, 0); 53 | return true; 54 | } 55 | 56 | return false; 57 | } 58 | 59 | static bool 60 | Image_create_shared_image(ImageObject *self) { 61 | Display *display = get_display(self); 62 | int screen = XDefaultScreen(display); 63 | // Allocate the memory needed for the XImage structure 64 | self->image = XShmCreateImage( 65 | display, XDefaultVisual(display, screen), 66 | DefaultDepth(display, screen), ZPixmap, 0, 67 | &self->segmentInfo, 0, 0); 68 | 69 | if (self->image) { 70 | self->image->data = (char*)self->segmentInfo.shmaddr; 71 | self->image->width = self->width; 72 | self->image->height = self->height; 73 | 74 | // Ask the X server to attach the shared memory segment and sync 75 | XShmAttach(display, &self->segmentInfo); 76 | XFlush(display); 77 | return true; 78 | } 79 | return false; 80 | } 81 | 82 | static void 83 | Image_destroy_shared_image(ImageObject *self) { 84 | if (self->image) { 85 | XShmDetach(get_display(self), &self->segmentInfo); 86 | XDestroyImage(self->image); 87 | self->image = NULL; 88 | } 89 | } 90 | 91 | static void 92 | Image_free_shared_memory(ImageObject *self) { 93 | if(self->segmentInfo.shmaddr != INVALID_SHM_ADDRESS) { 94 | shmdt(self->segmentInfo.shmaddr); 95 | self->segmentInfo.shmaddr = INVALID_SHM_ADDRESS; 96 | } 97 | } 98 | 99 | static void 100 | Image_finalise(ImageObject *self) { 101 | Image_destroy_shared_image(self); 102 | Image_free_shared_memory(self); 103 | Py_CLEAR(self->display_pyobject); 104 | } 105 | 106 | static int 107 | Image_init(ImageObject *self, PyObject *args, PyObject *kwds) { 108 | static char *kwlist[] = {"display", "width", "height", NULL}; 109 | PyObject *display_pyobject; 110 | 111 | if (!PyArg_ParseTupleAndKeywords( 112 | args, kwds, "O!ii", kwlist, 113 | &DisplayType, &display_pyobject, 114 | &self->width, &self->height)) { 115 | Py_INIT_RETURN_ERROR; 116 | } 117 | 118 | if (self->display_pyobject) { 119 | Image_finalise(self); 120 | } 121 | 122 | Py_INCREF(display_pyobject); 123 | self->display_pyobject = (DisplayObject*)display_pyobject; 124 | self->buffer_size = self->width * self->height * BYTES_PER_PIXEL; 125 | 126 | if (!Image_init_shared_memory(self)) { 127 | raiseInit(OSError, "could not init shared memory"); 128 | } 129 | 130 | if (!Image_map_shared_memory(self)) { 131 | raiseInit(OSError, "could not map shared memory"); 132 | } 133 | 134 | if (!Image_create_shared_image(self)) { 135 | Image_free_shared_memory(self); 136 | raiseInit(OSError, "could not allocate the XImage structure"); 137 | } 138 | 139 | Py_INIT_RETURN_SUCCESS; 140 | } 141 | 142 | static void 143 | Image_dealloc(ImageObject *self) { 144 | Image_finalise(self); 145 | Py_TYPE(self)->tp_free((PyObject*)self); 146 | } 147 | 148 | static PyObject * 149 | Image_copy_to(ImageObject *self, PyObject *args, PyObject *kwds) { 150 | // draws the image on the surface at x, y 151 | static char *kwlist[] = {"drawable", "x", "y", "width", "height", NULL}; 152 | Drawable surface; 153 | GC gc; 154 | int x, y; 155 | unsigned int width, height; 156 | Display *display = get_display(self); 157 | 158 | if (!PyArg_ParseTupleAndKeywords( 159 | args, kwds, "kiiII", kwlist, 160 | &surface, &x, &y, &width, &height)) { 161 | Py_RETURN_ERROR; 162 | } 163 | 164 | gc = XCreateGC(display, surface, 0, NULL); 165 | XShmPutImage(display, surface, gc, 166 | self->image, 0, 0, 167 | x, y, width, height, false); 168 | XFreeGC(display, gc); 169 | 170 | Py_RETURN_NONE; 171 | } 172 | 173 | static PyObject * 174 | Image_draw(ImageObject *self, PyObject *args, PyObject *kwds) { 175 | // puts the pixels on the image at x, y 176 | static char *kwlist[] = {"x", "y", "width", "height", "pixels", NULL}; 177 | int offset_x, offset_y; 178 | int width, height; 179 | int pixels_per_row; 180 | int source_pixels_per_row; 181 | int destination_pixels_per_row; 182 | int destination_offset_x_bytes; 183 | char *pixels; 184 | Py_ssize_t pixels_size; 185 | 186 | if (!PyArg_ParseTupleAndKeywords( 187 | args, kwds, "iiiis#", kwlist, 188 | &offset_x, &offset_y, &width, &height, 189 | &pixels, &pixels_size)) { 190 | Py_RETURN_ERROR; 191 | } 192 | 193 | Py_BEGIN_ALLOW_THREADS 194 | destination_offset_x_bytes = max(0, offset_x) * BYTES_PER_PIXEL; 195 | source_pixels_per_row = width * BYTES_PER_PIXEL; 196 | destination_pixels_per_row = self->width * BYTES_PER_PIXEL; 197 | pixels_per_row = min(width + min(offset_x, 0), self->width - max(offset_x, 0)) * BYTES_PER_PIXEL; 198 | 199 | if (offset_x + width > 0 && offset_x < self->width) { 200 | // < 0 -> start y = 0, min(surface.height, height - abs(offset)) 201 | // > 0 -> start y = offset, height = min(surface.height, height + offset) 202 | for (int y = max(0, offset_y); y < min(self->height, height + offset_y); y++) { 203 | // < 0 -> first row = abs(offset) => n row = y + abs(offset) 204 | // > 0 -> first row = - offset => n row = y - offset 205 | // => n row = y - offset 206 | int pixels_y = y - offset_y; 207 | void *destination = 208 | self->image->data + y * destination_pixels_per_row 209 | + destination_offset_x_bytes; 210 | void *source = pixels + pixels_y * source_pixels_per_row; 211 | 212 | if (! ((uintptr_t)self->image->data <= (uintptr_t)destination)) { 213 | raise(AssertionError, 214 | "The destination start address calculation went wrong.\n" 215 | "It points to an address which is before the start address of the buffer.\n" 216 | "%p not smaller than %p", 217 | self->image->data, destination); 218 | } 219 | if (! ((uintptr_t)destination + pixels_per_row 220 | <= (uintptr_t)self->image->data + self->buffer_size)) { 221 | raise(AssertionError, 222 | "The destination end address calculation went wrong.\n" 223 | "It points to an address which is after the end address of the buffer.\n" 224 | "%p not smaller than %p", 225 | destination + pixels_per_row, 226 | self->image->data + self->buffer_size); 227 | } 228 | if (! ((uintptr_t)pixels <= (uintptr_t)source)) { 229 | raise(AssertionError, 230 | "The source start address calculation went wrong.\n" 231 | "It points to an address which is before the start address of the buffer.\n" 232 | "%p not smaller than %p", 233 | pixels, source); 234 | } 235 | if (! ((uintptr_t)source + pixels_per_row 236 | <= (uintptr_t)pixels + pixels_size)) { 237 | raise(AssertionError, 238 | "The source end address calculation went wrong.\n" 239 | "It points to an address which is after the end address of the buffer." 240 | "%p not smaller than %p", 241 | source + pixels_per_row, 242 | pixels + pixels_size); 243 | } 244 | 245 | memcpy(destination, source, pixels_per_row); 246 | } 247 | } 248 | Py_END_ALLOW_THREADS 249 | 250 | Py_RETURN_NONE; 251 | } 252 | 253 | static PyMethodDef Image_methods[] = { 254 | {"copy_to", (PyCFunction)Image_copy_to, 255 | METH_VARARGS | METH_KEYWORDS, 256 | "Draws the image on the surface at the passed coordinate.\n" 257 | "\n" 258 | "Args:\n" 259 | " drawable (int): the surface to draw on\n" 260 | " x (int): the x position where this image should be placed\n" 261 | " y (int): the y position where this image should be placed\n" 262 | " width (int): the width of the area\n" 263 | " which should be copied to the drawable\n" 264 | " height (int): the height of the area\n" 265 | " which should be copied to the drawable"}, 266 | {"draw", (PyCFunction)Image_draw, 267 | METH_VARARGS | METH_KEYWORDS, 268 | "Places the pixels on the image at the passed coordinate.\n" 269 | "\n" 270 | "Args:\n" 271 | " x (int): the x position where the pixels should be placed\n" 272 | " y (int): the y position where the pixels should be placed\n" 273 | " width (int): amount of pixels per row in the passed data\n" 274 | " height (int): amount of pixels per column in the passed data\n" 275 | " pixels (bytes): the pixels to place on the image"}, 276 | {NULL} /* Sentinel */ 277 | }; 278 | 279 | PyTypeObject ImageType = { 280 | PyVarObject_HEAD_INIT(NULL, 0) 281 | .tp_name = "ueberzug.X.Image", 282 | .tp_doc = 283 | "An shared memory X11 Image\n" 284 | "\n" 285 | "Args:\n" 286 | " display (ueberzug.X.Display): the X11 display\n" 287 | " width (int): the width of this image\n" 288 | " height (int): the height of this image", 289 | .tp_basicsize = sizeof(ImageObject), 290 | .tp_itemsize = 0, 291 | .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, 292 | .tp_new = PyType_GenericNew, 293 | .tp_init = (initproc)Image_init, 294 | .tp_dealloc = (destructor) Image_dealloc, 295 | .tp_methods = Image_methods, 296 | }; 297 | -------------------------------------------------------------------------------- /ueberzug/X/Xshm.h: -------------------------------------------------------------------------------- 1 | #ifndef __XSHM_H__ 2 | #define __XSHM_H__ 3 | #include "python.h" 4 | 5 | 6 | extern PyTypeObject ImageType; 7 | #endif 8 | -------------------------------------------------------------------------------- /ueberzug/X/display.c: -------------------------------------------------------------------------------- 1 | #include "python.h" 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | #include "util.h" 9 | #include "display.h" 10 | 11 | 12 | #define INVALID_PID (pid_t)-1 13 | 14 | 15 | #define REOPEN_DISPLAY(display) \ 16 | if (display != NULL) { \ 17 | XCloseDisplay(display); \ 18 | } \ 19 | display = XOpenDisplay(NULL); 20 | 21 | #define CLOSE_DISPLAY(display) \ 22 | if (display != NULL) { \ 23 | XCloseDisplay(display); \ 24 | display = NULL; \ 25 | } 26 | 27 | 28 | static int 29 | Display_init(DisplayObject *self, PyObject *args, PyObject *kwds) { 30 | // Two connections are opened as 31 | // a death lock can occur 32 | // if you listen for events 33 | // (this will happen in parallel in asyncio worker threads) 34 | // and request information (e.g. XGetGeometry) 35 | // simultaneously. 36 | REOPEN_DISPLAY(self->event_display); 37 | REOPEN_DISPLAY(self->info_display); 38 | 39 | if (self->event_display == NULL || 40 | self->info_display == NULL) { 41 | raiseInit(OSError, "could not open a connection to the X server"); 42 | } 43 | 44 | int _; 45 | if (!XResQueryExtension(self->info_display, &_, &_)) { 46 | raiseInit(OSError, "the extension XRes is required"); 47 | } 48 | 49 | if (!XShmQueryExtension(self->event_display)) { 50 | raiseInit(OSError, "the extension Xext is required"); 51 | } 52 | 53 | int screen = XDefaultScreen(self->info_display); 54 | self->screen_width = XDisplayWidth(self->info_display, screen); 55 | self->screen_height = XDisplayHeight(self->info_display, screen); 56 | self->bitmap_pad = XBitmapPad(self->info_display); 57 | self->bitmap_unit = XBitmapUnit(self->info_display); 58 | 59 | self->wm_class = XInternAtom(self->info_display, "WM_CLASS", False); 60 | self->wm_name = XInternAtom(self->info_display, "WM_NAME", False); 61 | self->wm_locale_name = XInternAtom(self->info_display, "WM_LOCALE_NAME", False); 62 | self->wm_normal_hints = XInternAtom(self->info_display, "WM_NORMAL_HINTS", False); 63 | 64 | Py_INIT_RETURN_SUCCESS; 65 | } 66 | 67 | static void 68 | Display_dealloc(DisplayObject *self) { 69 | CLOSE_DISPLAY(self->event_display); 70 | CLOSE_DISPLAY(self->info_display); 71 | Py_TYPE(self)->tp_free((PyObject*)self); 72 | } 73 | 74 | 75 | static int 76 | has_property(DisplayObject *self, Window window, Atom property) { 77 | Atom actual_type_return; 78 | int actual_format_return; 79 | unsigned long bytes_after_return; 80 | unsigned char* prop_to_return = NULL; 81 | unsigned long nitems_return; 82 | 83 | int status = XGetWindowProperty( 84 | self->info_display, window, property, 0, 85 | 0L, False, 86 | AnyPropertyType, 87 | &actual_type_return, 88 | &actual_format_return, 89 | &nitems_return, &bytes_after_return, &prop_to_return); 90 | if (status == Success && prop_to_return) { 91 | XFree(prop_to_return); 92 | } 93 | return status == Success && !(actual_type_return == None && actual_format_return == 0); 94 | } 95 | 96 | static void 97 | get_child_window_ids_helper(Display *display, Window window, 98 | Window **total_children, unsigned int *total_num_children, unsigned int *return_code) { 99 | Window _, *children; 100 | unsigned int num_children; 101 | 102 | if (!XQueryTree(display, window, &_, &_, &children, &num_children)) { 103 | *return_code = 0; 104 | return; 105 | } 106 | if (!children) return; 107 | 108 | Window *tmp = realloc(*total_children, (num_children + *total_num_children) * sizeof(Window)); 109 | if (tmp) { 110 | *total_children = tmp; 111 | } else { 112 | *return_code = 0; 113 | XFree(children); 114 | return; 115 | } 116 | 117 | for (int i = 0; i < num_children; ++i) { 118 | (*total_children)[i + *total_num_children] = children[i]; 119 | } 120 | *total_num_children += num_children; 121 | 122 | for (int i = 0; i < num_children; ++i) { 123 | get_child_window_ids_helper(display, children[i], total_children, total_num_children, return_code); 124 | } 125 | 126 | XFree(children); 127 | } 128 | 129 | static PyObject * 130 | Display_get_child_window_ids(DisplayObject *self, PyObject *args, PyObject *kwds) { 131 | static char *kwlist[] = {"parent_id", NULL}; 132 | Window parent = XDefaultRootWindow(self->info_display); 133 | Window *children = NULL; 134 | unsigned int children_count = 0, xtree_return = 1; 135 | 136 | if (!PyArg_ParseTupleAndKeywords( 137 | args, kwds, "|k", kwlist, 138 | &parent)) { 139 | Py_RETURN_ERROR; 140 | } 141 | 142 | get_child_window_ids_helper(self->info_display, parent, &children, &children_count, &xtree_return); 143 | if (!xtree_return) { 144 | free(children); 145 | raise(OSError, "failed to query child windows of %lu", parent); 146 | } 147 | 148 | PyObject *child_ids = PyList_New(0); 149 | if (children) { 150 | for (unsigned int i = 0; i < children_count; i++) { 151 | // assume that windows without essential properties 152 | // like the window title aren't shown to the user 153 | int is_helper_window = ( 154 | !has_property(self, children[i], self->wm_class) && 155 | !has_property(self, children[i], self->wm_name) && 156 | !has_property(self, children[i], self->wm_locale_name) && 157 | !has_property(self, children[i], self->wm_normal_hints)); 158 | if (is_helper_window) { 159 | continue; 160 | } 161 | PyObject *py_window_id = Py_BuildValue("k", children[i]); 162 | PyList_Append(child_ids, py_window_id); 163 | Py_XDECREF(py_window_id); 164 | } 165 | free(children); 166 | } 167 | 168 | return child_ids; 169 | } 170 | 171 | static PyObject * 172 | Display_get_window_pid(DisplayObject *self, PyObject *args, PyObject *kwds) { 173 | static char *kwlist[] = {"window_id", NULL}; 174 | Window window; 175 | long num_ids; 176 | int num_specs = 1; 177 | XResClientIdValue *client_ids; 178 | XResClientIdSpec client_specs[1]; 179 | pid_t window_creator_pid = INVALID_PID; 180 | 181 | if (!PyArg_ParseTupleAndKeywords( 182 | args, kwds, "k", kwlist, 183 | &window)) { 184 | Py_RETURN_ERROR; 185 | } 186 | 187 | client_specs[0].client = window; 188 | client_specs[0].mask = XRES_CLIENT_ID_PID_MASK; 189 | if (Success != XResQueryClientIds( 190 | self->info_display, num_specs, client_specs, 191 | &num_ids, &client_ids)) { 192 | Py_RETURN_NONE; 193 | } 194 | 195 | for(int i = 0; i < num_ids; i++) { 196 | XResClientIdValue *value = client_ids + i; 197 | XResClientIdType type = XResGetClientIdType(value); 198 | 199 | if (type == XRES_CLIENT_ID_PID) { 200 | window_creator_pid = XResGetClientPid(value); 201 | } 202 | } 203 | 204 | XFree(client_ids); 205 | 206 | if (window_creator_pid != INVALID_PID) { 207 | return Py_BuildValue("i", window_creator_pid); 208 | } 209 | 210 | Py_RETURN_NONE; 211 | } 212 | 213 | static PyObject * 214 | Display_wait_for_event(DisplayObject *self) { 215 | Py_BEGIN_ALLOW_THREADS 216 | XEvent event; 217 | XPeekEvent(self->event_display, &event); 218 | Py_END_ALLOW_THREADS 219 | Py_RETURN_NONE; 220 | } 221 | 222 | static PyObject * 223 | Display_discard_event(DisplayObject *self) { 224 | Py_BEGIN_ALLOW_THREADS 225 | XEvent event; 226 | XNextEvent(self->event_display, &event); 227 | Py_END_ALLOW_THREADS 228 | Py_RETURN_NONE; 229 | } 230 | 231 | static PyObject * 232 | Display_get_bitmap_format_scanline_pad(DisplayObject *self, void *closure) { 233 | return Py_BuildValue("i", self->bitmap_pad); 234 | } 235 | 236 | static PyObject * 237 | Display_get_bitmap_format_scanline_unit(DisplayObject *self, void *closure) { 238 | return Py_BuildValue("i", self->bitmap_unit); 239 | } 240 | 241 | static PyObject * 242 | Display_get_screen_width(DisplayObject *self, void *closure) { 243 | return Py_BuildValue("i", self->screen_width); 244 | } 245 | 246 | static PyObject * 247 | Display_get_screen_height(DisplayObject *self, void *closure) { 248 | return Py_BuildValue("i", self->screen_height); 249 | } 250 | 251 | 252 | static PyGetSetDef Display_properties[] = { 253 | {"bitmap_format_scanline_pad", (getter)Display_get_bitmap_format_scanline_pad, 254 | .doc = "int: Each scanline must be padded to a multiple of bits of this value."}, 255 | {"bitmap_format_scanline_unit", (getter)Display_get_bitmap_format_scanline_unit, 256 | .doc = "int:\n" 257 | " The size of a bitmap's scanline unit in bits.\n" 258 | " The scanline is calculated in multiples of this value."}, 259 | {"screen_width", (getter)Display_get_screen_width, 260 | .doc = "int: The width of the default screen at the time the connection to X11 was opened."}, 261 | {"screen_height", (getter)Display_get_screen_height, 262 | .doc = "int: The height of the default screen at the time the connection to X11 was opened."}, 263 | {NULL} /* Sentinel */ 264 | }; 265 | 266 | 267 | static PyMethodDef Display_methods[] = { 268 | {"wait_for_event", (PyCFunction)Display_wait_for_event, 269 | METH_NOARGS, 270 | "Waits for an event to occur. till an event occur."}, 271 | {"discard_event", (PyCFunction)Display_discard_event, 272 | METH_NOARGS, 273 | "Discards the first event from the event queue."}, 274 | {"get_child_window_ids", (PyCFunction)Display_get_child_window_ids, 275 | METH_VARARGS | METH_KEYWORDS, 276 | "Queries for the ids of the children of the window with the passed identifier.\n" 277 | "\n" 278 | "Args:\n" 279 | " parent_id (int): optional\n" 280 | " the id of the window for which to query for the ids of its children\n" 281 | " if it's not specified the id of the default root window will be used\n" 282 | "\n" 283 | "Returns:\n" 284 | " list of ints: the ids of the child windows"}, 285 | {"get_window_pid", (PyCFunction)Display_get_window_pid, 286 | METH_VARARGS | METH_KEYWORDS, 287 | "Tries to figure out the pid of the process which created the window with the passed id.\n" 288 | "\n" 289 | "Args:\n" 290 | " window_id (int): the window id for which to retrieve information\n" 291 | "\n" 292 | "Returns:\n" 293 | " int or None: the pid of the creator of the window"}, 294 | {NULL} /* Sentinel */ 295 | }; 296 | 297 | PyTypeObject DisplayType = { 298 | PyVarObject_HEAD_INIT(NULL, 0) 299 | .tp_name = "ueberzug.X.Display", 300 | .tp_doc = "X11 display\n", 301 | .tp_basicsize = sizeof(DisplayObject), 302 | .tp_itemsize = 0, 303 | .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, 304 | .tp_new = PyType_GenericNew, 305 | .tp_init = (initproc)Display_init, 306 | .tp_dealloc = (destructor)Display_dealloc, 307 | .tp_getset = Display_properties, 308 | .tp_methods = Display_methods, 309 | }; 310 | -------------------------------------------------------------------------------- /ueberzug/X/display.h: -------------------------------------------------------------------------------- 1 | #ifndef __DISPLAY_H__ 2 | #define __DISPLAY_H__ 3 | 4 | #include "python.h" 5 | 6 | #include 7 | 8 | 9 | typedef struct { 10 | PyObject_HEAD 11 | // Always use the event_display 12 | // except for functions which return information 13 | // e.g. XGetGeometry. 14 | Display *event_display; 15 | Display *info_display; 16 | int bitmap_pad; 17 | int bitmap_unit; 18 | int screen; 19 | int screen_width; 20 | int screen_height; 21 | 22 | Atom wm_class; 23 | Atom wm_name; 24 | Atom wm_locale_name; 25 | Atom wm_normal_hints; 26 | } DisplayObject; 27 | extern PyTypeObject DisplayType; 28 | 29 | #endif 30 | -------------------------------------------------------------------------------- /ueberzug/X/math.h: -------------------------------------------------------------------------------- 1 | #ifndef __MATH_H__ 2 | #define __MATH_H__ 3 | 4 | #define min(a,b) (((a) < (b)) ? (a) : (b)) 5 | #define max(a,b) (((a) > (b)) ? (a) : (b)) 6 | 7 | #endif 8 | -------------------------------------------------------------------------------- /ueberzug/X/python.h: -------------------------------------------------------------------------------- 1 | #ifndef __PYTHON_H__ 2 | #define __PYTHON_H__ 3 | 4 | #ifndef __linux__ 5 | #error OS unsupported 6 | #endif 7 | 8 | #define PY_SSIZE_T_CLEAN // Make "s#" use Py_ssize_t rather than int. 9 | #include 10 | 11 | #endif 12 | -------------------------------------------------------------------------------- /ueberzug/X/util.h: -------------------------------------------------------------------------------- 1 | #ifndef __UTIL_H__ 2 | #define __UTIL_H__ 3 | 4 | #define Py_INIT_ERROR -1 5 | #define Py_INIT_SUCCESS 0 6 | #define Py_ERROR NULL 7 | #define Py_RETURN_ERROR return Py_ERROR 8 | #define Py_INIT_RETURN_ERROR return Py_INIT_ERROR 9 | #define Py_INIT_RETURN_SUCCESS return Py_INIT_SUCCESS 10 | 11 | #define __raise(return_value, Exception, message...) { \ 12 | char errorMessage[500]; \ 13 | snprintf(errorMessage, 500, message); \ 14 | PyErr_SetString( \ 15 | PyExc_##Exception, \ 16 | errorMessage); \ 17 | return return_value; \ 18 | } 19 | #define raise(Exception, message...) __raise(Py_ERROR, Exception, message) 20 | #define raiseInit(Exception, message...) __raise(Py_INIT_ERROR, Exception, message) 21 | 22 | #define ARRAY_LENGTH(stack_array) \ 23 | (sizeof stack_array \ 24 | ? sizeof stack_array / sizeof *stack_array \ 25 | : 0) 26 | 27 | 28 | #endif 29 | -------------------------------------------------------------------------------- /ueberzug/X/window.c: -------------------------------------------------------------------------------- 1 | #include "python.h" 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | #include "util.h" 9 | #include "display.h" 10 | 11 | 12 | typedef struct { 13 | PyObject_HEAD 14 | DisplayObject *display_pyobject; 15 | Window parent; 16 | Window window; 17 | unsigned int width; 18 | unsigned int height; 19 | } WindowObject; 20 | 21 | 22 | static inline Display * 23 | get_event_display(WindowObject *self) { 24 | return self->display_pyobject->event_display; 25 | } 26 | 27 | static inline Display * 28 | get_info_display(WindowObject *self) { 29 | return self->display_pyobject->info_display; 30 | } 31 | 32 | static void 33 | Window_create(WindowObject *self) { 34 | Window _0; int _1; unsigned int _2; 35 | XGetGeometry( 36 | get_info_display(self), 37 | self->parent, 38 | &_0, &_1, &_1, 39 | &self->width, &self->height, 40 | &_2, &_2); 41 | 42 | Display *display = get_event_display(self); 43 | int screen = XDefaultScreen(display); 44 | Visual *visual = XDefaultVisual(display, screen); 45 | unsigned long attributes_mask = 46 | CWEventMask | CWBackPixel | CWColormap | CWBorderPixel; 47 | XSetWindowAttributes attributes; 48 | attributes.event_mask = ExposureMask; 49 | attributes.colormap = XCreateColormap( 50 | display, XDefaultRootWindow(display), 51 | visual, AllocNone); 52 | attributes.background_pixel = 0; 53 | attributes.border_pixel = 0; 54 | 55 | self->window = XCreateWindow( 56 | display, self->parent, 57 | 0, 0, self->width, self->height, 0, 58 | DefaultDepth(display, screen), 59 | InputOutput, visual, 60 | attributes_mask, &attributes); 61 | } 62 | 63 | static void 64 | set_subscribed_events(Display *display, Window window, long event_mask) { 65 | XSetWindowAttributes attributes; 66 | attributes.event_mask = event_mask; 67 | XChangeWindowAttributes( 68 | display, window, 69 | CWEventMask , &attributes); 70 | } 71 | 72 | static void 73 | Window_finalise(WindowObject *self) { 74 | if (self->window) { 75 | Display *display = get_event_display(self); 76 | set_subscribed_events( 77 | display, self->parent, NoEventMask); 78 | XDestroyWindow(display, self->window); 79 | XFlush(display); 80 | } 81 | 82 | Py_CLEAR(self->display_pyobject); 83 | self->window = 0; 84 | } 85 | 86 | static inline void 87 | set_xshape_mask(Display *display, Window window, int kind, XRectangle area[], int area_length) { 88 | XShapeCombineRectangles( 89 | display, window, 90 | kind, 91 | 0, 0, area, area_length, 92 | ShapeSet, 0); 93 | } 94 | 95 | static inline void 96 | set_input_mask(Display *display, Window window, XRectangle area[], int area_length) { 97 | set_xshape_mask( 98 | display, window, ShapeInput, area, area_length); 99 | } 100 | 101 | static inline void 102 | set_visibility_mask(Display *display, Window window, XRectangle area[], int area_length) { 103 | set_xshape_mask( 104 | display, window, ShapeBounding, area, area_length); 105 | } 106 | 107 | static int 108 | Window_init(WindowObject *self, PyObject *args, PyObject *kwds) { 109 | static XRectangle empty_area[0] = {}; 110 | static char *kwlist[] = {"display", "parent", NULL}; 111 | PyObject *display_pyobject; 112 | Window parent; 113 | 114 | if (!PyArg_ParseTupleAndKeywords( 115 | args, kwds, "O!k", kwlist, 116 | &DisplayType, &display_pyobject, &parent)) { 117 | Py_INIT_RETURN_ERROR; 118 | } 119 | 120 | if (self->display_pyobject) { 121 | Window_finalise(self); 122 | } 123 | 124 | Py_INCREF(display_pyobject); 125 | self->display_pyobject = (DisplayObject*)display_pyobject; 126 | Display *display = get_event_display(self); 127 | self->parent = parent; 128 | Window_create(self); 129 | set_subscribed_events( 130 | display, self->parent, StructureNotifyMask); 131 | set_input_mask( 132 | display, self->window, 133 | empty_area, ARRAY_LENGTH(empty_area)); 134 | set_visibility_mask( 135 | display, self->window, 136 | empty_area, ARRAY_LENGTH(empty_area)); 137 | XMapWindow(display, self->window); 138 | 139 | Py_INIT_RETURN_SUCCESS; 140 | } 141 | 142 | static void 143 | Window_dealloc(WindowObject *self) { 144 | Window_finalise(self); 145 | Py_TYPE(self)->tp_free((PyObject*)self); 146 | } 147 | 148 | static PyObject * 149 | Window_set_visibility_mask(WindowObject *self, PyObject *args, PyObject *kwds) { 150 | static char *kwlist[] = {"area", NULL}; 151 | PyObject *py_area; 152 | Py_ssize_t area_length; 153 | 154 | if (!PyArg_ParseTupleAndKeywords( 155 | args, kwds, "O!", kwlist, 156 | &PyList_Type, &py_area)) { 157 | Py_RETURN_ERROR; 158 | } 159 | 160 | area_length = PyList_Size(py_area); 161 | XRectangle area[area_length]; 162 | 163 | for (Py_ssize_t i = 0; i < area_length; i++) { 164 | short x, y; 165 | unsigned short width, height; 166 | PyObject *py_rectangle = PyList_GetItem(py_area, i); 167 | 168 | if (!PyObject_TypeCheck(py_rectangle, &PyTuple_Type)) { 169 | raise(ValueError, "Expected a list of a tuple of ints!"); 170 | } 171 | if (!PyArg_ParseTuple( 172 | py_rectangle, "hhHH", 173 | &x, &y, &width, &height)) { 174 | raise(ValueError, 175 | "Expected a rectangle to be a " 176 | "tuple of (x: int, y: int, width: int, height: int)!"); 177 | } 178 | 179 | area[i].x = x; 180 | area[i].y = y; 181 | area[i].width = width; 182 | area[i].height = height; 183 | } 184 | 185 | set_visibility_mask( 186 | get_event_display(self), 187 | self->window, 188 | area, area_length); 189 | 190 | Py_RETURN_NONE; 191 | } 192 | 193 | static PyObject * 194 | Window_draw(WindowObject *self) { 195 | XFlush(get_event_display(self)); 196 | Py_RETURN_NONE; 197 | } 198 | 199 | static PyObject * 200 | Window_get_id(WindowObject *self, void *closure) { 201 | return Py_BuildValue("k", self->window); 202 | } 203 | 204 | static PyObject * 205 | Window_get_parent_id(WindowObject *self, void *closure) { 206 | return Py_BuildValue("k", self->parent); 207 | } 208 | 209 | static PyObject * 210 | Window_get_width(WindowObject *self, void *closure) { 211 | return Py_BuildValue("I", self->width); 212 | } 213 | 214 | static PyObject * 215 | Window_get_height(WindowObject *self, void *closure) { 216 | return Py_BuildValue("I", self->height); 217 | } 218 | 219 | static PyObject * 220 | Window_process_event(WindowObject *self) { 221 | XEvent event; 222 | XAnyEvent *metadata = &event.xany; 223 | Display *display = get_event_display(self); 224 | 225 | if (!XPending(display)) { 226 | Py_RETURN_FALSE; 227 | } 228 | 229 | XPeekEvent(display, &event); 230 | 231 | if (! (event.type == Expose && metadata->window == self->window) && 232 | ! (event.type == ConfigureNotify && metadata->window == self->parent)) { 233 | Py_RETURN_FALSE; 234 | } 235 | 236 | XNextEvent(display, &event); 237 | 238 | switch (event.type) { 239 | case Expose: 240 | if(event.xexpose.count == 0) { 241 | Py_XDECREF(PyObject_CallMethod( 242 | (PyObject*)self, "draw", NULL)); 243 | } 244 | break; 245 | case ConfigureNotify: { 246 | unsigned int delta_width = 247 | ((unsigned int)event.xconfigure.width) - self->width; 248 | unsigned int delta_height = 249 | ((unsigned int)event.xconfigure.height) - self->height; 250 | 251 | if (delta_width != 0 || delta_height != 0) { 252 | self->width = (unsigned int)event.xconfigure.width; 253 | self->height = (unsigned int)event.xconfigure.height; 254 | XResizeWindow(display, self->window, self->width, self->height); 255 | } 256 | 257 | if (delta_width > 0 || delta_height > 0) { 258 | Py_XDECREF(PyObject_CallMethod( 259 | (PyObject*)self, "draw", NULL)); 260 | } 261 | else { 262 | XFlush(display); 263 | } 264 | } 265 | break; 266 | } 267 | 268 | Py_RETURN_TRUE; 269 | } 270 | 271 | static PyGetSetDef Window_properties[] = { 272 | {"id", (getter)Window_get_id, .doc = "int: the X11 id of this window."}, 273 | {"parent_id", (getter)Window_get_parent_id, .doc = "int: the X11 id of the parent window."}, 274 | {"width", (getter)Window_get_width, .doc = "int: the width of this window."}, 275 | {"height", (getter)Window_get_height, .doc = "int: the height of this window."}, 276 | {NULL} /* Sentinel */ 277 | }; 278 | 279 | static PyMethodDef Window_methods[] = { 280 | {"draw", (PyCFunction)Window_draw, 281 | METH_NOARGS, 282 | "Redraws the window."}, 283 | {"set_visibility_mask", (PyCFunction)Window_set_visibility_mask, 284 | METH_VARARGS | METH_KEYWORDS, 285 | "Specifies the part of the window which should be visible.\n" 286 | "\n" 287 | "Args:\n" 288 | " area (tuple of (tuple of (x: int, y: int, width: int, height: int))):\n" 289 | " the visible area specified by rectangles"}, 290 | {"process_event", (PyCFunction)Window_process_event, 291 | METH_NOARGS, 292 | "Processes the next X11 event if it targets this window.\n" 293 | "\n" 294 | "Returns:\n" 295 | " bool: True if an event was processed"}, 296 | {NULL} /* Sentinel */ 297 | }; 298 | 299 | PyTypeObject WindowType = { 300 | PyVarObject_HEAD_INIT(NULL, 0) 301 | .tp_name = "ueberzug.X.OverlayWindow", 302 | .tp_doc = 303 | "Basic implementation of an overlay window\n" 304 | "\n" 305 | "Args:\n" 306 | " display (ueberzug.X.Display): the X11 display\n" 307 | " parent (int): the parent window of this window", 308 | .tp_basicsize = sizeof(WindowObject), 309 | .tp_itemsize = 0, 310 | .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, 311 | .tp_new = PyType_GenericNew, 312 | .tp_init = (initproc)Window_init, 313 | .tp_dealloc = (destructor)Window_dealloc, 314 | .tp_getset = Window_properties, 315 | .tp_methods = Window_methods, 316 | }; 317 | -------------------------------------------------------------------------------- /ueberzug/X/window.h: -------------------------------------------------------------------------------- 1 | #ifndef __WINDOW_H__ 2 | #define __WINDOW_H__ 3 | #include "python.h" 4 | 5 | 6 | extern PyTypeObject WindowType; 7 | #endif 8 | -------------------------------------------------------------------------------- /ueberzug/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "18.3.1" 2 | __license__ = "GPLv3" 3 | __description__ = "ueberzug is a command line util which allows to display images in combination with X11" 4 | __url_repository__ = "https://github.com/ueber-devel/ueberzug" 5 | __url_bug_reports__ = "https://github.com/ueber-devel/ueberzug/issues" 6 | __url_project__ = __url_repository__ 7 | __author__ = "" 8 | -------------------------------------------------------------------------------- /ueberzug/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Usage: 3 | ueberzug layer [options] 4 | ueberzug library 5 | ueberzug version 6 | ueberzug query_windows PIDS ... 7 | 8 | Routines: 9 | layer Display images 10 | library Prints the path to the bash library 11 | version Prints the project version 12 | query_windows Orders ueberzug to search for windows. 13 | Only for internal use. 14 | 15 | Layer options: 16 | -p, --parser one of json, simple, bash 17 | json: Json-Object per line 18 | simple: Key-Values separated by a tab 19 | bash: associative array dumped via `declare -p` 20 | [default: json] 21 | -l, --loader one of synchronous, thread, process 22 | synchronous: load images right away 23 | thread: load images in threads 24 | process: load images in additional processes 25 | [default: thread] 26 | -s, --silent print stderr to /dev/null 27 | 28 | 29 | License: 30 | ueberzug 31 | This program comes with ABSOLUTELY NO WARRANTY. 32 | This is free software, and you are welcome to redistribute it 33 | under certain conditions. 34 | """ 35 | import docopt 36 | 37 | 38 | def main(): 39 | options = docopt.docopt(__doc__) 40 | module = None 41 | 42 | if options["layer"]: 43 | import ueberzug.layer as layer 44 | 45 | module = layer 46 | elif options["library"]: 47 | import ueberzug.library as library 48 | 49 | module = library 50 | elif options["query_windows"]: 51 | import ueberzug.query_windows as query_windows 52 | 53 | module = query_windows 54 | elif options["version"]: 55 | import ueberzug.version as version 56 | 57 | module = version 58 | 59 | module.main(options) 60 | 61 | 62 | if __name__ == "__main__": 63 | main() 64 | -------------------------------------------------------------------------------- /ueberzug/action.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import enum 3 | import os.path 4 | 5 | import attr 6 | 7 | import ueberzug.geometry as geometry 8 | import ueberzug.scaling as scaling 9 | import ueberzug.conversion as conversion 10 | 11 | 12 | @attr.s 13 | class Action(metaclass=abc.ABCMeta): 14 | """Describes the structure used to define actions classes. 15 | 16 | Defines a general interface used to implement the building of commands 17 | and their execution. 18 | """ 19 | 20 | action = attr.ib( 21 | type=str, 22 | default=attr.Factory( 23 | lambda self: self.get_action_name(), takes_self=True 24 | ), 25 | ) 26 | 27 | @staticmethod 28 | @abc.abstractmethod 29 | def get_action_name(): 30 | """Returns the constant name which is associated to this action.""" 31 | raise NotImplementedError() 32 | 33 | @abc.abstractmethod 34 | async def apply(self, windows, view, tools): 35 | """Executes the action on the passed view and windows.""" 36 | raise NotImplementedError() 37 | 38 | 39 | @attr.s(kw_only=True) 40 | class Drawable: 41 | """Defines the attributes of drawable actions.""" 42 | 43 | draw = attr.ib(default=True, converter=conversion.to_bool) 44 | synchronously_draw = attr.ib(default=False, converter=conversion.to_bool) 45 | 46 | 47 | @attr.s(kw_only=True) 48 | class Identifiable: 49 | """Defines the attributes of actions 50 | which are associated to an identifier. 51 | """ 52 | 53 | identifier = attr.ib(type=str) 54 | 55 | 56 | @attr.s(kw_only=True) 57 | class DrawAction(Action, Drawable, metaclass=abc.ABCMeta): 58 | """Defines actions which redraws all windows.""" 59 | 60 | # pylint: disable=abstract-method 61 | __redraw_scheduled = False 62 | 63 | @staticmethod 64 | def schedule_redraw(windows): 65 | """Creates a async function which redraws every window 66 | if there is no unexecuted function 67 | (returned by this function) 68 | which does the same. 69 | 70 | Args: 71 | windows (batch.BatchList of ui.CanvasWindow): 72 | the windows to be redrawn 73 | 74 | Returns: 75 | function: the redraw function or None 76 | """ 77 | if not DrawAction.__redraw_scheduled: 78 | DrawAction.__redraw_scheduled = True 79 | 80 | async def redraw(): 81 | windows.draw() 82 | DrawAction.__redraw_scheduled = False 83 | 84 | return redraw() 85 | return None 86 | 87 | async def apply(self, windows, view, tools): 88 | if self.draw: 89 | import asyncio 90 | 91 | if self.synchronously_draw: 92 | windows.draw() 93 | # force coroutine switch 94 | await asyncio.sleep(0) 95 | return 96 | 97 | function = self.schedule_redraw(windows) 98 | if function: 99 | asyncio.ensure_future(function) 100 | 101 | 102 | @attr.s(kw_only=True) 103 | class ImageAction(DrawAction, Identifiable, metaclass=abc.ABCMeta): 104 | """Defines actions which are related to images.""" 105 | 106 | # pylint: disable=abstract-method 107 | pass 108 | 109 | 110 | @attr.s(kw_only=True) 111 | class AddImageAction(ImageAction): 112 | """Displays the image according to the passed option. 113 | If there's already an image with the given identifier 114 | it's going to be replaced. 115 | """ 116 | 117 | x = attr.ib(type=int, converter=int) 118 | y = attr.ib(type=int, converter=int) 119 | path = attr.ib(type=str) 120 | width = attr.ib(type=int, converter=int, default=0) 121 | height = attr.ib(type=int, converter=int, default=0) 122 | scaling_position_x = attr.ib(type=float, converter=float, default=0) 123 | scaling_position_y = attr.ib(type=float, converter=float, default=0) 124 | scaler = attr.ib( 125 | type=str, default=scaling.ContainImageScaler.get_scaler_name() 126 | ) 127 | # deprecated 128 | max_width = attr.ib(type=int, converter=int, default=0) 129 | max_height = attr.ib(type=int, converter=int, default=0) 130 | 131 | @staticmethod 132 | def get_action_name(): 133 | return "add" 134 | 135 | def __attrs_post_init__(self): 136 | self.width = self.max_width or self.width 137 | self.height = self.max_height or self.height 138 | # attrs doesn't support overriding the init method 139 | # pylint: disable=attribute-defined-outside-init 140 | self.__scaler_class = None 141 | self.__last_modified = None 142 | 143 | @property 144 | def scaler_class(self): 145 | """scaling.ImageScaler: the used scaler class of this placement""" 146 | if self.__scaler_class is None: 147 | self.__scaler_class = scaling.ScalerOption(self.scaler).scaler_class 148 | return self.__scaler_class 149 | 150 | @property 151 | def last_modified(self): 152 | """float: the last modified time of the image""" 153 | if self.__last_modified is None: 154 | self.__last_modified = os.path.getmtime(self.path) 155 | return self.__last_modified 156 | 157 | def is_same_image(self, old_placement): 158 | """Determines whether the placement contains the same image 159 | after applying the changes of this command. 160 | 161 | Args: 162 | old_placement (ui.CanvasWindow.Placement): 163 | the old data of the placement 164 | 165 | Returns: 166 | bool: True if it's the same file 167 | """ 168 | return old_placement and not ( 169 | old_placement.last_modified < self.last_modified 170 | or self.path != old_placement.path 171 | ) 172 | 173 | def is_full_reload_required( 174 | self, old_placement, screen_columns, screen_rows 175 | ): 176 | """Determines whether it's required to fully reload 177 | the image of the placement to properly render the placement. 178 | 179 | Args: 180 | old_placement (ui.CanvasWindow.Placement): 181 | the old data of the placement 182 | screen_columns (float): 183 | the maximum amount of columns the screen can display 184 | screen_rows (float): 185 | the maximum amount of rows the screen can display 186 | 187 | Returns: 188 | bool: True if the image should be reloaded 189 | """ 190 | return old_placement and ( 191 | ( 192 | not self.scaler_class.is_indulgent_resizing() 193 | and old_placement.scaler.is_indulgent_resizing() 194 | ) 195 | or (old_placement.width <= screen_columns < self.width) 196 | or (old_placement.height <= screen_rows < self.height) 197 | ) 198 | 199 | def is_partly_reload_required( 200 | self, old_placement, screen_columns, screen_rows 201 | ): 202 | """Determines whether it's required to partly reload 203 | the image of the placement to render the placement more quickly. 204 | 205 | Args: 206 | old_placement (ui.CanvasWindow.Placement): 207 | the old data of the placement 208 | screen_columns (float): 209 | the maximum amount of columns the screen can display 210 | screen_rows (float): 211 | the maximum amount of rows the screen can display 212 | 213 | Returns: 214 | bool: True if the image should be reloaded 215 | """ 216 | return old_placement and ( 217 | ( 218 | self.scaler_class.is_indulgent_resizing() 219 | and not old_placement.scaler.is_indulgent_resizing() 220 | ) 221 | or (self.width <= screen_columns < old_placement.width) 222 | or (self.height <= screen_rows < old_placement.height) 223 | ) 224 | 225 | async def apply(self, windows, view, tools): 226 | try: 227 | import ueberzug.ui as ui 228 | import ueberzug.loading as loading 229 | 230 | old_placement = view.media.pop(self.identifier, None) 231 | cache = old_placement and old_placement.cache 232 | image = old_placement and old_placement.image 233 | 234 | max_font_width = max( 235 | map(lambda i: i or 0, windows.parent_info.font_width or [0]) 236 | ) 237 | max_font_height = max( 238 | map(lambda i: i or 0, windows.parent_info.font_height or [0]) 239 | ) 240 | font_size_available = max_font_width and max_font_height 241 | screen_columns = ( 242 | font_size_available and view.screen_width / max_font_width 243 | ) 244 | screen_rows = ( 245 | font_size_available and view.screen_height / max_font_height 246 | ) 247 | 248 | # By default images are only stored up to a resolution which 249 | # is about as big as the screen resolution. 250 | # (loading.CoverPostLoadImageProcessor) 251 | # The principle of spatial locality does not apply to 252 | # resize operations of images with big resolutions 253 | # which is why those operations should be applied 254 | # to a resized version of those images. 255 | # Sometimes we still need all pixels e.g. 256 | # if the image scaler crop is used. 257 | # So sometimes it's required to fully load them 258 | # and sometimes it's not required anymore which is 259 | # why they should be partly reloaded 260 | # (to speed up the resize operations again). 261 | if ( 262 | not self.is_same_image(old_placement) 263 | or ( 264 | font_size_available 265 | and self.is_full_reload_required( 266 | old_placement, screen_columns, screen_rows 267 | ) 268 | ) 269 | or ( 270 | font_size_available 271 | and self.is_partly_reload_required( 272 | old_placement, screen_columns, screen_rows 273 | ) 274 | ) 275 | ): 276 | upper_bound_size = None 277 | image_post_load_processor = None 278 | if ( 279 | self.scaler_class != scaling.CropImageScaler 280 | and font_size_available 281 | ): 282 | upper_bound_size = ( 283 | max_font_width * self.width, 284 | max_font_height * self.height, 285 | ) 286 | if ( 287 | self.scaler_class != scaling.CropImageScaler 288 | and font_size_available 289 | and self.width <= screen_columns 290 | and self.height <= screen_rows 291 | ): 292 | image_post_load_processor = ( 293 | loading.CoverPostLoadImageProcessor( 294 | view.screen_width, view.screen_height 295 | ) 296 | ) 297 | image = tools.loader.load( 298 | self.path, upper_bound_size, image_post_load_processor 299 | ) 300 | cache = None 301 | 302 | view.media[self.identifier] = ui.CanvasWindow.Placement( 303 | self.x, 304 | self.y, 305 | self.width, 306 | self.height, 307 | geometry.Point( 308 | self.scaling_position_x, self.scaling_position_y 309 | ), 310 | self.scaler_class(), 311 | self.path, 312 | image, 313 | self.last_modified, 314 | cache, 315 | ) 316 | finally: 317 | await super().apply(windows, view, tools) 318 | 319 | 320 | @attr.s(kw_only=True) 321 | class RemoveImageAction(ImageAction): 322 | """Removes the image with the passed identifier.""" 323 | 324 | @staticmethod 325 | def get_action_name(): 326 | return "remove" 327 | 328 | async def apply(self, windows, view, tools): 329 | try: 330 | if self.identifier in view.media: 331 | del view.media[self.identifier] 332 | finally: 333 | await super().apply(windows, view, tools) 334 | 335 | 336 | @enum.unique 337 | class Command(str, enum.Enum): 338 | ADD = AddImageAction 339 | REMOVE = RemoveImageAction 340 | 341 | def __new__(cls, action_class): 342 | inst = str.__new__(cls) 343 | inst._value_ = action_class.get_action_name() 344 | inst.action_class = action_class 345 | return inst 346 | -------------------------------------------------------------------------------- /ueberzug/batch.py: -------------------------------------------------------------------------------- 1 | """This module defines util classes 2 | which allow to execute operations 3 | for each element of a list of objects of the same class. 4 | """ 5 | 6 | import abc 7 | import collections.abc 8 | import functools 9 | 10 | 11 | class SubclassingMeta(abc.ABCMeta): 12 | """Metaclass which creates a subclass for each instance. 13 | 14 | As decorators only work 15 | if the class object contains the declarations, 16 | we need to create a subclass for each different type 17 | if we want to dynamically use them. 18 | """ 19 | 20 | SUBCLASS_IDENTIFIER = "__subclassed__" 21 | 22 | def __call__(cls, *args, **kwargs): 23 | if hasattr(cls, SubclassingMeta.SUBCLASS_IDENTIFIER): 24 | return super().__call__(*args, **kwargs) 25 | 26 | subclass = type( 27 | cls.__name__, 28 | (cls,), 29 | { 30 | SubclassingMeta.SUBCLASS_IDENTIFIER: SubclassingMeta.SUBCLASS_IDENTIFIER 31 | }, 32 | ) 33 | return subclass(*args, **kwargs) 34 | 35 | 36 | class BatchList(collections.abc.MutableSequence, metaclass=SubclassingMeta): 37 | """BatchList provides the execution of methods and field access 38 | for each element of a list of instances of the same class 39 | in a similar way to one of these instances it would. 40 | """ 41 | 42 | __attributes_declared = False 43 | 44 | class BatchMember: 45 | def __init__(self, outer, name): 46 | """ 47 | Args: 48 | outer (BatchList): Outer class instance 49 | """ 50 | self.outer = outer 51 | self.name = name 52 | 53 | class BatchField(BatchMember): 54 | def __get__(self, owner_instance, owner_class): 55 | return BatchList( 56 | [ 57 | instance.__getattribute__(self.name) 58 | for instance in self.outer 59 | ] 60 | ) 61 | 62 | def __set__(self, owner_instance, value): 63 | for instance in self.outer: 64 | instance.__setattr__(self.name, value) 65 | 66 | def __delete__(self, instance): 67 | for instance in self.outer: 68 | instance.__delattr__(self.name) 69 | 70 | class BatchMethod(BatchMember): 71 | def __call__(self, *args, **kwargs): 72 | return BatchList( 73 | [ 74 | instance.__getattribute__(self.name)(*args, **kwargs) 75 | for instance in self.outer 76 | ] 77 | ) 78 | 79 | def __init__(self, collection: list): 80 | """ 81 | Args: 82 | collection (List): List of target instances 83 | """ 84 | self.__collection = collection.copy() 85 | self.__initialized = False 86 | self.__type = None 87 | self.entered = False 88 | self.__attributes_declared = True 89 | self.__init_members__() 90 | 91 | def __call__(self, *args, **kwargs): 92 | if self.__initialized: 93 | raise TypeError("'%s' object is not callable" % self.__type) 94 | return BatchList([]) 95 | 96 | def __getattr__(self, name): 97 | if self.__initialized: 98 | return AttributeError( 99 | "'%s' object has no attribute '%s'" % (self.__type, name) 100 | ) 101 | return BatchList([]) 102 | 103 | def __setattr__(self, name, value): 104 | if ( 105 | not self.__attributes_declared 106 | or self.__initialized 107 | or not isinstance(getattr(self, name), BatchList) 108 | ): 109 | super().__setattr__(name, value) 110 | 111 | def __init_members__(self): 112 | if self.__collection and not self.__initialized: 113 | # Note: We can't simply use the class, 114 | # as the attributes exists only after the instantiation 115 | self.__initialized = True 116 | instance = self.__collection[0] 117 | self.__type = type(instance) 118 | self.__init_attributes__(instance) 119 | self.__init_methods__(instance) 120 | 121 | def __declare_decorator__(self, name, decorator): 122 | setattr(type(self), name, decorator) 123 | 124 | def __init_attributes__(self, target_instance): 125 | for name in self.__get_public_attributes(target_instance): 126 | self.__declare_decorator__(name, BatchList.BatchField(self, name)) 127 | 128 | @staticmethod 129 | def __get_public_attributes(target_instance): 130 | attributes = ( 131 | vars(target_instance) 132 | if hasattr(target_instance, "__dict__") 133 | else [] 134 | ) 135 | return (name for name in attributes if not name.startswith("_")) 136 | 137 | @staticmethod 138 | @functools.lru_cache() 139 | def __get_public_members(target_type): 140 | members = { 141 | name: member 142 | for type_members in map(vars, reversed(target_type.mro())) 143 | for name, member in type_members.items() 144 | } 145 | return { 146 | name: member 147 | for name, member in members.items() 148 | if not name.startswith("_") 149 | } 150 | 151 | def __init_methods__(self, target_instance): 152 | public_members = self.__get_public_members(type(target_instance)) 153 | for name, value in public_members.items(): 154 | if callable(value): 155 | self.__declare_decorator__( 156 | name, BatchList.BatchMethod(self, name) 157 | ) 158 | else: 159 | # should be an decorator 160 | self.__declare_decorator__( 161 | name, BatchList.BatchField(self, name) 162 | ) 163 | 164 | def __enter__(self): 165 | self.entered = True 166 | return BatchList([instance.__enter__() for instance in self]) 167 | 168 | def __exit__(self, *args): 169 | for instance in self: 170 | instance.__exit__(*args) 171 | 172 | def __iadd__(self, other): 173 | if self.entered: 174 | for i in other: 175 | i.__enter__() 176 | self.__collection.__iadd__(other) 177 | self.__init_members__() 178 | return self 179 | 180 | def append(self, item): 181 | if self.entered: 182 | item.__enter__() 183 | self.__collection.append(item) 184 | self.__init_members__() 185 | 186 | def insert(self, index, item): 187 | if self.entered: 188 | item.__enter__() 189 | self.__collection.insert(index, item) 190 | self.__init_members__() 191 | 192 | def extend(self, iterable): 193 | for item in iterable: 194 | self.append(item) 195 | 196 | def __add__(self, other): 197 | return BatchList(self.__collection.__add__(other)) 198 | 199 | def reverse(self): 200 | self.__collection.reverse() 201 | 202 | def clear(self): 203 | if self.entered: 204 | for i in self.__collection: 205 | i.__exit__(None, None, None) 206 | self.__collection.clear() 207 | 208 | def copy(self): 209 | return BatchList(self.__collection.copy()) 210 | 211 | def pop(self, *args): 212 | result = self.__collection.pop(*args) 213 | 214 | if self.entered: 215 | result.__exit__(None, None, None) 216 | 217 | return result 218 | 219 | def remove(self, value): 220 | if self.entered: 221 | value.__exit__(None, None, None) 222 | return self.__collection.remove(value) 223 | 224 | def __isub__(self, other): 225 | for i in other: 226 | self.remove(i) 227 | return self 228 | 229 | def __sub__(self, other): 230 | copied = self.copy() 231 | copied -= other 232 | return copied 233 | 234 | def __len__(self): 235 | return len(self.__collection) 236 | 237 | def __delitem__(self, key): 238 | return self.pop(key) 239 | 240 | def __setitem__(self, key, value): 241 | self.pop(key) 242 | self.insert(key, value) 243 | 244 | def __getitem__(self, key): 245 | return self.__collection[key] 246 | 247 | def count(self, *args, **kwargs): 248 | return self.__collection.count(*args, **kwargs) 249 | 250 | def index(self, *args, **kwargs): 251 | return self.__collection.index(*args, **kwargs) 252 | 253 | def __iter__(self): 254 | return iter(self.__collection) 255 | 256 | def __contains__(self, item): 257 | return item in self.__collection 258 | 259 | def __reversed__(self): 260 | return reversed(self.__collection) 261 | 262 | 263 | if __name__ == "__main__": 264 | 265 | class FooBar: 266 | def __init__(self, a, b, c): 267 | self.mhm = a 268 | self.b = b 269 | self.c = c 270 | 271 | def ok(self): 272 | return self.b 273 | 274 | @property 275 | def prop(self): 276 | return self.c 277 | 278 | # print attributes 279 | # print(vars(FooBar())) 280 | # print properties and methods 281 | # print(vars(FooBar).keys()) 282 | blist = BatchList([FooBar("foo", "bar", "yay")]) 283 | blist += [FooBar("foobar", "barfoo", "yay foobar")] 284 | print("mhm", blist.mhm) 285 | print("prop", blist.prop) 286 | # print('ok', blist.ok) 287 | print("ok call", blist.ok()) 288 | -------------------------------------------------------------------------------- /ueberzug/conversion.py: -------------------------------------------------------------------------------- 1 | # extracted from distutils 2 | # under license: MIT 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining a copy 5 | # of this software and associated documentation files (the "Software"), to 6 | # deal in the Software without restriction, including without limitation the 7 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 8 | # sell copies of the Software, and to permit persons to whom the Software is 9 | # furnished to do so, subject to the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be included in 12 | # all copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 20 | # IN THE SOFTWARE. 21 | # 22 | def strtobool(val): 23 | """Convert a string representation of truth to true (1) or false (0). 24 | 25 | True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values 26 | are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if 27 | 'val' is anything else. 28 | """ 29 | val = val.lower() 30 | if val in ("y", "yes", "t", "true", "on", "1"): 31 | return 1 32 | elif val in ("n", "no", "f", "false", "off", "0"): 33 | return 0 34 | else: 35 | raise ValueError(f"invalid truth value {val!r}") 36 | 37 | 38 | def to_bool(value): 39 | """Converts a String to a Boolean. 40 | 41 | Args: 42 | value (str or bool): a boolean or a string representing a boolean 43 | 44 | Returns: 45 | bool: the evaluated boolean 46 | """ 47 | return value if isinstance(value, bool) else bool(strtobool(value)) 48 | -------------------------------------------------------------------------------- /ueberzug/files.py: -------------------------------------------------------------------------------- 1 | import select 2 | import fcntl 3 | import contextlib 4 | import pathlib 5 | 6 | 7 | class LineReader: 8 | """Async iterator class used to read lines""" 9 | 10 | def __init__(self, loop, file): 11 | self._loop = loop 12 | self._file = file 13 | 14 | @staticmethod 15 | async def read_line(loop, file): 16 | """Waits asynchronously for a line and returns it""" 17 | return await loop.run_in_executor(None, file.readline) 18 | 19 | def __aiter__(self): 20 | return self 21 | 22 | async def __anext__(self): 23 | if select.select([self._file], [], [], 0)[0]: 24 | return self._file.readline() 25 | return await LineReader.read_line(self._loop, self._file) 26 | 27 | 28 | @contextlib.contextmanager 29 | def lock(path: pathlib.PosixPath): 30 | """Creates a lock file, 31 | a file protected from beeing used by other processes. 32 | (The lock file isn't the same as the file of the passed path.) 33 | 34 | Args: 35 | path (pathlib.PosixPath): path to the file 36 | """ 37 | path = path.with_suffix(".lock") 38 | 39 | if not path.exists(): 40 | path.touch() 41 | 42 | with path.open("r+") as lock_file: 43 | try: 44 | fcntl.lockf(lock_file.fileno(), fcntl.LOCK_EX) 45 | yield lock_file 46 | finally: 47 | fcntl.lockf(lock_file.fileno(), fcntl.LOCK_UN) 48 | -------------------------------------------------------------------------------- /ueberzug/geometry.py: -------------------------------------------------------------------------------- 1 | """Module which defines classes all about geometry""" 2 | 3 | 4 | class Point: 5 | """Data class which holds a coordinate.""" 6 | 7 | def __init__(self, x, y): 8 | self.x = x 9 | self.y = y 10 | 11 | def __eq__(self, other): 12 | return (self.x, self.y) == (other.x, other.y) 13 | 14 | 15 | class Distance: 16 | """Data class which holds the distance values in all directions.""" 17 | 18 | def __init__(self, top=0, left=0, bottom=0, right=0): 19 | self.top = top 20 | self.left = left 21 | self.bottom = bottom 22 | self.right = right 23 | -------------------------------------------------------------------------------- /ueberzug/layer.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import sys 3 | import os 4 | import asyncio 5 | import signal 6 | import pathlib 7 | import re 8 | import tempfile 9 | 10 | import ueberzug.thread as thread 11 | import ueberzug.files as files 12 | import ueberzug.xutil as xutil 13 | import ueberzug.parser as parser 14 | import ueberzug.ui as ui 15 | import ueberzug.batch as batch 16 | import ueberzug.action as action 17 | import ueberzug.tmux_util as tmux_util 18 | import ueberzug.geometry as geometry 19 | import ueberzug.loading as loading 20 | import ueberzug.X as X 21 | 22 | 23 | async def process_xevents(loop, display, windows): 24 | """Coroutine which processes X11 events""" 25 | async for _ in xutil.Events(loop, display): 26 | if not any(windows.process_event()): 27 | display.discard_event() 28 | 29 | 30 | async def process_commands( 31 | loop, shutdown_routine_factory, windows, view, tools 32 | ): 33 | """Coroutine which processes the input of stdin""" 34 | try: 35 | async for line in files.LineReader(loop, sys.stdin): 36 | if not line: 37 | break 38 | 39 | try: 40 | line = re.sub("\\\\", "", line) 41 | 42 | data = tools.parser.parse(line[:-1]) 43 | command = action.Command(data["action"]) 44 | await command.action_class(**data).apply(windows, view, tools) 45 | except (OSError, KeyError, ValueError, TypeError) as error: 46 | tools.error_handler(error) 47 | finally: 48 | asyncio.ensure_future(shutdown_routine_factory()) 49 | 50 | 51 | async def query_windows(display: X.Display, window_factory, windows, view): 52 | """Signal handler for SIGUSR1. 53 | Searches for added and removed tmux clients. 54 | Added clients: additional windows will be mapped 55 | Removed clients: existing windows will be destroyed 56 | """ 57 | parent_window_infos = xutil.get_parent_window_infos(display) 58 | view.offset = tmux_util.get_offset() 59 | map_parent_window_id_info = { 60 | info.window_id: info for info in parent_window_infos 61 | } 62 | parent_window_ids = map_parent_window_id_info.keys() 63 | map_current_windows = {window.parent_id: window for window in windows} 64 | current_window_ids = map_current_windows.keys() 65 | diff_window_ids = parent_window_ids ^ current_window_ids 66 | added_window_ids = diff_window_ids & parent_window_ids 67 | removed_window_ids = diff_window_ids & current_window_ids 68 | draw = added_window_ids or removed_window_ids 69 | 70 | if added_window_ids: 71 | windows += window_factory.create( 72 | *[map_parent_window_id_info.get(wid) for wid in added_window_ids] 73 | ) 74 | 75 | if removed_window_ids: 76 | windows -= [map_current_windows.get(wid) for wid in removed_window_ids] 77 | 78 | if draw: 79 | windows.draw() 80 | 81 | 82 | async def reset_terminal_info(windows): 83 | """Signal handler for SIGWINCH. 84 | Resets the terminal information of all windows. 85 | """ 86 | windows.reset_terminal_info() 87 | 88 | 89 | async def shutdown(loop): 90 | try: 91 | all_tasks = asyncio.all_tasks() 92 | current_task = asyncio.current_task() 93 | except AttributeError: 94 | all_tasks = asyncio.Task.all_tasks() 95 | current_task = asyncio.tasks.Task.current_task() 96 | 97 | tasks = [task for task in all_tasks if task is not current_task] 98 | list(map(lambda task: task.cancel(), tasks)) 99 | await asyncio.gather(*tasks, return_exceptions=True) 100 | loop.stop() 101 | 102 | 103 | def shutdown_factory(loop): 104 | return lambda: asyncio.ensure_future(shutdown(loop)) 105 | 106 | 107 | def setup_tmux_hooks(): 108 | """Registers tmux hooks which are 109 | required to notice a change in the visibility 110 | of the pane this program runs in. 111 | Also it's required to notice new tmux clients 112 | displaying our pane. 113 | 114 | Returns: 115 | function which unregisters the registered hooks 116 | """ 117 | events = ( 118 | "client-session-changed", 119 | "session-window-changed", 120 | "pane-mode-changed", 121 | "client-detached", 122 | ) 123 | 124 | xdg_cache_dir = os.environ.get("XDG_CACHE_HOME") 125 | if xdg_cache_dir: 126 | lock_directory_path = pathlib.PosixPath.joinpath( 127 | pathlib.PosixPath(xdg_cache_dir), pathlib.PosixPath("ueberzug") 128 | ) 129 | else: 130 | lock_directory_path = pathlib.PosixPath.joinpath( 131 | pathlib.PosixPath.home(), pathlib.PosixPath(".cache/ueberzug") 132 | ) 133 | 134 | lock_file_path = lock_directory_path / tmux_util.get_session_id() 135 | own_pid = str(os.getpid()) 136 | command_template = "ueberzug query_windows " 137 | 138 | try: 139 | lock_directory_path.mkdir() 140 | except FileExistsError: 141 | pass 142 | 143 | def update_hooks(pid_file, pids): 144 | pids = " ".join(pids) 145 | command = command_template + pids 146 | 147 | pid_file.seek(0) 148 | pid_file.truncate() 149 | pid_file.write(pids) 150 | pid_file.flush() 151 | 152 | for event in events: 153 | if pids: 154 | tmux_util.register_hook(event, command) 155 | else: 156 | tmux_util.unregister_hook(event) 157 | 158 | def remove_hooks(): 159 | """Removes the hooks registered by the outer function.""" 160 | with files.lock(lock_file_path) as lock_file: 161 | pids = set(lock_file.read().split()) 162 | pids.discard(own_pid) 163 | update_hooks(lock_file, pids) 164 | 165 | with files.lock(lock_file_path) as lock_file: 166 | pids = set(lock_file.read().split()) 167 | pids.add(own_pid) 168 | update_hooks(lock_file, pids) 169 | 170 | return remove_hooks 171 | 172 | 173 | def error_processor_factory(parser): 174 | def wrapper(exception): 175 | return process_error(parser, exception) 176 | 177 | return wrapper 178 | 179 | 180 | def process_error(parser, exception): 181 | print( 182 | parser.unparse( 183 | { 184 | "type": "error", 185 | "name": type(exception).__name__, 186 | "message": str(exception), 187 | # 'stack': traceback.format_exc() 188 | } 189 | ), 190 | file=sys.stderr, 191 | ) 192 | 193 | 194 | class View: 195 | """Data class which holds meta data about the screen""" 196 | 197 | def __init__(self): 198 | self.offset = geometry.Distance() 199 | self.media = {} 200 | self.screen_width = 0 201 | self.screen_height = 0 202 | 203 | 204 | class Tools: 205 | """Data class which holds helper functions, ...""" 206 | 207 | def __init__(self, loader, parser, error_handler): 208 | self.loader = loader 209 | self.parser = parser 210 | self.error_handler = error_handler 211 | 212 | 213 | def main(options): 214 | if options["--silent"]: 215 | try: 216 | outfile = os.open(os.devnull, os.O_WRONLY) 217 | os.close(sys.stderr.fileno()) 218 | os.dup2(outfile, sys.stderr.fileno()) 219 | finally: 220 | os.close(outfile) 221 | 222 | loop = asyncio.get_event_loop() 223 | executor = thread.DaemonThreadPoolExecutor(max_workers=2) 224 | parser_object = parser.ParserOption(options["--parser"]).parser_class() 225 | image_loader = loading.ImageLoaderOption(options["--loader"]).loader_class() 226 | error_handler = error_processor_factory(parser_object) 227 | view = View() 228 | tools = Tools(image_loader, parser_object, error_handler) 229 | X.init_threads() 230 | display = X.Display() 231 | window_factory = ui.CanvasWindow.Factory(display, view) 232 | window_infos = xutil.get_parent_window_infos(display) 233 | windows = batch.BatchList(window_factory.create(*window_infos)) 234 | image_loader.register_error_handler(error_handler) 235 | view.screen_width = display.screen_width 236 | view.screen_height = display.screen_height 237 | 238 | if tmux_util.is_used(): 239 | atexit.register(setup_tmux_hooks()) 240 | view.offset = tmux_util.get_offset() 241 | 242 | with windows, image_loader: 243 | loop.set_default_executor(executor) 244 | 245 | for sig in (signal.SIGINT, signal.SIGTERM): 246 | loop.add_signal_handler(sig, shutdown_factory(loop)) 247 | 248 | loop.add_signal_handler( 249 | signal.SIGUSR1, 250 | lambda: asyncio.ensure_future( 251 | query_windows(display, window_factory, windows, view) 252 | ), 253 | ) 254 | 255 | loop.add_signal_handler( 256 | signal.SIGWINCH, 257 | lambda: asyncio.ensure_future(reset_terminal_info(windows)), 258 | ) 259 | 260 | asyncio.ensure_future(process_xevents(loop, display, windows)) 261 | asyncio.ensure_future( 262 | process_commands(loop, shutdown_factory(loop), windows, view, tools) 263 | ) 264 | 265 | try: 266 | loop.run_forever() 267 | finally: 268 | loop.close() 269 | executor.shutdown(wait=False) 270 | -------------------------------------------------------------------------------- /ueberzug/lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ueber-devel/ueberzug/9483d92338322d90e26abf545f01390b3831be35/ueberzug/lib/__init__.py -------------------------------------------------------------------------------- /ueberzug/lib/lib.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | function String::trim { 3 | while read line; do 4 | printf %s\\n "$line" 5 | done 6 | } 7 | 8 | 9 | function Error::raise { 10 | local -a stack=() 11 | local stack_size=${#FUNCNAME[@]} 12 | 13 | for ((i = 1; i < $stack_size; i++)); do 14 | local caller="${FUNCNAME[$i]}" 15 | local line_number="${BASH_LINENO[$(( i - 1 ))]}" 16 | local file="${BASH_SOURCE[$i]}" 17 | [ -z "$caller" ] && caller=main 18 | 19 | stack+=( 20 | # note: lines ending with a backslash are counted as a single line 21 | $'\t'"File ${file}, line ${line_number}, in ${caller}" 22 | $'\t\t'"`String::trim < "${file}" | head --lines "${line_number}" | tail --lines 1`" 23 | ) 24 | done 25 | 26 | printf '%s\n' "${@}" "${stack[@]}" 1>&2 27 | exit 1 28 | } 29 | 30 | 31 | function Map::escape_items { 32 | while (( "${#@}" > 0 )); do 33 | local key="${1%%=[^=]*}" 34 | local value="${1#[^=]*=}" 35 | printf "%s=%q " "$key" "$value" 36 | shift 37 | done 38 | } 39 | 40 | 41 | function ImageLayer { 42 | ueberzug layer -p bash "$@" 43 | } 44 | 45 | function ImageLayer::__build_command { 46 | local -a required_keys=( $1 ); shift 47 | local -A data="( `Map::escape_items "$@"` )" 48 | 49 | for key in "${required_keys[@]}"; do 50 | # see: https://stackoverflow.com/a/13221491 51 | if ! [ ${data["$key"]+exists} ]; then 52 | Error::raise "Key '$key' missing!" 53 | fi 54 | done 55 | 56 | declare -p data 57 | } 58 | 59 | function ImageLayer::build_command { 60 | local action="$1"; shift 61 | local required_keys="$1"; shift 62 | ImageLayer::__build_command "action $required_keys" [action]="$action" "$@" 63 | } 64 | 65 | function ImageLayer::add { 66 | ImageLayer::build_command add "identifier x y path" "$@" 67 | } 68 | 69 | function ImageLayer::remove { 70 | ImageLayer::build_command remove "identifier" "$@" 71 | } 72 | -------------------------------------------------------------------------------- /ueberzug/lib/v0/__init__.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import enum 3 | import subprocess 4 | import threading 5 | import json 6 | import collections 7 | import contextlib 8 | import os 9 | import signal 10 | 11 | import attr 12 | 13 | import ueberzug.action as _action 14 | from ueberzug.scaling import ScalerOption 15 | from ueberzug.loading import ImageLoaderOption 16 | 17 | 18 | class Visibility(enum.Enum): 19 | """Enum which defines the different visibility states.""" 20 | 21 | VISIBLE = enum.auto() 22 | INVISIBLE = enum.auto() 23 | 24 | 25 | class Placement: 26 | """The class which represent a (image) placement on the canvas. 27 | 28 | Attributes: 29 | Every parameter defined by the add action is an attribute. 30 | 31 | Raises: 32 | IOError: on assign a new value to an attribute 33 | if stdin of the ueberzug process was closed 34 | during an attempt of writing to it. 35 | """ 36 | 37 | __initialised = False 38 | __DEFAULT_VALUES = {str: "", int: 0} 39 | __ATTRIBUTES = { 40 | attribute.name: attribute 41 | for attribute in attr.fields(_action.AddImageAction) 42 | } 43 | __EMPTY_BASE_PAIRS = ( 44 | lambda attributes, default_values: { 45 | attribute.name: default_values[attribute.type] 46 | for attribute in attributes.values() 47 | if (attribute.default == attr.NOTHING and attribute.init) 48 | } 49 | )(__ATTRIBUTES, __DEFAULT_VALUES) 50 | 51 | def __init__( 52 | self, 53 | canvas, 54 | identifier, 55 | visibility: Visibility = Visibility.INVISIBLE, 56 | **kwargs, 57 | ): 58 | """ 59 | Args: 60 | canvas (Canvas): the canvas this placement belongs to 61 | identifier (str): a string which uniquely identifies this placement 62 | visibility (Visibility): the initial visibility of this placement 63 | (all required parameters need to be set 64 | if it's visible) 65 | kwargs: parameters of the add action 66 | """ 67 | self.__canvas = canvas 68 | self.__identifier = identifier 69 | self.__visibility = False 70 | self.__data = {} 71 | self.__initialised = True 72 | for key, value in kwargs.items(): 73 | setattr(self, key, value) 74 | self.visibility = visibility 75 | 76 | @property 77 | def canvas(self): 78 | """Canvas: the canvas this placement belongs to""" 79 | return self.__canvas 80 | 81 | @property 82 | def identifier(self): 83 | """str: the identifier of this placement""" 84 | return self.__identifier 85 | 86 | @property 87 | def visibility(self): 88 | """Visibility: the visibility of this placement""" 89 | return self.__visibility 90 | 91 | @visibility.setter 92 | def visibility(self, value): 93 | if self.__visibility != value: 94 | if value is Visibility.INVISIBLE: 95 | self.__remove() 96 | elif value is Visibility.VISIBLE: 97 | self.__update() 98 | else: 99 | raise TypeError("expected an instance of Visibility") 100 | self.__visibility = value 101 | 102 | def __remove(self): 103 | self.__canvas.enqueue( 104 | _action.RemoveImageAction(identifier=self.identifier) 105 | ) 106 | self.__canvas.request_transmission() 107 | 108 | def __update(self): 109 | self.__canvas.enqueue( 110 | _action.AddImageAction( 111 | **{ 112 | **self.__data, 113 | **attr.asdict( 114 | _action.Identifiable(identifier=self.identifier) 115 | ), 116 | } 117 | ) 118 | ) 119 | self.__canvas.request_transmission() 120 | 121 | def __getattr__(self, name): 122 | if name not in self.__ATTRIBUTES: 123 | raise AttributeError("There is no attribute named %s" % name) 124 | 125 | attribute = self.__ATTRIBUTES[name] 126 | 127 | if name in self.__data: 128 | return self.__data[name] 129 | if attribute.default != attr.NOTHING: 130 | return attribute.default 131 | return None 132 | 133 | def __setattr__(self, name, value): 134 | if not self.__initialised: 135 | super().__setattr__(name, value) 136 | return 137 | if name not in self.__ATTRIBUTES: 138 | if hasattr(self, name): 139 | super().__setattr__(name, value) 140 | return 141 | raise AttributeError("There is no attribute named %s" % name) 142 | 143 | data = dict(self.__data) 144 | self.__data.update( 145 | attr.asdict( 146 | _action.AddImageAction( 147 | **{ 148 | **self.__EMPTY_BASE_PAIRS, 149 | **self.__data, 150 | **attr.asdict( 151 | _action.Identifiable(identifier=self.identifier) 152 | ), 153 | name: value, 154 | } 155 | ) 156 | ) 157 | ) 158 | 159 | # remove the key's of the empty base pairs 160 | # so the developer is forced to set them by himself 161 | for key in self.__EMPTY_BASE_PAIRS: 162 | if key not in data and key != name: 163 | del self.__data[key] 164 | 165 | if self.visibility is Visibility.VISIBLE: 166 | self.__update() 167 | 168 | 169 | class UeberzugProcess: 170 | """Class which handles the creation and 171 | destructions of ueberzug processes. 172 | """ 173 | 174 | __KILL_TIMEOUT_SECONDS = 1 175 | __BUFFER_SIZE_BYTES = 50 * 1024 176 | 177 | def __init__(self, options): 178 | """ 179 | Args: 180 | options (list of str): additional command line arguments 181 | """ 182 | self.__start_options = options 183 | self.__process = None 184 | 185 | @property 186 | def stdin(self): 187 | """_io.TextIOWrapper: stdin of the ueberzug process""" 188 | return self.__process.stdin 189 | 190 | @property 191 | def running(self): 192 | """bool: ueberzug process is still running""" 193 | return self.__process is not None and self.__process.poll() is None 194 | 195 | @property 196 | def responsive(self): 197 | """bool: ueberzug process is able to receive instructions""" 198 | return self.running and not self.__process.stdin.closed 199 | 200 | def start(self): 201 | """Starts a new ueberzug process 202 | if there's none or it's not responsive. 203 | """ 204 | if self.responsive: 205 | return 206 | if self.running: 207 | self.stop() 208 | 209 | self.__process = subprocess.Popen( 210 | ["ueberzug", "layer"] + self.__start_options, 211 | stdin=subprocess.PIPE, 212 | bufsize=self.__BUFFER_SIZE_BYTES, 213 | universal_newlines=True, 214 | start_new_session=True, 215 | ) 216 | 217 | def stop(self): 218 | """Sends SIGTERM to the running ueberzug process 219 | and waits for it to exit. 220 | If the process won't end after a specific timeout 221 | SIGKILL will also be send. 222 | """ 223 | if self.running: 224 | timer_kill = None 225 | 226 | try: 227 | ueberzug_pgid = os.getpgid(self.__process.pid) 228 | own_pgid = os.getpgid(0) 229 | assert ueberzug_pgid != own_pgid 230 | timer_kill = threading.Timer( 231 | self.__KILL_TIMEOUT_SECONDS, 232 | os.killpg, 233 | [ueberzug_pgid, signal.SIGKILL], 234 | ) 235 | 236 | self.__process.terminate() 237 | timer_kill.start() 238 | self.__process.communicate() 239 | except ProcessLookupError: 240 | pass 241 | finally: 242 | if timer_kill is not None: 243 | timer_kill.cancel() 244 | 245 | 246 | class CommandTransmitter: 247 | """Describes the structure used to define command transmitter classes. 248 | 249 | Defines a general interface used to implement different ways 250 | of storing and transmitting commands to ueberzug processes. 251 | """ 252 | 253 | def __init__(self, process): 254 | self._process = process 255 | 256 | @abc.abstractproperty 257 | def synchronously_draw(self): 258 | """bool: execute draw operations of ImageActions synchrously""" 259 | raise NotImplementedError() 260 | 261 | @abc.abstractmethod 262 | def enqueue(self, action: _action.Action): 263 | """Enqueues a command. 264 | 265 | Args: 266 | action (action.Action): the command which should be executed 267 | """ 268 | raise NotImplementedError() 269 | 270 | @abc.abstractmethod 271 | def transmit(self): 272 | """Transmits every command in the queue.""" 273 | raise NotImplementedError() 274 | 275 | 276 | class DequeCommandTransmitter(CommandTransmitter): 277 | """Implements the command transmitter with a dequeue.""" 278 | 279 | def __init__(self, process): 280 | super().__init__(process) 281 | self.__queue_commands = collections.deque() 282 | self.__synchronously_draw = False 283 | 284 | @property 285 | def synchronously_draw(self): 286 | return self.__synchronously_draw 287 | 288 | @synchronously_draw.setter 289 | def synchronously_draw(self, value): 290 | self.__synchronously_draw = value 291 | 292 | def enqueue(self, action: _action.Action): 293 | self.__queue_commands.append(action) 294 | 295 | def transmit(self): 296 | while self.__queue_commands: 297 | command = self.__queue_commands.popleft() 298 | self._process.stdin.write( 299 | json.dumps( 300 | { 301 | **attr.asdict(command), 302 | **attr.asdict( 303 | _action.Drawable( 304 | synchronously_draw=self.__synchronously_draw, 305 | draw=not self.__queue_commands, 306 | ) 307 | ), 308 | } 309 | ) 310 | ) 311 | self._process.stdin.write("\n") 312 | self._process.stdin.flush() 313 | 314 | 315 | class LazyCommandTransmitter(CommandTransmitter): 316 | """Implements lazily transmitting commands as decorator class. 317 | 318 | Ignores calls of the transmit method. 319 | """ 320 | 321 | def __init__(self, transmitter): 322 | super().__init__(None) 323 | self.transmitter = transmitter 324 | 325 | @property 326 | def synchronously_draw(self): 327 | return self.transmitter.synchronously_draw 328 | 329 | @synchronously_draw.setter 330 | def synchronously_draw(self, value): 331 | self.transmitter.synchronously_draw = value 332 | 333 | def enqueue(self, action: _action.Action): 334 | self.transmitter.enqueue(action) 335 | 336 | def transmit(self): 337 | pass 338 | 339 | def force_transmit(self): 340 | """Executes the transmit method of the decorated CommandTransmitter.""" 341 | self.transmitter.transmit() 342 | 343 | 344 | class Canvas: 345 | """The class which represents the drawing area.""" 346 | 347 | def __init__(self, debug=False): 348 | self.__process_arguments = ( 349 | ["--loader", ImageLoaderOption.SYNCHRONOUS.value] 350 | if debug 351 | else ["--silent"] 352 | ) 353 | self.__process = None 354 | self.__transmitter = None 355 | self.__used_identifiers = set() 356 | self.automatic_transmission = True 357 | 358 | def create_placement(self, identifier, *args, **kwargs): 359 | """Creates a placement associated with this canvas. 360 | 361 | Args: 362 | the same as the constructor of Placement 363 | """ 364 | if identifier in self.__used_identifiers: 365 | raise ValueError("Identifier '%s' is already taken." % identifier) 366 | self.__used_identifiers.add(identifier) 367 | return Placement(self, identifier, *args, **kwargs) 368 | 369 | @property 370 | @contextlib.contextmanager 371 | def lazy_drawing(self): 372 | """Context manager factory function which 373 | prevents transmitting commands till the with-statement ends. 374 | 375 | Raises: 376 | IOError: on transmitting commands 377 | if stdin of the ueberzug process was closed 378 | during an attempt of writing to it. 379 | """ 380 | try: 381 | self.__transmitter.transmit() 382 | self.__transmitter = LazyCommandTransmitter(self.__transmitter) 383 | yield 384 | self.__transmitter.force_transmit() 385 | finally: 386 | self.__transmitter = self.__transmitter.transmitter 387 | 388 | @property 389 | @contextlib.contextmanager 390 | def synchronous_lazy_drawing(self): 391 | """Context manager factory function which 392 | prevents transmitting commands till the with-statement ends. 393 | Also enforces to execute the draw operation synchronously 394 | right after the last command. 395 | 396 | Raises: 397 | IOError: on transmitting commands 398 | if stdin of the ueberzug process was closed 399 | during an attempt of writing to it. 400 | """ 401 | try: 402 | self.__transmitter.synchronously_draw = True 403 | with self.lazy_drawing: 404 | yield 405 | finally: 406 | self.__transmitter.synchronously_draw = False 407 | 408 | def __call__(self, function): 409 | def decorator(*args, **kwargs): 410 | with self: 411 | return function(*args, canvas=self, **kwargs) 412 | 413 | return decorator 414 | 415 | def __enter__(self): 416 | self.__process = UeberzugProcess(self.__process_arguments) 417 | self.__transmitter = DequeCommandTransmitter(self.__process) 418 | self.__process.start() 419 | return self 420 | 421 | def __exit__(self, *args): 422 | try: 423 | self.__process.stop() 424 | finally: 425 | self.__process = None 426 | self.__transmitter = None 427 | 428 | def enqueue(self, command: _action.Action): 429 | """Enqueues a command. 430 | 431 | Args: 432 | action (action.Action): the command which should be executed 433 | """ 434 | if not self.__process.responsive: 435 | self.__process.start() 436 | 437 | self.__transmitter.enqueue(command) 438 | 439 | def request_transmission(self, *, force=False): 440 | """Requests the transmission of every command in the queue.""" 441 | if not self.__process.responsive: 442 | self.__process.start() 443 | 444 | if self.automatic_transmission or force: 445 | self.__transmitter.transmit() 446 | -------------------------------------------------------------------------------- /ueberzug/library.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | 4 | 5 | def main(options): 6 | directory = ( 7 | pathlib.PosixPath(os.path.abspath(os.path.dirname(__file__))) / "lib" 8 | ) 9 | print((directory / "lib.sh").as_posix()) 10 | -------------------------------------------------------------------------------- /ueberzug/loading.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import queue 3 | import weakref 4 | import os 5 | import threading 6 | import concurrent.futures 7 | import enum 8 | 9 | import ueberzug.thread as thread 10 | import ueberzug.pattern as pattern 11 | 12 | 13 | INDEX_ALPHA_CHANNEL = 3 14 | 15 | 16 | def load_image(path, upper_bound_size): 17 | """Loads the image and converts it 18 | if it doesn't use the RGB or RGBX mode. 19 | 20 | Args: 21 | path (str): the path of the image file 22 | upper_bound_size (tuple of (width: int, height: int)): 23 | the maximal size to load data for 24 | 25 | Returns: 26 | tuple of (PIL.Image, bool): rgb image, downscaled 27 | 28 | Raises: 29 | OSError: for unsupported formats 30 | """ 31 | import PIL.Image 32 | 33 | image = PIL.Image.open(path) 34 | original_size = image.width, image.height 35 | downscaled = False 36 | mask = None 37 | 38 | if upper_bound_size: 39 | upper_bound_size = tuple( 40 | min(size for size in size_pair if size > 0) 41 | for size_pair in zip(upper_bound_size, original_size) 42 | ) 43 | image.draft(None, upper_bound_size) 44 | downscaled = (image.width, image.height) < original_size 45 | 46 | image.load() 47 | 48 | if ( 49 | image.format == "PNG" 50 | and image.mode in ("L", "P") 51 | and "transparency" in image.info 52 | ): 53 | # Prevent pillow to print the warning 54 | # 'Palette images with Transparency expressed in bytes should be 55 | # converted to RGBA images' 56 | image = image.convert("RGBA") 57 | 58 | if image.mode == "RGBA": 59 | mask = image.split()[INDEX_ALPHA_CHANNEL] 60 | 61 | if image.mode not in ("RGB", "RGBX"): 62 | image_rgb = PIL.Image.new("RGB", image.size, color=(255, 255, 255)) 63 | image_rgb.paste(image, mask=mask) 64 | image = image_rgb 65 | 66 | return image, downscaled 67 | 68 | 69 | class ImageHolder: 70 | """Holds the reference of an image. 71 | It serves as bridge between image loader and image user. 72 | """ 73 | 74 | def __init__(self, path, image=None): 75 | self.path = path 76 | self.image = image 77 | self.waiter = threading.Condition() 78 | 79 | def reveal_image(self, image): 80 | """Assigns an image to this holder and 81 | notifies waiting image users about it. 82 | 83 | Args: 84 | image (PIL.Image): the loaded image 85 | """ 86 | with self.waiter: 87 | self.image = image 88 | self.waiter.notify_all() 89 | 90 | def await_image(self): 91 | """Waits till an image loader assigns the image 92 | if it's not already happened. 93 | 94 | Returns: 95 | PIL.Image: the image assigned to this holder 96 | """ 97 | if self.image is None: 98 | with self.waiter: 99 | if self.image is None: 100 | self.waiter.wait() 101 | return self.image 102 | 103 | 104 | class PostLoadImageProcessor(metaclass=abc.ABCMeta): 105 | """Describes the structure used to define callbacks which 106 | will be invoked after loading an image. 107 | """ 108 | 109 | @abc.abstractmethod 110 | def on_loaded(self, image): 111 | """Postprocessor of an loaded image. 112 | The returned image will be assigned to the image holder. 113 | 114 | Args: 115 | image (PIL.Image): the loaded image 116 | 117 | Returns: 118 | PIL.Image: 119 | the image which will be assigned 120 | to the image holder of this loading process 121 | """ 122 | raise NotImplementedError() 123 | 124 | 125 | class CoverPostLoadImageProcessor(PostLoadImageProcessor): 126 | """Implementation of PostLoadImageProcessor 127 | which resizes an image (if possible -> needs to be bigger) 128 | such that it covers only just a given resolution. 129 | """ 130 | 131 | def __init__(self, width, height): 132 | self.width = width 133 | self.height = height 134 | 135 | def on_loaded(self, image): 136 | import PIL.Image 137 | 138 | resize_ratio = max( 139 | min(1, self.width / image.width), min(1, self.height / image.height) 140 | ) 141 | 142 | if resize_ratio != 1: 143 | image = image.resize( 144 | ( 145 | int(resize_ratio * image.width), 146 | int(resize_ratio * image.height), 147 | ), 148 | PIL.Image.LANCZOS, 149 | ) 150 | 151 | return image 152 | 153 | 154 | class ImageLoader(metaclass=abc.ABCMeta): 155 | """Describes the structure used to define image loading strategies. 156 | 157 | Defines a general interface used to implement different ways 158 | of loading images. 159 | E.g. loading images asynchron 160 | """ 161 | 162 | @pattern.LazyConstant 163 | def PLACEHOLDER(): 164 | """PIL.Image: fallback image for occuring errors""" 165 | # pylint: disable=no-method-argument,invalid-name 166 | import PIL.Image 167 | 168 | return PIL.Image.new("RGB", (1, 1)) 169 | 170 | @staticmethod 171 | @abc.abstractmethod 172 | def get_loader_name(): 173 | """Returns the constant name which is associated to this loader.""" 174 | raise NotImplementedError() 175 | 176 | def __init__(self): 177 | self.error_handler = None 178 | 179 | @abc.abstractmethod 180 | def load(self, path, upper_bound_size, post_load_processor=None): 181 | """Starts the image loading procedure for the passed path. 182 | How and when an image get's loaded depends on the implementation 183 | of the used ImageLoader class. 184 | 185 | Args: 186 | path (str): the path to the image which should be loaded 187 | upper_bound_size (tuple of (width: int, height: int)): 188 | the maximal size to load data for 189 | post_load_processor (PostLoadImageProcessor): 190 | allows to apply changes to the recently loaded image 191 | 192 | Returns: 193 | ImageHolder: which the image will be assigned to 194 | """ 195 | raise NotImplementedError() 196 | 197 | def register_error_handler(self, error_handler): 198 | """Set's the error handler to the passed function. 199 | An error handler will be called with exceptions which were 200 | raised during loading an image. 201 | 202 | Args: 203 | error_handler (Function(Exception)): 204 | the function which should be called 205 | to handle an error 206 | """ 207 | self.error_handler = error_handler 208 | 209 | def process_error(self, exception): 210 | """Processes an exception. 211 | Calls the error_handler with the exception 212 | if there's any. 213 | 214 | Args: 215 | exception (Exception): the occurred error 216 | """ 217 | if self.error_handler is not None and exception is not None: 218 | self.error_handler(exception) 219 | 220 | def __enter__(self): 221 | pass 222 | 223 | def __exit__(self, *_): 224 | """Finalises the image loader.""" 225 | pass 226 | 227 | 228 | class SynchronousImageLoader(ImageLoader): 229 | """Implementation of ImageLoader 230 | which loads images right away in the same thread 231 | it was requested to load the image. 232 | """ 233 | 234 | @staticmethod 235 | def get_loader_name(): 236 | return "synchronous" 237 | 238 | def load(self, path, upper_bound_size, post_load_processor=None): 239 | image = None 240 | 241 | try: 242 | image, _ = load_image(path, None) 243 | except OSError as exception: 244 | self.process_error(exception) 245 | 246 | if image and post_load_processor: 247 | image = post_load_processor.on_loaded(image) 248 | 249 | return ImageHolder(path, image or self.PLACEHOLDER) 250 | 251 | 252 | class AsynchronousImageLoader(ImageLoader): 253 | """Extension of ImageLoader 254 | which adds basic functionality 255 | needed to implement asynchron image loading. 256 | """ 257 | 258 | @enum.unique 259 | class Priority(enum.Enum): 260 | """Enum which defines the possible priorities 261 | of queue entries. 262 | """ 263 | 264 | HIGH = enum.auto() 265 | LOW = enum.auto() 266 | 267 | def __init__(self): 268 | super().__init__() 269 | self.__queue = queue.Queue() 270 | self.__queue_low_priority = queue.Queue() 271 | self.__waiter_low_priority = threading.Condition() 272 | 273 | def _enqueue( 274 | self, queue, image_holder, upper_bound_size, post_load_processor 275 | ): 276 | """Enqueues the image holder weakly referenced. 277 | 278 | Args: 279 | queue (queue.Queue): the queue to operate on 280 | image_holder (ImageHolder): 281 | the image holder for which an image should be loaded 282 | upper_bound_size (tuple of (width: int, height: int)): 283 | the maximal size to load data for 284 | post_load_processor (PostLoadImageProcessor): 285 | allows to apply changes to the recently loaded image 286 | """ 287 | queue.put( 288 | (weakref.ref(image_holder), upper_bound_size, post_load_processor) 289 | ) 290 | 291 | def _dequeue(self, queue): 292 | """Removes queue entries till an alive reference was found. 293 | The referenced image holder will be returned in this case. 294 | Otherwise if there wasn't found any alive reference 295 | None will be returned. 296 | 297 | Args: 298 | queue (queue.Queue): the queue to operate on 299 | 300 | Returns: 301 | tuple of (ImageHolder, tuple of (width: int, height: int), 302 | PostLoadImageProcessor): 303 | an queued image holder or None, upper bound size or None, 304 | the post load image processor or None 305 | """ 306 | holder_reference = None 307 | image_holder = None 308 | upper_bound_size = None 309 | post_load_processor = None 310 | 311 | while not queue.empty(): 312 | holder_reference, upper_bound_size, post_load_processor = ( 313 | queue.get_nowait() 314 | ) 315 | image_holder = holder_reference and holder_reference() 316 | if holder_reference is None or image_holder is not None: 317 | break 318 | 319 | return image_holder, upper_bound_size, post_load_processor 320 | 321 | @abc.abstractmethod 322 | def _schedule(self, function, priority): 323 | """Schedules the execution of a function. 324 | Functions should be executed in different thread pools 325 | based on their priority otherwise you can wait for a death lock. 326 | 327 | Args: 328 | function (Function): the function which should be executed 329 | priority (AsynchronImageLoader.Priority): 330 | the priority of the execution of this function 331 | """ 332 | raise NotImplementedError() 333 | 334 | def _load_image(self, path, upper_bound_size, post_load_processor): 335 | """Wrapper for calling load_image. 336 | Behaves like calling it directly, 337 | but allows e.g. executing the function in other processes. 338 | """ 339 | image, *other_data = load_image(path, upper_bound_size) 340 | 341 | if image and post_load_processor: 342 | image = post_load_processor.on_loaded(image) 343 | 344 | return (image, *other_data) 345 | 346 | def load(self, path, upper_bound_size, post_load_processor=None): 347 | holder = ImageHolder(path) 348 | self._enqueue( 349 | self.__queue, holder, upper_bound_size, post_load_processor 350 | ) 351 | self._schedule(self.__process_high_priority_entry, self.Priority.HIGH) 352 | return holder 353 | 354 | def __wait_for_main_work(self): 355 | """Waits till all queued high priority entries were processed.""" 356 | if not self.__queue.empty(): 357 | with self.__waiter_low_priority: 358 | if not self.__queue.empty(): 359 | self.__waiter_low_priority.wait() 360 | 361 | def __notify_main_work_done(self): 362 | """Notifies waiting threads that 363 | all queued high priority entries were processed. 364 | """ 365 | if self.__queue.empty(): 366 | with self.__waiter_low_priority: 367 | if self.__queue.empty(): 368 | self.__waiter_low_priority.notify_all() 369 | 370 | def __process_high_priority_entry(self): 371 | """Processes a single queued high priority entry.""" 372 | self.__process_queue(self.__queue) 373 | self.__notify_main_work_done() 374 | 375 | def __process_low_priority_entry(self): 376 | """Processes a single queued low priority entry.""" 377 | self.__wait_for_main_work() 378 | self.__process_queue(self.__queue_low_priority) 379 | 380 | def __process_queue(self, queue): 381 | """Processes a single queued entry. 382 | 383 | Args: 384 | queue (queue.Queue): the queue to operate on 385 | """ 386 | image = None 387 | image_holder, upper_bound_size, post_load_processor = self._dequeue( 388 | queue 389 | ) 390 | if image_holder is None: 391 | return 392 | 393 | try: 394 | image, downscaled = self._load_image( 395 | image_holder.path, upper_bound_size, post_load_processor 396 | ) 397 | if upper_bound_size and downscaled: 398 | self._enqueue( 399 | self.__queue_low_priority, 400 | image_holder, 401 | None, 402 | post_load_processor, 403 | ) 404 | self._schedule( 405 | self.__process_low_priority_entry, self.Priority.LOW 406 | ) 407 | except OSError as exception: 408 | self.process_error(exception) 409 | finally: 410 | image_holder.reveal_image(image or self.PLACEHOLDER) 411 | 412 | 413 | # * Pythons GIL limits the usefulness of threads. 414 | # So in order to use all cpu cores (assumed GIL isn't released) 415 | # you need to use multiple processes. 416 | # * Pillows load method will read & decode the image. 417 | # So it does the I/O and CPU work. 418 | # Decoding seems to be the bottleneck for large images. 419 | # * Using multiple processes comes with it's own bottleneck 420 | # (transfering the data between the processes). 421 | # 422 | # => Using multiple processes seems to be faster for small images. 423 | # Using threads seems to be faster for large images. 424 | class ThreadImageLoader(AsynchronousImageLoader): 425 | """Implementation of AsynchronImageLoader 426 | which loads images in multiple threads. 427 | """ 428 | 429 | @staticmethod 430 | def get_loader_name(): 431 | return "thread" 432 | 433 | def __init__(self): 434 | super().__init__() 435 | threads = os.cpu_count() 436 | threads_low_priority = max(1, threads // 2) 437 | self.__executor = thread.DaemonThreadPoolExecutor(max_workers=threads) 438 | self.__executor_low_priority = thread.DaemonThreadPoolExecutor( 439 | max_workers=threads_low_priority 440 | ) 441 | self.threads = threads + threads_low_priority 442 | 443 | def __exit__(self, *_): 444 | self.__executor_low_priority.shutdown() 445 | self.__executor.shutdown() 446 | 447 | def _schedule(self, function, priority): 448 | executor = self.__executor 449 | if priority == self.Priority.LOW: 450 | executor = self.__executor_low_priority 451 | executor.submit(function).add_done_callback( 452 | lambda future: self.process_error(future.exception()) 453 | ) 454 | 455 | 456 | class ProcessImageLoader(ThreadImageLoader): 457 | """Implementation of AsynchronImageLoader 458 | which loads images in multiple processes. 459 | Therefore it allows to utilise all cpu cores 460 | for decoding an image. 461 | """ 462 | 463 | @staticmethod 464 | def get_loader_name(): 465 | return "process" 466 | 467 | def __init__(self): 468 | super().__init__() 469 | self.__executor_loader = concurrent.futures.ProcessPoolExecutor( 470 | max_workers=self.threads 471 | ) 472 | # ProcessPoolExecutor won't work 473 | # when used first in ThreadPoolExecutor 474 | self.__executor_loader.submit(id, id).result() 475 | 476 | def __exit__(self, *args): 477 | super().__exit__(*args) 478 | self.__executor_loader.shutdown() 479 | 480 | @staticmethod 481 | def _load_image_extern(path, upper_bound_size, post_load_processor): 482 | """This function is a wrapper for the image loading function 483 | as sometimes pillow restores decoded images 484 | received from other processes wrongly. 485 | E.g. a PNG is reported as webp (-> crash on using an image function) 486 | So this function is a workaround which prevents these crashs to happen. 487 | """ 488 | image, *other_data = load_image(path, upper_bound_size) 489 | 490 | if image and post_load_processor: 491 | image = post_load_processor.on_loaded(image) 492 | 493 | return (image.mode, image.size, image.tobytes(), *other_data) 494 | 495 | def _load_image(self, path, upper_bound_size, post_load_processor=None): 496 | import PIL.Image 497 | 498 | future = self.__executor_loader.submit( 499 | ProcessImageLoader._load_image_extern, 500 | path, 501 | upper_bound_size, 502 | post_load_processor, 503 | ) 504 | mode, size, data, downscaled = future.result() 505 | return PIL.Image.frombytes(mode, size, data), downscaled 506 | 507 | 508 | @enum.unique 509 | class ImageLoaderOption(str, enum.Enum): 510 | """Enum which lists the useable ImageLoader classes.""" 511 | 512 | SYNCHRONOUS = SynchronousImageLoader 513 | THREAD = ThreadImageLoader 514 | PROCESS = ProcessImageLoader 515 | 516 | def __new__(cls, loader_class): 517 | inst = str.__new__(cls) 518 | # Based on an official example 519 | # https://docs.python.org/3/library/enum.html#using-a-custom-new 520 | # So.. stfu pylint 521 | # pylint: disable=protected-access 522 | inst._value_ = loader_class.get_loader_name() 523 | inst.loader_class = loader_class 524 | return inst 525 | -------------------------------------------------------------------------------- /ueberzug/parser.py: -------------------------------------------------------------------------------- 1 | """This module defines data parser 2 | which can be used to exchange information between processes. 3 | """ 4 | 5 | import json 6 | import abc 7 | import shlex 8 | import itertools 9 | import enum 10 | 11 | 12 | class Parser: 13 | """Subclasses of this abstract class define 14 | how to input will be parsed. 15 | """ 16 | 17 | @staticmethod 18 | @abc.abstractmethod 19 | def get_name(): 20 | """Returns the constant name which is associated to this parser.""" 21 | raise NotImplementedError() 22 | 23 | @abc.abstractmethod 24 | def parse(self, line): 25 | """Parses a line. 26 | 27 | Args: 28 | line (str): read line 29 | 30 | Returns: 31 | dict containing the key value pairs of the line 32 | """ 33 | raise NotImplementedError() 34 | 35 | @abc.abstractmethod 36 | def unparse(self, data): 37 | """Composes the data to a string 38 | whichs follows the syntax of the parser. 39 | 40 | Args: 41 | data (dict): data as key value pairs 42 | 43 | Returns: 44 | string 45 | """ 46 | raise NotImplementedError() 47 | 48 | 49 | class JsonParser(Parser): 50 | """Parses json input""" 51 | 52 | @staticmethod 53 | def get_name(): 54 | return "json" 55 | 56 | def parse(self, line): 57 | try: 58 | data = json.loads(line) 59 | if not isinstance(data, dict): 60 | raise ValueError( 61 | "Expected to parse an json object, got " + line 62 | ) 63 | return data 64 | except json.JSONDecodeError as error: 65 | raise ValueError(error) 66 | 67 | def unparse(self, data): 68 | return json.dumps(data) 69 | 70 | 71 | class SimpleParser(Parser): 72 | """Parses key value pairs separated by a tab. 73 | Does not support escaping spaces. 74 | """ 75 | 76 | SEPARATOR = "\t" 77 | 78 | @staticmethod 79 | def get_name(): 80 | return "simple" 81 | 82 | def parse(self, line): 83 | components = line.split(SimpleParser.SEPARATOR) 84 | 85 | if len(components) % 2 != 0: 86 | raise ValueError( 87 | "Expected key value pairs, " 88 | + "but at least one key has no value: " 89 | + line 90 | ) 91 | 92 | return { 93 | key: value 94 | for key, value in itertools.zip_longest( 95 | components[::2], components[1::2] 96 | ) 97 | } 98 | 99 | def unparse(self, data): 100 | return SimpleParser.SEPARATOR.join( 101 | str(key) + SimpleParser.SEPARATOR + str(value.replace("\n", "")) 102 | for key, value in data.items() 103 | ) 104 | 105 | 106 | class BashParser(Parser): 107 | """Parses input generated 108 | by dumping associative arrays with `declare -p`. 109 | """ 110 | 111 | @staticmethod 112 | def get_name(): 113 | return "bash" 114 | 115 | def parse(self, line): 116 | # remove 'typeset -A varname=( ' and ')' 117 | start = line.find("(") 118 | end = line.rfind(")") 119 | 120 | if not 0 <= start < end: 121 | raise ValueError( 122 | "Expected input to be formatted like " 123 | "the output of bashs `declare -p` function. " 124 | "Got: " + line 125 | ) 126 | 127 | components = itertools.dropwhile( 128 | lambda text: not text or text[0] != "[", 129 | shlex.split(line[start + 1 : end]), 130 | ) 131 | return { 132 | key[1:-1]: value 133 | for pair in components 134 | for key, value in (pair.split("=", maxsplit=1),) 135 | } 136 | 137 | def unparse(self, data): 138 | return " ".join( 139 | "[" + str(key) + "]=" + shlex.quote(value) 140 | for key, value in data.items() 141 | ) 142 | 143 | 144 | @enum.unique 145 | class ParserOption(str, enum.Enum): 146 | JSON = JsonParser 147 | SIMPLE = SimpleParser 148 | BASH = BashParser 149 | 150 | def __new__(cls, parser_class): 151 | inst = str.__new__(cls) 152 | inst._value_ = parser_class.get_name() 153 | inst.parser_class = parser_class 154 | return inst 155 | -------------------------------------------------------------------------------- /ueberzug/pattern.py: -------------------------------------------------------------------------------- 1 | class LazyConstant: 2 | def __init__(self, function): 3 | self.value = None 4 | self.function = function 5 | 6 | def __get__(self, instance, owner): 7 | if self.value is None: 8 | self.value = self.function() 9 | return self.value 10 | 11 | def __set__(self, instance, value): 12 | raise AttributeError("can't set attribute") 13 | -------------------------------------------------------------------------------- /ueberzug/process.py: -------------------------------------------------------------------------------- 1 | import re 2 | import os 3 | import functools 4 | 5 | 6 | MAX_PROCESS_NAME_LENGTH = 15 7 | 8 | 9 | @functools.wraps(os.getpid) 10 | def get_own_pid(*args, **kwargs): 11 | # pylint: disable=missing-docstring 12 | return os.getpid(*args, **kwargs) 13 | 14 | 15 | def get_info(pid: int): 16 | """Determines information about the process with the given pid. 17 | 18 | Determines 19 | - the process id (pid) 20 | - the command name (comm) 21 | - the state (state) 22 | - the process id of the parent process (ppid) 23 | - the process group id (pgrp) 24 | - the session id (session) 25 | - the controlling terminal (tty_nr) 26 | of the process with the given pid. 27 | 28 | Args: 29 | pid (int or str): 30 | the associated pid of the process 31 | for which to retrieve the information for 32 | 33 | Returns: 34 | dict of str: bytes: 35 | containing the listed information. 36 | The term in the brackets is used as key. 37 | 38 | Raises: 39 | FileNotFoundError: if there is no process with the given pid 40 | """ 41 | with open(f"/proc/{pid}/stat", "rb") as proc_file: 42 | data = proc_file.read() 43 | return re.search( 44 | rb"^(?P[-+]?\d+) " 45 | rb"\((?P.{0," 46 | + str(MAX_PROCESS_NAME_LENGTH).encode() 47 | + rb"})\) " 48 | rb"(?P.) " 49 | rb"(?P[-+]?\d+) " 50 | rb"(?P[-+]?\d+) " 51 | rb"(?P[-+]?\d+) " 52 | rb"(?P[-+]?\d+)", 53 | data, 54 | re.DOTALL, 55 | ).groupdict() 56 | 57 | 58 | @functools.lru_cache() 59 | def get_pty_slave_folders(): 60 | """Determines the folders in which linux 61 | creates the control device files of the pty slaves. 62 | 63 | Returns: 64 | list of str: containing the paths to these folders 65 | """ 66 | paths = [] 67 | 68 | with open("/proc/tty/drivers", "rb") as drivers_file: 69 | for line in drivers_file: 70 | # The documentation about /proc/tty/drivers 71 | # is a little bit short (man proc): 72 | # /proc/tty 73 | # Subdirectory containing the pseudo-files and 74 | # subdirectories for tty drivers and line disciplines. 75 | # So.. see the source code: 76 | # https://github.com/torvalds/linux/blob/8653b778e454a7708847aeafe689bce07aeeb94e/fs/proc/proc_tty.c#L28-L67 77 | driver = re.search( 78 | rb"^(?P(\S| )+?) +" rb"(?P/dev/\S+) ", line 79 | ).groupdict() 80 | if driver["name"] == b"pty_slave": 81 | paths += [driver["path"].decode()] 82 | 83 | return paths 84 | 85 | 86 | def get_parent_pid(pid: int): 87 | """Determines pid of the parent process of the process with the given pid. 88 | 89 | Args: 90 | pid (int or str): 91 | the associated pid of the process 92 | for which to retrieve the information for 93 | 94 | Returns: 95 | int: the pid of the parent process 96 | 97 | Raises: 98 | FileNotFoundError: if there is no process with the given pid 99 | """ 100 | process_info = get_info(pid) 101 | return int(process_info["ppid"]) 102 | 103 | 104 | def calculate_minor_device_number(tty_nr: int): 105 | """Calculates the minor device number contained 106 | in a tty_nr of a stat file of the procfs. 107 | 108 | Args: 109 | tty_nr (int): 110 | a tty_nr of a stat file of the procfs 111 | 112 | Returns: 113 | int: the minor device number contained in the tty_nr 114 | """ 115 | TTY_NR_BITS = 32 116 | FIRST_BITS = 8 117 | SHIFTED_BITS = 12 118 | FIRST_BITS_MASK = 0xFF 119 | SHIFTED_BITS_MASK = 0xFFF00000 120 | minor_device_number = (tty_nr & FIRST_BITS_MASK) + ( 121 | (tty_nr & SHIFTED_BITS_MASK) 122 | >> (TTY_NR_BITS - SHIFTED_BITS - FIRST_BITS) 123 | ) 124 | return minor_device_number 125 | 126 | 127 | def get_pty_slave(pid: int): 128 | """Determines control device file 129 | of the pty slave of the process with the given pid. 130 | 131 | Args: 132 | pid (int or str): 133 | the associated pid of the process 134 | for which to retrieve the information for 135 | 136 | Returns: 137 | str or None: 138 | the path to the control device file 139 | or None if no path was found 140 | 141 | Raises: 142 | FileNotFoundError: if there is no process with the given pid 143 | """ 144 | pty_slave_folders = get_pty_slave_folders() 145 | process_info = get_info(pid) 146 | tty_nr = int(process_info["tty_nr"]) 147 | minor_device_number = calculate_minor_device_number(tty_nr) 148 | 149 | for folder in pty_slave_folders: 150 | device_path = f"{folder}/{minor_device_number}" 151 | 152 | try: 153 | if tty_nr == os.stat(device_path).st_rdev: 154 | return device_path 155 | except FileNotFoundError: 156 | pass 157 | 158 | return None 159 | -------------------------------------------------------------------------------- /ueberzug/query_windows.py: -------------------------------------------------------------------------------- 1 | import os 2 | import signal 3 | import errno 4 | 5 | 6 | def get_command(pid): 7 | """Figures out the associated command name 8 | of a process with the given pid. 9 | 10 | Args: 11 | pid (int): the pid of the process of interest 12 | 13 | Returns: 14 | str: the associated command name 15 | """ 16 | with open("/proc/{}/comm".format(pid), "r") as commfile: 17 | return "\n".join(commfile.readlines()) 18 | 19 | 20 | def is_same_command(pid0, pid1): 21 | """Checks whether the associated command name 22 | of the processes of the given pids equals to each other. 23 | 24 | Args: 25 | pid0 (int): the pid of the process of interest 26 | pid1 (int): the pid of another process of interest 27 | 28 | Returns: 29 | bool: True if both processes have 30 | the same associated command name 31 | """ 32 | return get_command(pid0) == get_command(pid1) 33 | 34 | 35 | def send_signal_safe(own_pid, target_pid): 36 | """Sends SIGUSR1 to a process if both 37 | processes have the same associated command name. 38 | (Race condition free) 39 | 40 | Requires: 41 | - Python 3.9+ 42 | - Linux 5.1+ 43 | 44 | Args: 45 | own_pid (int): the pid of this process 46 | target_pid (int): 47 | the pid of the process to send the signal to 48 | """ 49 | pidfile = None 50 | try: 51 | pidfile = os.open(f"/proc/{target_pid}", os.O_DIRECTORY) 52 | if is_same_command(own_pid, target_pid): 53 | signal.pidfd_send_signal(pidfile, signal.SIGUSR1) 54 | except FileNotFoundError: 55 | pass 56 | except OSError as error: 57 | # not sure if errno is really set.. 58 | # at least the documentation of the used functions says so.. 59 | # see e.g.: https://github.com/python/cpython/commit/7483451577916e693af6d20cf520b2cc7e2174d2#diff-99fb04b208835118fdca0d54b76a00c450da3eaff09d2b53e8a03d63bbe88e30R1279-R1281 60 | # and https://docs.python.org/3/c-api/exceptions.html#c.PyErr_SetFromErrno 61 | 62 | # caused by either pidfile_open or pidfd_send_signal 63 | if error.errno != errno.ESRCH: 64 | raise 65 | # else: the process is death 66 | finally: 67 | if pidfile is not None: 68 | os.close(pidfile) 69 | 70 | 71 | def send_signal_unsafe(own_pid, target_pid): 72 | """Sends SIGUSR1 to a process if both 73 | processes have the same associated command name. 74 | (Race condition if process dies) 75 | 76 | Args: 77 | own_pid (int): the pid of this process 78 | target_pid (int): 79 | the pid of the process to send the signal to 80 | """ 81 | try: 82 | if is_same_command(own_pid, target_pid): 83 | os.kill(target_pid, signal.SIGUSR1) 84 | except (FileNotFoundError, ProcessLookupError): 85 | pass 86 | 87 | 88 | def main(options): 89 | # assumption: 90 | # started by calling the programs name 91 | # ueberzug layer and 92 | # ueberzug query_windows 93 | own_pid = os.getpid() 94 | 95 | for pid in options["PIDS"]: 96 | try: 97 | send_signal_safe(own_pid, int(pid)) 98 | except AttributeError: 99 | send_signal_unsafe(own_pid, int(pid)) 100 | -------------------------------------------------------------------------------- /ueberzug/scaling.py: -------------------------------------------------------------------------------- 1 | """Modul which implements class and functions 2 | all about scaling images. 3 | """ 4 | 5 | import abc 6 | import enum 7 | 8 | import ueberzug.geometry as geometry 9 | 10 | 11 | class ImageScaler(metaclass=abc.ABCMeta): 12 | """Describes the structure used to define image scaler classes. 13 | 14 | Defines a general interface used to implement different ways 15 | of scaling images to specific sizes. 16 | """ 17 | 18 | @staticmethod 19 | @abc.abstractmethod 20 | def get_scaler_name(): 21 | """Returns: 22 | str: the constant name which is associated to this scaler. 23 | """ 24 | raise NotImplementedError() 25 | 26 | @staticmethod 27 | @abc.abstractmethod 28 | def is_indulgent_resizing(): 29 | """This method specifies whether the 30 | algorithm returns noticeable different results for 31 | the same image with different sizes (bigger than the 32 | maximum size which is passed to the scale method). 33 | 34 | Returns: 35 | bool: False if the results differ 36 | """ 37 | raise NotImplementedError() 38 | 39 | @abc.abstractmethod 40 | def calculate_resolution(self, image, width: int, height: int): 41 | """Calculates the final resolution of the scaled image. 42 | 43 | Args: 44 | image (PIL.Image): the image which should be scaled 45 | width (int): maximum width that can be taken 46 | height (int): maximum height that can be taken 47 | 48 | Returns: 49 | tuple: final width: int, final height: int 50 | """ 51 | raise NotImplementedError() 52 | 53 | @abc.abstractmethod 54 | def scale(self, image, position: geometry.Point, width: int, height: int): 55 | """Scales the image according to the respective implementation. 56 | 57 | Args: 58 | image (PIL.Image): the image which should be scaled 59 | position (geometry.Position): the centered position, if possible 60 | Specified as factor of the image size, 61 | so it should be an element of [0, 1]. 62 | width (int): maximum width that can be taken 63 | height (int): maximum height that can be taken 64 | 65 | Returns: 66 | PIL.Image: the scaled image 67 | """ 68 | raise NotImplementedError() 69 | 70 | 71 | class OffsetImageScaler(ImageScaler, metaclass=abc.ABCMeta): 72 | """Extension of the ImageScaler class by Offset specific functions.""" 73 | 74 | # pylint can't detect abstract subclasses 75 | # pylint: disable=abstract-method 76 | 77 | @staticmethod 78 | def get_offset(position: float, target_size: float, image_size: float): 79 | """Calculates a offset which contains the position 80 | in a range from offset to offset + target_size. 81 | 82 | Args: 83 | position (float): the centered position, if possible 84 | Specified as factor of the image size, 85 | so it should be an element of [0, 1]. 86 | target_size (int): the image size of the wanted result 87 | image_size (int): the image size 88 | 89 | Returns: 90 | int: the offset 91 | """ 92 | return int( 93 | min( 94 | max(0, position * image_size - target_size / 2), 95 | image_size - target_size, 96 | ) 97 | ) 98 | 99 | 100 | class MinSizeImageScaler(ImageScaler): 101 | """Partial implementation of an ImageScaler. 102 | Subclasses calculate the final resolution of the scaled image 103 | as the minimum value of the image size and the maximum size. 104 | """ 105 | 106 | # pylint: disable=abstract-method 107 | 108 | def calculate_resolution(self, image, width: int, height: int): 109 | return (min(width, image.width), min(height, image.height)) 110 | 111 | 112 | class CropImageScaler(MinSizeImageScaler, OffsetImageScaler): 113 | """Implementation of the ImageScaler 114 | which crops out the maximum image size. 115 | """ 116 | 117 | @staticmethod 118 | def get_scaler_name(): 119 | return "crop" 120 | 121 | @staticmethod 122 | def is_indulgent_resizing(): 123 | return False 124 | 125 | def scale(self, image, position: geometry.Point, width: int, height: int): 126 | width, height = self.calculate_resolution(image, width, height) 127 | image_width, image_height = image.width, image.height 128 | offset_x = self.get_offset(position.x, width, image_width) 129 | offset_y = self.get_offset(position.y, height, image_height) 130 | return image.crop( 131 | (offset_x, offset_y, offset_x + width, offset_y + height) 132 | ) 133 | 134 | 135 | class DistortImageScaler(ImageScaler): 136 | """Implementation of the ImageScaler 137 | which distorts the image to the maximum image size. 138 | """ 139 | 140 | @staticmethod 141 | def get_scaler_name(): 142 | return "distort" 143 | 144 | @staticmethod 145 | def is_indulgent_resizing(): 146 | return True 147 | 148 | def calculate_resolution(self, image, width: int, height: int): 149 | return width, height 150 | 151 | def scale(self, image, position: geometry.Point, width: int, height: int): 152 | import PIL.Image 153 | 154 | width, height = self.calculate_resolution(image, width, height) 155 | return image.resize((width, height), PIL.Image.LANCZOS) 156 | 157 | 158 | class FitContainImageScaler(DistortImageScaler): 159 | """Implementation of the ImageScaler 160 | which resizes the image that either 161 | the width matches the maximum width 162 | or the height matches the maximum height 163 | while keeping the image ratio. 164 | """ 165 | 166 | @staticmethod 167 | def get_scaler_name(): 168 | return "fit_contain" 169 | 170 | @staticmethod 171 | def is_indulgent_resizing(): 172 | return True 173 | 174 | def calculate_resolution(self, image, width: int, height: int): 175 | factor = min(width / image.width, height / image.height) 176 | return int(image.width * factor), int(image.height * factor) 177 | 178 | 179 | class ContainImageScaler(FitContainImageScaler): 180 | """Implementation of the ImageScaler 181 | which resizes the image to a size <= the maximum size 182 | while keeping the image ratio. 183 | """ 184 | 185 | @staticmethod 186 | def get_scaler_name(): 187 | return "contain" 188 | 189 | @staticmethod 190 | def is_indulgent_resizing(): 191 | return True 192 | 193 | def calculate_resolution(self, image, width: int, height: int): 194 | return super().calculate_resolution( 195 | image, min(width, image.width), min(height, image.height) 196 | ) 197 | 198 | 199 | class ForcedCoverImageScaler(DistortImageScaler, OffsetImageScaler): 200 | """Implementation of the ImageScaler 201 | which resizes the image to cover the entire area which should be filled 202 | while keeping the image ratio. 203 | If the image is smaller than the desired size 204 | it will be stretched to reach the desired size. 205 | If the ratio of the area differs 206 | from the image ratio the edges will be cut off. 207 | """ 208 | 209 | @staticmethod 210 | def get_scaler_name(): 211 | return "forced_cover" 212 | 213 | @staticmethod 214 | def is_indulgent_resizing(): 215 | return True 216 | 217 | def scale(self, image, position: geometry.Point, width: int, height: int): 218 | import PIL.Image 219 | 220 | width, height = self.calculate_resolution(image, width, height) 221 | image_width, image_height = image.width, image.height 222 | if width / image_width > height / image_height: 223 | image_height = int(image_height * width / image_width) 224 | image_width = width 225 | else: 226 | image_width = int(image_width * height / image_height) 227 | image_height = height 228 | offset_x = self.get_offset(position.x, width, image_width) 229 | offset_y = self.get_offset(position.y, height, image_height) 230 | 231 | return image.resize( 232 | (image_width, image_height), PIL.Image.LANCZOS 233 | ).crop((offset_x, offset_y, offset_x + width, offset_y + height)) 234 | 235 | 236 | class CoverImageScaler(MinSizeImageScaler, ForcedCoverImageScaler): 237 | """The same as ForcedCoverImageScaler but images won't be stretched 238 | if they are smaller than the area which should be filled. 239 | """ 240 | 241 | @staticmethod 242 | def get_scaler_name(): 243 | return "cover" 244 | 245 | @staticmethod 246 | def is_indulgent_resizing(): 247 | return True 248 | 249 | 250 | @enum.unique 251 | class ScalerOption(str, enum.Enum): 252 | """Enum which lists the useable ImageScaler classes.""" 253 | 254 | DISTORT = DistortImageScaler 255 | CROP = CropImageScaler 256 | FIT_CONTAIN = FitContainImageScaler 257 | CONTAIN = ContainImageScaler 258 | FORCED_COVER = ForcedCoverImageScaler 259 | COVER = CoverImageScaler 260 | 261 | def __new__(cls, scaler_class): 262 | inst = str.__new__(cls) 263 | # Based on an official example 264 | # https://docs.python.org/3/library/enum.html#using-a-custom-new 265 | # So.. stfu pylint 266 | # pylint: disable=protected-access 267 | inst._value_ = scaler_class.get_scaler_name() 268 | inst.scaler_class = scaler_class 269 | return inst 270 | -------------------------------------------------------------------------------- /ueberzug/terminal.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import struct 3 | import fcntl 4 | import termios 5 | import math 6 | 7 | 8 | class TerminalInfo: 9 | @staticmethod 10 | def get_size(fd_pty=None): 11 | """Determines the columns, rows, width (px), 12 | height (px) of the terminal. 13 | 14 | Returns: 15 | tuple of int: cols, rows, width, height 16 | """ 17 | fd_pty = fd_pty or sys.stdout.fileno() 18 | farg = struct.pack("HHHH", 0, 0, 0, 0) 19 | fretint = fcntl.ioctl(fd_pty, termios.TIOCGWINSZ, farg) 20 | rows, cols, xpixels, ypixels = struct.unpack("HHHH", fretint) 21 | return cols, rows, xpixels, ypixels 22 | 23 | @staticmethod 24 | def __guess_padding(chars, pixels): 25 | # (this won't work all the time but 26 | # it's still better than disrespecting padding all the time) 27 | # let's assume the padding is the same on both sides: 28 | # let font_width = floor(xpixels / cols) 29 | # (xpixels - padding)/cols = font_size 30 | # <=> (xpixels - padding) = font_width * cols 31 | # <=> - padding = font_width * cols - xpixels 32 | # <=> padding = - font_width * cols + xpixels 33 | font_size = math.floor(pixels / chars) 34 | padding = (-font_size * chars + pixels) / 2 35 | return padding 36 | 37 | @staticmethod 38 | def __guess_font_size(chars, pixels, padding): 39 | return (pixels - 2 * padding) / chars 40 | 41 | def __init__(self, pty=None): 42 | self.pty = pty 43 | self.font_width = None 44 | self.font_height = None 45 | self.padding_vertical = None 46 | self.padding_horizontal = None 47 | 48 | @property 49 | def ready(self): 50 | """bool: True if the information 51 | of every attribute has been calculated. 52 | """ 53 | return all( 54 | ( 55 | self.font_width, 56 | self.font_height, 57 | self.padding_vertical, 58 | self.padding_horizontal, 59 | ) 60 | ) 61 | 62 | def reset(self): 63 | """Resets the font size and padding.""" 64 | self.font_width = None 65 | self.font_height = None 66 | self.padding_vertical = None 67 | self.padding_horizontal = None 68 | 69 | def calculate_sizes(self, fallback_width, fallback_height): 70 | """Calculates the values for font_{width,height} and 71 | padding_{horizontal,vertical}. 72 | """ 73 | if isinstance(self.pty, (int, type(None))): 74 | self.__calculate_sizes(self.pty, fallback_width, fallback_height) 75 | else: 76 | with open(self.pty) as fd_pty: 77 | self.__calculate_sizes(fd_pty, fallback_width, fallback_height) 78 | 79 | def __calculate_sizes(self, fd_pty, fallback_width, fallback_height): 80 | cols, rows, xpixels, ypixels = TerminalInfo.get_size(fd_pty) 81 | xpixels = xpixels or fallback_width 82 | ypixels = ypixels or fallback_height 83 | padding_horizontal = self.__guess_padding(cols, xpixels) 84 | padding_vertical = self.__guess_padding(rows, ypixels) 85 | self.padding_horizontal = max(padding_horizontal, padding_vertical) 86 | self.padding_vertical = self.padding_horizontal 87 | self.font_width = self.__guess_font_size( 88 | cols, xpixels, self.padding_horizontal 89 | ) 90 | self.font_height = self.__guess_font_size( 91 | rows, ypixels, self.padding_vertical 92 | ) 93 | 94 | if xpixels < fallback_width and ypixels < fallback_height: 95 | # some terminal emulators return the size of the text area 96 | # instead of the size of the whole window 97 | # ----- 98 | # we're still missing information 99 | # e.g.: 100 | # we know the size of the text area but 101 | # we still don't know the margin of the text area to the edges 102 | # (a character has a specific size so: 103 | # there's some additional varying space 104 | # if the character size isn't a divider of 105 | # (window size - padding)) 106 | # -> it's okay not to know it 107 | # if the terminal emulator centers the text area 108 | # (kitty seems to do that) 109 | # -> it's not okay not to know it 110 | # if the terminal emulator just 111 | # adds the additional space to the right margin 112 | # (which will most likely be done) 113 | # (stterm seems to do that) 114 | self.padding_horizontal = 1 / 2 * (fallback_width - xpixels) 115 | self.padding_vertical = 1 / 2 * (fallback_height - ypixels) 116 | self.font_width = xpixels / cols 117 | self.font_height = ypixels / rows 118 | -------------------------------------------------------------------------------- /ueberzug/thread.py: -------------------------------------------------------------------------------- 1 | """This module reimplements the ThreadPoolExecutor. 2 | https://github.com/python/cpython/blob/master/Lib/concurrent/futures/thread.py 3 | 4 | The only change is the prevention of waiting 5 | for each thread to exit on exiting the script. 6 | """ 7 | 8 | import threading 9 | import weakref 10 | import concurrent.futures as futures 11 | 12 | 13 | def _worker(executor_reference, work_queue): 14 | # pylint: disable=W0212 15 | try: 16 | while True: 17 | work_item = work_queue.get(block=True) 18 | if work_item is not None: 19 | work_item.run() 20 | del work_item 21 | continue 22 | executor = executor_reference() 23 | if executor is None or executor._shutdown: 24 | if executor is not None: 25 | executor._shutdown = True 26 | work_queue.put(None) 27 | return 28 | del executor 29 | except BaseException: 30 | futures._base.LOGGER.critical("Exception in worker", exc_info=True) 31 | 32 | 33 | class DaemonThreadPoolExecutor(futures.ThreadPoolExecutor): 34 | """The concurrent.futures.ThreadPoolExecutor extended by 35 | the prevention of waiting for each thread on exiting the script. 36 | """ 37 | 38 | def _adjust_thread_count(self): 39 | def weakref_cb(_, queue=self._work_queue): 40 | queue.put(None) 41 | 42 | num_threads = len(self._threads) 43 | if num_threads < self._max_workers: 44 | thread_name = "%s_%d" % (self, num_threads) 45 | thread = threading.Thread( 46 | name=thread_name, 47 | target=_worker, 48 | args=(weakref.ref(self, weakref_cb), self._work_queue), 49 | ) 50 | thread.daemon = True 51 | thread.start() 52 | self._threads.add(thread) 53 | -------------------------------------------------------------------------------- /ueberzug/tmux_util.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import shlex 3 | import os 4 | 5 | import ueberzug.geometry as geometry 6 | 7 | 8 | def is_used(): 9 | """Determines whether this program runs in tmux or not.""" 10 | return get_pane() is not None 11 | 12 | 13 | def get_pane(): 14 | """Determines the pane identifier this process runs in. 15 | 16 | Returns: 17 | str or None 18 | """ 19 | return os.environ.get("TMUX_PANE") 20 | 21 | 22 | def get_session_id(): 23 | """Determines the session identifier this process runs in. 24 | 25 | Returns: 26 | str 27 | """ 28 | return ( 29 | subprocess.check_output( 30 | ["tmux", "display", "-p", "-F", "#{session_id}", "-t", get_pane()] 31 | ) 32 | .decode() 33 | .strip() 34 | ) 35 | 36 | 37 | def get_offset(): 38 | """Determines the offset 39 | of the pane (this process runs in) 40 | within it's tmux window. 41 | """ 42 | result = subprocess.check_output( 43 | [ 44 | "tmux", 45 | "display", 46 | "-p", 47 | "-F", 48 | "#{pane_top},#{pane_left}," 49 | "#{pane_bottom},#{pane_right}," 50 | "#{window_height},#{window_width}", 51 | "-t", 52 | get_pane(), 53 | ] 54 | ).decode() 55 | top, left, bottom, right, height, width = ( 56 | int(i) for i in result.split(",") 57 | ) 58 | return geometry.Distance(top, left, height - bottom, width - right) 59 | 60 | 61 | def is_window_focused(): 62 | """Determines whether the window 63 | which owns the pane 64 | which owns this process is focused. 65 | """ 66 | result = subprocess.check_output( 67 | [ 68 | "tmux", 69 | "display", 70 | "-p", 71 | "-F", 72 | "#{window_active},#{pane_in_mode}", 73 | "-t", 74 | get_pane(), 75 | ] 76 | ).decode() 77 | return result == "1,0\n" 78 | 79 | 80 | def get_client_pids(): 81 | """Determines the tty for each tmux client 82 | displaying the pane this program runs in. 83 | """ 84 | if not is_window_focused(): 85 | return {} 86 | 87 | return { 88 | int(pid) 89 | for pid in subprocess.check_output( 90 | ["tmux", "list-clients", "-F", "#{client_pid}", "-t", get_pane()] 91 | ) 92 | .decode() 93 | .splitlines() 94 | } 95 | 96 | 97 | def register_hook(event, command): 98 | """Updates the hook of the passed event 99 | for the pane this program runs in 100 | to the execution of a program. 101 | 102 | Note: tmux does not support multiple hooks for the same target. 103 | So if there's already an hook registered it will be overwritten. 104 | """ 105 | subprocess.check_call( 106 | [ 107 | "tmux", 108 | "set-hook", 109 | "-t", 110 | get_pane(), 111 | event, 112 | "run-shell " + shlex.quote(command), 113 | ] 114 | ) 115 | 116 | 117 | def unregister_hook(event): 118 | """Removes the hook of the passed event 119 | for the pane this program runs in. 120 | """ 121 | subprocess.check_call(["tmux", "set-hook", "-u", "-t", get_pane(), event]) 122 | -------------------------------------------------------------------------------- /ueberzug/ui.py: -------------------------------------------------------------------------------- 1 | """This module contains user interface related classes and methods. 2 | """ 3 | 4 | import abc 5 | import weakref 6 | import attr 7 | 8 | import PIL.Image as Image 9 | 10 | import ueberzug.xutil as xutil 11 | import ueberzug.geometry as geometry 12 | import ueberzug.scaling as scaling 13 | import ueberzug.X as X 14 | 15 | 16 | def roundup(value, unit): 17 | return ((value + (unit - 1)) & ~(unit - 1)) >> 3 18 | 19 | 20 | class WindowFactory: 21 | """Window factory class""" 22 | 23 | def __init__(self, display): 24 | self.display = display 25 | 26 | @abc.abstractmethod 27 | def create(self, *window_infos: xutil.TerminalWindowInfo): 28 | """Creates a child window for each window id.""" 29 | raise NotImplementedError() 30 | 31 | 32 | class CanvasWindow(X.OverlayWindow): 33 | """Ensures unmapping of windows""" 34 | 35 | class Factory(WindowFactory): 36 | """CanvasWindows factory class""" 37 | 38 | def __init__(self, display, view): 39 | super().__init__(display) 40 | self.view = view 41 | 42 | def create(self, *window_infos: xutil.TerminalWindowInfo): 43 | return [ 44 | CanvasWindow(self.display, self.view, info) 45 | for info in window_infos 46 | ] 47 | 48 | class Placement: 49 | @attr.s 50 | class TransformedImage: 51 | """Data class which contains the options 52 | an image was transformed with 53 | and the image data.""" 54 | 55 | options = attr.ib(type=tuple) 56 | data = attr.ib(type=bytes) 57 | 58 | def __init__( 59 | self, 60 | x: int, 61 | y: int, 62 | width: int, 63 | height: int, 64 | scaling_position: geometry.Point, 65 | scaler: scaling.ImageScaler, 66 | path: str, 67 | image: Image, 68 | last_modified: int, 69 | cache: weakref.WeakKeyDictionary = None, 70 | ): 71 | # x, y are useful names in this case 72 | # pylint: disable=invalid-name 73 | self.x = x 74 | self.y = y 75 | self.width = width 76 | self.height = height 77 | self.scaling_position = scaling_position 78 | self.scaler = scaler 79 | self.path = path 80 | self.image = image 81 | self.last_modified = last_modified 82 | self.cache = cache or weakref.WeakKeyDictionary() 83 | 84 | def transform_image( 85 | self, 86 | term_info: xutil.TerminalWindowInfo, 87 | width: int, 88 | height: int, 89 | format_scanline: tuple, 90 | ): 91 | """Scales to image and calculates 92 | the width & height needed to display it. 93 | 94 | Returns: 95 | tuple of (width: int, height: int, image: bytes) 96 | """ 97 | image = self.image.await_image() 98 | scanline_pad, scanline_unit = format_scanline 99 | transformed_image = self.cache.get(term_info) 100 | final_size = self.scaler.calculate_resolution(image, width, height) 101 | options = ( 102 | self.scaler.get_scaler_name(), 103 | self.scaling_position, 104 | final_size, 105 | ) 106 | 107 | if ( 108 | transformed_image is None 109 | or transformed_image.options != options 110 | ): 111 | image = self.scaler.scale( 112 | image, self.scaling_position, width, height 113 | ) 114 | stride = roundup(image.width * scanline_unit, scanline_pad) 115 | transformed_image = self.TransformedImage( 116 | options, image.tobytes("raw", "BGRX", stride, 0) 117 | ) 118 | self.cache[term_info] = transformed_image 119 | 120 | return (*final_size, transformed_image.data) 121 | 122 | def resolve( 123 | self, 124 | pane_offset: geometry.Distance, 125 | term_info: xutil.TerminalWindowInfo, 126 | format_scanline, 127 | ): 128 | """Resolves the position and size of the image 129 | according to the teminal window information. 130 | 131 | Returns: 132 | tuple of (x: int, y: int, width: int, height: int, 133 | image: PIL.Image) 134 | """ 135 | # x, y are useful names in this case 136 | # pylint: disable=invalid-name 137 | image = self.image.await_image() 138 | x = int( 139 | (self.x + pane_offset.left) * term_info.font_width 140 | + term_info.padding_horizontal 141 | ) 142 | y = int( 143 | (self.y + pane_offset.top) * term_info.font_height 144 | + term_info.padding_vertical 145 | ) 146 | width = int( 147 | (self.width and (self.width * term_info.font_width)) 148 | or image.width 149 | ) 150 | height = int( 151 | (self.height and (self.height * term_info.font_height)) 152 | or image.height 153 | ) 154 | 155 | return ( 156 | x, 157 | y, 158 | *self.transform_image( 159 | term_info, width, height, format_scanline 160 | ), 161 | ) 162 | 163 | def __init__( 164 | self, display: X.Display, view, parent_info: xutil.TerminalWindowInfo 165 | ): 166 | """Changes the foreground color of the gc object. 167 | 168 | Args: 169 | display (X.Display): any created instance 170 | parent_id (int): the X11 window id of the parent window 171 | """ 172 | super().__init__(display, parent_info.window_id) 173 | self.parent_info = parent_info 174 | self._view = view 175 | self.scanline_pad = display.bitmap_format_scanline_pad 176 | self.scanline_unit = display.bitmap_format_scanline_unit 177 | self.screen_width = display.screen_width 178 | self.screen_height = display.screen_height 179 | self._image = X.Image(display, self.screen_width, self.screen_height) 180 | 181 | def __enter__(self): 182 | self.draw() 183 | return self 184 | 185 | def __exit__(self, *args): 186 | pass 187 | 188 | def draw(self): 189 | """Draws the window and updates the visibility mask.""" 190 | rectangles = [] 191 | 192 | if not self.parent_info.ready: 193 | self.parent_info.calculate_sizes(self.width, self.height) 194 | 195 | for placement in self._view.media.values(): 196 | # x, y are useful names in this case 197 | # pylint: disable=invalid-name 198 | x, y, width, height, image = placement.resolve( 199 | self._view.offset, 200 | self.parent_info, 201 | (self.scanline_pad, self.scanline_unit), 202 | ) 203 | rectangles.append((x, y, width, height)) 204 | self._image.draw(x, y, width, height, image) 205 | 206 | self._image.copy_to( 207 | self.id, 208 | 0, 209 | 0, 210 | min(self.width, self.screen_width), 211 | min(self.height, self.screen_height), 212 | ) 213 | self.set_visibility_mask(rectangles) 214 | super().draw() 215 | 216 | def reset_terminal_info(self): 217 | """Resets the terminal information of this window.""" 218 | self.parent_info.reset() 219 | -------------------------------------------------------------------------------- /ueberzug/version.py: -------------------------------------------------------------------------------- 1 | import ueberzug 2 | 3 | 4 | def main(_): 5 | print(ueberzug.__version__) 6 | -------------------------------------------------------------------------------- /ueberzug/xutil.py: -------------------------------------------------------------------------------- 1 | """This module contains x11 utils""" 2 | 3 | import functools 4 | import asyncio 5 | import os 6 | 7 | import ueberzug.tmux_util as tmux_util 8 | import ueberzug.terminal as terminal 9 | import ueberzug.process as process 10 | import ueberzug.X as X 11 | 12 | 13 | PREPARED_DISPLAYS = [] 14 | DISPLAY_SUPPLIES = 1 15 | 16 | 17 | class Events: 18 | """Async iterator class for x11 events""" 19 | 20 | def __init__(self, loop, display: X.Display): 21 | self._loop = loop 22 | self._display = display 23 | 24 | @staticmethod 25 | async def wait_for_event(loop, display: X.Display): 26 | """Waits asynchronously for an x11 event and returns it""" 27 | return await loop.run_in_executor(None, display.wait_for_event) 28 | 29 | def __aiter__(self): 30 | return self 31 | 32 | async def __anext__(self): 33 | return await Events.wait_for_event(self._loop, self._display) 34 | 35 | 36 | class TerminalWindowInfo(terminal.TerminalInfo): 37 | def __init__(self, window_id, fd_pty=None): 38 | super().__init__(fd_pty) 39 | self.window_id = window_id 40 | 41 | 42 | @functools.lru_cache() 43 | def get_parent_pids(pid): 44 | """Determines all parent pids of this process. 45 | The list is sorted from youngest parent to oldest parent. 46 | """ 47 | pids = [] 48 | next_pid = pid 49 | 50 | while next_pid > 1: 51 | pids.append(next_pid) 52 | next_pid = process.get_parent_pid(next_pid) 53 | 54 | return pids 55 | 56 | 57 | def get_pid_window_id_map(display: X.Display): 58 | """Determines the pid of each mapped window. 59 | 60 | Returns: 61 | dict of {pid: window_id} 62 | """ 63 | return { 64 | display.get_window_pid(window_id): window_id 65 | for window_id in display.get_child_window_ids() 66 | } 67 | 68 | 69 | def sort_by_key_list(mapping: dict, key_list: list): 70 | """Sorts the items of the mapping 71 | by the index of the keys in the key list. 72 | 73 | Args: 74 | mapping (dict): the mapping to be sorted 75 | key_list (list): the list which specifies the order 76 | 77 | Returns: 78 | list: which contains the sorted items as tuples 79 | """ 80 | key_map = {key: index for index, key in enumerate(key_list)} 81 | return sorted( 82 | mapping.items(), key=lambda item: key_map.get(item[0], float("inf")) 83 | ) 84 | 85 | 86 | def key_intersection(mapping: dict, key_list: list): 87 | """Creates a new map which only contains the intersection 88 | of the keys. 89 | 90 | Args: 91 | mapping (dict): the mapping to be filtered 92 | key_list (list): the keys to be used as a whitelist 93 | 94 | Returns: 95 | dict: which only contains keys which are also in key_list 96 | """ 97 | key_map = {key: index for index, key in enumerate(key_list)} 98 | return {key: value for key, value in mapping.items() if key in key_map} 99 | 100 | 101 | def get_first_pty(pids: list): 102 | """Determines the pseudo terminal of 103 | the first process in the passed list which owns one. 104 | """ 105 | for pid in pids: 106 | pty_slave_file = process.get_pty_slave(pid) 107 | if pty_slave_file: 108 | return pty_slave_file 109 | 110 | return None 111 | 112 | 113 | def get_parent_window_infos(display: X.Display): 114 | """Determines the window id of each 115 | terminal which displays the program using 116 | this layer. 117 | 118 | Returns: 119 | list of TerminalWindowInfo 120 | """ 121 | window_infos = [] 122 | client_pids = {} 123 | 124 | if tmux_util.is_used(): 125 | client_pids = tmux_util.get_client_pids() 126 | else: 127 | client_pids = {process.get_own_pid()} 128 | 129 | if client_pids: 130 | pid_window_id_map = get_pid_window_id_map(display) 131 | # Insert current window's PID & WID to the end of map to support tabbed. 132 | # NOTE: Terminal (current window) must have WINDOWID as env. variable. 133 | if os.environ.get("WINDOWID") != None: 134 | pid_window_id_map[os.getpid()] = int(os.environ.get("WINDOWID")) 135 | 136 | for pid in client_pids: 137 | ppids = get_parent_pids(pid) 138 | ppid_window_id_map = key_intersection(pid_window_id_map, ppids) 139 | try: 140 | window_pid, window_id = next( 141 | iter(sort_by_key_list(ppid_window_id_map, ppids)) 142 | ) 143 | # window_children_pids = ppids[:ppids.index(window_pid)][::-1] 144 | pty = get_first_pty(ppids) 145 | window_infos.append(TerminalWindowInfo(window_id, pty)) 146 | except StopIteration: 147 | # Window needs to be mapped, 148 | # otherwise it's not listed in _NET_CLIENT_LIST 149 | pass 150 | 151 | return window_infos 152 | --------------------------------------------------------------------------------