├── .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 | [](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 |
--------------------------------------------------------------------------------