discover-desktop/discover_overlay/notification_overlay.py
2024-07-08 23:32:59 +01:00

552 lines
21 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 math
import cairo
import gi
from .image_getter import get_surface, draw_img_to_rect
from .overlay import OverlayWindow
gi.require_version('PangoCairo', '1.0')
# pylint: disable=wrong-import-position,wrong-import-order
from gi.repository import 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):
"""Set to no data and redraw"""
self.content = []
self.set_needs_redraw()
def tick(self):
"""Remove old messages from dataset"""
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.set_needs_redraw()
def add_notification_message(self, data):
"""Add new message to dataset"""
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.set_needs_redraw()
self.get_all_images()
def set_padding(self, padding):
"""Config option: Padding between notifications, in window-space pixels"""
if self.padding != padding:
self.padding = padding
self.set_needs_redraw()
def set_border_radius(self, radius):
"""Config option: Radius of the border, in window-space pixels"""
if self.border_radius != radius:
self.border_radius = radius
self.set_needs_redraw()
def set_icon_size(self, size):
"""Config option: Size of icons, in window-space pixels"""
if self.icon_size != size:
self.icon_size = size
self.image_list = {}
self.get_all_images()
def set_icon_pad(self, pad):
"""Config option: Padding between icon and message, in window-space pixels"""
if self.icon_pad != pad:
self.icon_pad = pad
self.set_needs_redraw()
def set_icon_left(self, left):
"""Config option: Icon on left or right of text"""
if self.icon_left != left:
self.icon_left = left
self.set_needs_redraw()
def set_text_time(self, timer):
"""Config option: Duration that a message will be visible for, in seconds"""
self.text_time = timer
self.timer_after_draw = timer
def set_limit_width(self, limit):
"""Config option: Word wrap limit, in window-space pixels
"""
if self.limit_width != limit:
self.limit_width = limit
self.set_needs_redraw()
def get_all_images(self):
"""Return a list of all downloaded images"""
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):
"""Callback from image_getter for icons"""
self.image_list[identifier] = pix
self.set_needs_redraw()
def set_fg(self, fg_col):
"""Config option: Set default text colour"""
if self.fg_col != fg_col:
self.fg_col = fg_col
self.set_needs_redraw()
def set_bg(self, bg_col):
"""Config option: Set background colour"""
if self.bg_col != bg_col:
self.bg_col = bg_col
self.set_needs_redraw()
def set_show_icon(self, icon):
"""Config option: Set if icons should be shown inline"""
if self.show_icon != icon:
self.show_icon = icon
self.set_needs_redraw()
self.get_all_images()
def set_reverse_order(self, rev):
"""Config option: Reverse order of messages"""
if self.reverse_order != rev:
self.reverse_order = rev
self.set_needs_redraw()
def set_font(self, font):
"""Config option: Font used to render text"""
if self.text_font != font:
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.set_needs_redraw()
def recv_attach(self, identifier, pix):
"""Callback from image_getter for attachments"""
self.icons[identifier] = pix
self.set_needs_redraw()
def calc_all_height(self):
"""Return the height in window-space pixels required
to draw this overlay with current dataset"""
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):
"""Return height in window-space pixels required to draw individual notification"""
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)
(_floating_x, _floating_y, floating_width,
_floating_height) = self.get_floating_coords()
width = self.limit_width if floating_width > self.limit_width else floating_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):
"""Return true if this overlay has meaningful content to show"""
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()
self.tick()
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
(floating_x, floating_y, floating_width,
floating_height) = self.get_floating_coords()
if self.floating:
context.new_path()
context.translate(floating_x, floating_y)
context.rectangle(0, 0, floating_width, floating_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)
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()
(_floating_x, _floating_y, floating_width,
_floating_height) = self.get_floating_coords()
width = self.limit_width if floating_width > self.limit_width else floating_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 = floating_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()
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()
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("%s >= %s", 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.icons:
get_surface(self.recv_attach,
key,
key, None)
return
pix = self.icons[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):
"""Toggle placeholder images for testing"""
self.testing = testing
self.set_needs_redraw()
self.get_all_images()