discover-desktop/discover_overlay/notification_overlay.py
Trigg c0098a45f9 - Catch resolution & monitor changes
- Attempt to re assign monitor when needed
- Still buggy but an improvement
- Bump version
- Fix Window name on notifications
2024-02-03 16:08:45 +00:00

518 lines
19 KiB
Python

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Notification window for text"""
import logging
import time
import re
import cairo
import math
import gi
from .image_getter import get_surface, draw_img_to_rect, get_aspected_size
from .overlay import OverlayWindow
gi.require_version("Gtk", "3.0")
gi.require_version('PangoCairo', '1.0')
# pylint: disable=wrong-import-position,wrong-import-order
from gi.repository import Gtk, Pango, PangoCairo # nopep8
log = logging.getLogger(__name__)
class NotificationOverlayWindow(OverlayWindow):
"""Overlay window for notifications"""
def __init__(self, discover, piggyback=None):
OverlayWindow.__init__(self, discover, piggyback)
self.text_spacing = 4
self.content = []
self.test_content = [{"icon": "https://cdn.discordapp.com/icons/951077080769114172/991abffc0d2a5c040444be4d1a4085f4.webp?size=96", "title": "Title1"},
{"title": "Title2", "body": "Body", "icon": None},
{"icon": "https://cdn.discordapp.com/icons/951077080769114172/991abffc0d2a5c040444be4d1a4085f4.webp?size=96", "title": "Title 3",
"body": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."},
{"icon": None, "title": "Title 3", "body": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."},
{"icon": "https://cdn.discordapp.com/avatars/147077941317206016/6a6935192076489fa6dc1eb5dafbf6e7.webp?size=128", "title": "PM", "body": "Birdy test"}]
self.text_font = None
self.text_size = 13
self.text_time = None
self.show_icon = None
self.pango_rect = Pango.Rectangle()
self.pango_rect.width = self.text_size * Pango.SCALE
self.pango_rect.height = self.text_size * Pango.SCALE
self.connected = True
self.bg_col = [0.0, 0.6, 0.0, 0.1]
self.fg_col = [1.0, 1.0, 1.0, 1.0]
self.icons = []
self.reverse_order = False
self.padding = 10
self.border_radius = 5
self.limit_width = 100
self.testing = False
self.icon_size = 64
self.icon_pad = 16
self.icon_left = True
self.image_list = {}
self.warned_filetypes = []
self.set_title("Discover Notifications")
self.redraw()
def set_blank(self):
self.content = []
self.needsredraw = True
def tick(self):
# This doesn't really belong in overlay or settings
now = time.time()
newlist = []
oldsize = len(self.content)
# Iterate over and remove messages older than 30s
for message in self.content:
if message['time'] + self.text_time > now:
newlist.append(message)
self.content = newlist
# If the list is different than before
if oldsize != len(newlist):
self.needsredraw = True
def add_notification_message(self, data):
noti = None
data = data['data']
message_id = data['message']['id']
for message in self.content:
if message['id'] == message_id:
return
if 'body' in data and 'title' in data:
if 'icon_url' in data:
noti = {"icon": data['icon_url'],
"title": data['title'],
"body": data['body'], "time": time.time(),
"id": message_id}
else:
noti = {"title": data['title'],
"body": data['body'], "time": time.time(),
"id": message_id}
if noti:
self.content.append(noti)
self.needsredraw = True
self.get_all_images()
def set_padding(self, padding):
"""
Set the padding between notifications
"""
self.padding = padding
self.needsredraw = True
def set_border_radius(self, radius):
"""
Set the radius of the border
"""
self.border_radius = radius
self.needsredraw = True
def set_icon_size(self, size):
"""
Set Icon size
"""
self.icon_size = size
self.image_list = {}
self.get_all_images()
def set_icon_pad(self, pad):
"""
Set padding between icon and message
"""
self.icon_pad = pad
self.needsredraw = True
def set_icon_left(self, left):
self.icon_left = left
self.needsredraw = True
def set_text_time(self, timer):
"""
Set the duration that a message will be visible for.
"""
self.text_time = timer
def set_limit_width(self, limit):
"""
Set the word wrap limit in pixels
"""
self.limit_width = limit
self.needsredraw = True
def get_all_images(self):
the_list = self.content
if self.testing:
the_list = self.test_content
for line in the_list:
icon = line["icon"]
if icon and icon not in self.image_list:
get_surface(self.recv_icon, icon, icon,
self.icon_size)
def recv_icon(self, identifier, pix, mask):
"""
Called when image_getter has downloaded an image
"""
self.image_list[identifier] = pix
self.needsredraw = True
def set_fg(self, fg_col):
"""
Set default text colour
"""
self.fg_col = fg_col
self.needsredraw = True
def set_bg(self, bg_col):
"""
Set background colour
"""
self.bg_col = bg_col
self.needsredraw = True
def set_show_icon(self, icon):
"""
Set if icons should be shown inline
"""
self.show_icon = icon
self.needsredraw = True
self.get_all_images()
def set_reverse_order(self, rev):
self.reverse_order = rev
self.needsredraw = True
def set_font(self, font):
"""
Set font used to render text
"""
self.text_font = font
self.pango_rect = Pango.Rectangle()
font = Pango.FontDescription(self.text_font)
self.pango_rect.width = font.get_size() * Pango.SCALE
self.pango_rect.height = font.get_size() * Pango.SCALE
self.needsredraw = True
def recv_attach(self, identifier, pix):
"""
Called when an image has been downloaded by image_getter
"""
self.icons[identifier] = pix
self.needsredraw = True
def calc_all_height(self):
h = 0
my_list = self.content
if self.testing:
my_list = self.test_content
for line in my_list:
h += self.calc_height(line)
if h > 0:
h -= self.padding # Remove one unneeded padding
return h
def calc_height(self, line):
icon_width = 0
icon_pad = 0
icon = line['icon']
if self.show_icon and icon and icon in self.image_list and self.image_list[icon]:
icon_width = self.icon_size
icon_pad = self.icon_pad
message = ""
if 'body' in line and len(line['body']) > 0:
m_no_body = "<span>%s</span>\n%s"
message = m_no_body % (self.sanitize_string(line["title"]),
self.sanitize_string(line['body']))
else:
m_with_body = "<span>%s</span>"
message = m_with_body % (self.sanitize_string(line["title"]))
layout = self.create_pango_layout(message)
layout.set_auto_dir(True)
layout.set_markup(message, -1)
attr = layout.get_attributes()
width = self.limit_width if self.width > self.limit_width else self.width
layout.set_width((Pango.SCALE * (width -
(self.border_radius * 4 + icon_width + icon_pad))))
layout.set_spacing(Pango.SCALE * 3)
if self.text_font:
font = Pango.FontDescription(self.text_font)
layout.set_font_description(font)
text_width, text_height = layout.get_pixel_size()
if text_height < icon_width:
text_height = icon_width
return text_height + (self.border_radius*4) + self.padding
def has_content(self):
if not self.enabled:
return False
if self.hidden:
return False
if self.testing:
return self.test_content
return self.content
def overlay_draw(self, _w, context, data=None):
"""
Draw the overlay
"""
if self.piggyback:
self.piggyback.overlay_draw(w, context, data)
if not self.enabled:
return
self.context = context
(width, height) = self.get_size()
if not self.piggyback_parent:
context.set_antialias(cairo.ANTIALIAS_GOOD)
# Make background transparent
context.set_source_rgba(0.0, 0.0, 0.0, 0.0)
context.set_operator(cairo.OPERATOR_SOURCE)
context.paint()
context.save()
if self.is_wayland or self.piggyback_parent or self.discover.steamos:
# Special case!
# The window is full-screen regardless of what the user has selected.
# We need to set a clip and a transform to imitate original behaviour
# Used in wlroots & gamescope
width = self.width
height = self.height
context.translate(self.pos_x, self.pos_y)
context.rectangle(0, 0, width, height)
context.clip()
current_y = height
if self.align_vert == 0:
current_y = 0
if self.align_vert == 1: # Center. Oh god why
current_y = (height/2.0) - (self.calc_all_height() / 2.0)
tnow = time.time()
if self.testing:
the_list = self.test_content
else:
the_list = self.content
if self.reverse_order:
the_list = reversed(the_list)
for line in the_list:
col = "#fff"
if 'body' in line and len(line['body']) > 0:
m_no_body = "<span foreground='%s'>%s</span>\n%s"
message = m_no_body % (self.sanitize_string(col),
self.sanitize_string(line["title"]),
self.sanitize_string(line['body']))
else:
m_with_body = "<span foreground='%s'>%s</span>"
message = m_with_body % (self.sanitize_string(col),
self.sanitize_string(line["title"]))
icon = None
# If we've got an embedded image
if "icon_surface" in line and line["icon_surface"]:
icon = line["icon_surface"]
# If we're given an icon name, it's in the list of icons, and it's not none
elif line["icon"] and line["icon"] in self.image_list and self.image_list[line["icon"]]:
icon = self.image_list[line["icon"]]
current_y = self.draw_text(current_y, message, icon)
if current_y <= 0:
# We've done enough
break
context.restore()
self.context = None
def draw_text(self, pos_y, text, icon):
"""
Draw a text message, returning the Y position of the next message
"""
icon_width = self.icon_size
icon_pad = self.icon_pad
if not self.show_icon:
icon = None
if not icon:
icon_pad = 0
icon_width = 0
layout = self.create_pango_layout(text)
layout.set_auto_dir(True)
layout.set_markup(text, -1)
attr = layout.get_attributes()
width = self.limit_width if self.width > self.limit_width else self.width
layout.set_width((Pango.SCALE * (width -
(self.border_radius * 4 + icon_width + icon_pad))))
layout.set_spacing(Pango.SCALE * 3)
if self.text_font:
font = Pango.FontDescription(self.text_font)
layout.set_font_description(font)
text_width, text_height = layout.get_pixel_size()
self.col(self.bg_col)
top = 0
if self.align_vert == 2: # Bottom align
top = pos_y - (text_height + self.border_radius * 4)
else: # Top align
top = pos_y
if text_height < icon_width:
text_height = icon_width
shape_height = text_height + self.border_radius * 4
shape_width = text_width + self.border_radius*4 + icon_width + icon_pad
left = 0
if self.align_right:
left = self.width - shape_width
self.context.save()
# Draw Background
self.context.translate(left, top)
# self.context.rectangle(self.border_radius, 0,
# shape_width - (self.border_radius*2), shape_height)
# self.context.fill()
# self.context.rectangle(0, self.border_radius,
# shape_width, shape_height - (self.border_radius * 2))
#self.context.arc(0.7, 0.3, 0.035, 1.25 * math.pi, 2.25 * math.pi)
#self.context.arc(0.3, 0.7, 0.035, .25 * math.pi, 1.25 * math.pi)
if self.border_radius == 0:
self.context.move_to(0.0, 0.0)
self.context.line_to(shape_width, 0.0)
self.context.line_to(shape_width, shape_height)
self.context.line_to(0.0, shape_height)
self.context.close_path()
self.context.fill()
else:
# Edge top
self.context.move_to(self.border_radius, 0.0)
self.context.line_to(shape_width - self.border_radius, 0.0)
# Arc topright
self.context.arc(shape_width - self.border_radius, self.border_radius,
self.border_radius, 1.5 * math.pi, 2 * math.pi)
# Edge right
self.context.line_to(
shape_width, shape_height - self.border_radius)
# Arc bottomright
self.context.arc(shape_width - self.border_radius, shape_height - self.border_radius,
self.border_radius, 0.0, 0.5 * math.pi)
# Edge bottom
self.context.line_to(self.border_radius, shape_height)
# Arch bottomleft
self.context.arc(self.border_radius, shape_height -
self.border_radius, self.border_radius, 0.5 * math.pi, math.pi)
# Edge left
self.context.line_to(0.0, self.border_radius)
# Arc topleft
self.context.arc(self.border_radius, self.border_radius,
self.border_radius, math.pi, 1.5 * math.pi)
# End
self.context.close_path()
self.context.fill()
self.context.set_operator(cairo.OPERATOR_OVER)
# Draw Image
if icon:
self.context.save()
if self.icon_left:
self.context.translate(
self.border_radius*2, self.border_radius*2)
draw_img_to_rect(icon, self.context, 0, 0,
icon_width, icon_width)
else:
self.context.translate(
self.border_radius*2 + text_width + icon_pad, self.border_radius*2)
draw_img_to_rect(icon, self.context, 0, 0,
icon_width, icon_width)
self.context.restore()
self.col(self.fg_col)
if self.icon_left:
self.context.translate(
self.border_radius*2 + icon_width + icon_pad, self.border_radius*2)
PangoCairo.context_set_shape_renderer(
self.get_pango_context(), self.render_custom, None)
text = layout.get_text()
count = 0
layout.set_attributes(attr)
PangoCairo.show_layout(self.context, layout)
else:
self.context.translate(self.border_radius*2, self.border_radius*2)
PangoCairo.context_set_shape_renderer(
self.get_pango_context(), self.render_custom, None)
text = layout.get_text()
count = 0
layout.set_attributes(attr)
PangoCairo.show_layout(self.context, layout)
self.context.restore()
next_y = 0
if self.align_vert == 2:
next_y = pos_y - (shape_height + self.padding)
else:
next_y = pos_y + shape_height + self.padding
return next_y
def render_custom(self, ctx, shape, path, _data):
"""
Draw an inline image as a custom emoticon
"""
if shape.data >= len(self.image_list):
log.warning(f"{shape.data} >= {len(self.image_list)}")
return
# key is the url to the image
key = self.image_list[shape.data]
if key not in self.attachment:
get_surface(self.recv_attach,
key,
key, None)
return
pix = self.attachment[key]
(pos_x, pos_y) = ctx.get_current_point()
draw_img_to_rect(pix, ctx, pos_x, pos_y - self.text_size, self.text_size,
self.text_size, path=path)
return True
def sanitize_string(self, string):
"""
Sanitize a text message so that it doesn't intefere with Pango's XML format
"""
string = string.replace("&", "&amp;")
string = string.replace("<", "&lt;")
string = string .replace(">", "&gt;")
string = string.replace("'", "&#39;")
string = string.replace("\"", "&#34;")
return string
def set_testing(self, testing):
self.testing = testing
self.needsredraw = True
self.get_all_images()