You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ueberzug/ueberzug/ui.py

293 lines
10 KiB
Python

"""This module contains user interface related classes and methods.
"""
import abc
import weakref
import attr
import Xlib.X as X
import Xlib.display as Xdisplay
import Xlib.ext.shape as Xshape
import Xlib.protocol.event as Xevent
import PIL.Image as Image
import ueberzug.xutil as xutil
import ueberzug.geometry as geometry
import ueberzug.scaling as scaling
import Xshm
def roundup(value, unit):
return ((value + (unit - 1)) & ~(unit - 1)) >> 3
def get_visual_id(screen, depth: int):
"""Determines the visual id
for the given screen and - depth.
"""
try:
return next(filter(lambda i: i.depth == depth,
screen.allowed_depths)) \
.visuals[0].visual_id
except StopIteration:
raise ValueError(
'Screen does not support depth %d' % depth)
class WindowFactory:
"""Window factory class"""
def __init__(self, display):
self.display = display
@abc.abstractmethod
def create(self, *window_infos: xutil.TerminalWindowInfo):
"""Creates a child window for each window id."""
raise NotImplementedError()
class OverlayWindow:
"""Ensures unmapping of windows"""
SCREEN_DEPTH = 24
class Factory(WindowFactory):
"""OverlayWindows factory class"""
def __init__(self, display, view):
super().__init__(display)
self.view = view
def create(self, *window_infos: xutil.TerminalWindowInfo):
return [OverlayWindow(self.display, self.view, info)
for info in window_infos]
class Placement:
@attr.s
class TransformedImage:
"""Data class which contains the options
an image was transformed with
and the image data."""
options = attr.ib(type=tuple)
data = attr.ib(type=bytes)
def __init__(self, x: int, y: int, width: int, height: int,
scaling_position: geometry.Point,
scaler: scaling.ImageScaler,
path: str, image: Image, last_modified: int,
cache: weakref.WeakKeyDictionary = None):
# x, y are useful names in this case
# pylint: disable=invalid-name
self.x = x
self.y = y
self.width = width
self.height = height
self.scaling_position = scaling_position
self.scaler = scaler
self.path = path
self.image = image
self.last_modified = last_modified
self.cache = cache or weakref.WeakKeyDictionary()
def transform_image(self, term_info: xutil.TerminalWindowInfo,
width: int, height: int,
format_scanline: tuple):
"""Scales to image and calculates
the width & height needed to display it.
Returns:
tuple of (width: int, height: int, image: bytes)
"""
image = self.image.await_image()
scanline_pad, scanline_unit = format_scanline
transformed_image = self.cache.get(term_info)
final_size = self.scaler.calculate_resolution(
image, width, height)
options = (self.scaler.get_scaler_name(),
self.scaling_position, final_size)
if (transformed_image is None
or transformed_image.options != options):
image = self.scaler.scale(
image, self.scaling_position, width, height)
stride = roundup(image.width * scanline_unit, scanline_pad)
transformed_image = self.TransformedImage(
options, image.tobytes("raw", 'BGRX', stride, 0))
self.cache[term_info] = transformed_image
return (*final_size, transformed_image.data)
def resolve(self, pane_offset: geometry.Distance,
term_info: xutil.TerminalWindowInfo,
format_scanline):
"""Resolves the position and size of the image
according to the teminal window information.
Returns:
tuple of (x: int, y: int, width: int, height: int,
image: PIL.Image)
"""
# x, y are useful names in this case
# pylint: disable=invalid-name
image = self.image.await_image()
x = int((self.x + pane_offset.left) * term_info.font_width +
term_info.padding_horizontal)
y = int((self.y + pane_offset.top) * term_info.font_height +
term_info.padding_vertical)
width = int((self.width and (self.width * term_info.font_width))
or image.width)
height = \
int((self.height and (self.height * term_info.font_height))
or image.height)
return (x, y, *self.transform_image(
term_info, width, height, format_scanline))
def __init__(self, display: Xdisplay.Display,
view, term_info: xutil.TerminalWindowInfo):
"""Changes the foreground color of the gc object.
Args:
display (Xlib.display.Display): any created instance
parent_id (int): the X11 window id of the parent window
"""
self._display = display
self._screen = display.screen()
self._colormap = None
self.parent_info = term_info
self.parent_window = None
self.window = None
self._view = view
self._width = 1
self._height = 1
self._image = Xshm.Image(
self._screen.width_in_pixels,
self._screen.height_in_pixels)
self.create()
def __enter__(self):
self.map()
self.draw()
return self
def __exit__(self, *args):
self.destroy()
def draw(self):
"""Draws the window and updates the visibility mask."""
rectangles = []
scanline_pad = self.window.display.info.bitmap_format_scanline_pad
scanline_unit = self.window.display.info.bitmap_format_scanline_unit
if not self.parent_info.ready:
self.parent_info.calculate_sizes(
self._width, self._height)
for placement in self._view.media.values():
# x, y are useful names in this case
# pylint: disable=invalid-name
x, y, width, height, image = \
placement.resolve(self._view.offset, self.parent_info,
(scanline_pad, scanline_unit))
rectangles.append((x, y, width, height))
self._image.draw(x, y, width, height, image)
self._image.copy_to(
self.window.id,
0, 0,
min(self._width, self._screen.width_in_pixels),
min(self._height, self._screen.height_in_pixels))
self.window.shape_rectangles(
Xshape.SO.Set, Xshape.SK.Bounding, 0,
0, 0, rectangles)
self._display.flush()
def create(self):
"""Creates the window and gc"""
if self.window:
return
visual_id = get_visual_id(self._screen, OverlayWindow.SCREEN_DEPTH)
self._colormap = self._screen.root.create_colormap(
visual_id, X.AllocNone)
self.parent_window = self._display.create_resource_object(
'window', self.parent_info.window_id)
parent_size = None
with xutil.get_display() as display:
parent_window = display.create_resource_object(
'window', self.parent_info.window_id)
parent_size = parent_window.get_geometry()
self._width, self._height = parent_size.width, parent_size.height
self.window = self.parent_window.create_window(
0, 0, parent_size.width, parent_size.height, 0,
OverlayWindow.SCREEN_DEPTH,
X.InputOutput,
visual_id,
background_pixmap=0,
colormap=self._colormap,
background_pixel=0,
border_pixel=0,
event_mask=X.ExposureMask)
self.parent_window.change_attributes(
event_mask=X.StructureNotifyMask)
self._set_click_through()
self._set_invisible()
self._display.sync()
def reset_terminal_info(self):
"""Resets the terminal information of this window."""
self.parent_info.reset()
def process_event(self, event):
if (isinstance(event, Xevent.Expose) and
event.window.id == self.window.id and
event.count == 0):
self.draw()
elif (isinstance(event, Xevent.ConfigureNotify) and
event.window.id == self.parent_window.id):
delta_width = event.width - self._width
delta_height = event.height - self._height
if delta_width != 0 or delta_height != 0:
self._width, self._height = event.width, event.height
self.window.configure(
width=event.width,
height=event.height)
self._display.flush()
if delta_width > 0 or delta_height > 0:
self.draw()
def map(self):
self.window.map()
self._display.flush()
def unmap(self):
self.window.unmap()
self._display.flush()
def destroy(self):
"""Destroys the window and it's resources"""
if self.window:
self.window.unmap()
self.window.destroy()
self.window = None
if self._colormap:
self._colormap.free()
self._colormap = None
self._display.flush()
def _set_click_through(self):
"""Sets the input processing area to an area
of 1x1 pixel by using the XShape extension.
So nearly the full window is click-through.
"""
self.window.shape_rectangles(
Xshape.SO.Set, Xshape.SK.Input, 0,
0, 0, [])
def _set_invisible(self):
"""Makes the window invisible."""
self.window.shape_rectangles(
Xshape.SO.Set, Xshape.SK.Bounding, 0,
0, 0, [])