Sort episodes by published date when sending to folder
[gpodder.git] / src / gpodder / gtkui / draw.py
blob2ecf43853113a23c5fe3d25385017fbbfb9d6a76
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2018 The gPodder Team
6 # gPodder is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 3 of the License, or
9 # (at your option) any later version.
11 # gPodder is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
22 # draw.py -- Draw routines for gPodder-specific graphics
23 # Thomas Perl <thp@perli.net>, 2007-11-25
26 import io
27 import math
29 import cairo
31 import gi # isort:skip
32 gi.require_version('Gdk', '3.0') # isort:skip
33 gi.require_version('Gtk', '3.0') # isort:skip
34 gi.require_version('PangoCairo', '1.0') # isort:skip
35 from gi.repository import Gdk, GdkPixbuf, Gtk, Pango, PangoCairo # isort:skip
38 class TextExtents(object):
39 def __init__(self, ctx, text):
40 extents = ctx.text_extents(text)
41 (self.x_bearing, self.y_bearing, self.width, self.height, self.x_advance, self.y_advance) = extents
44 EPISODE_LIST_ICON_SIZE = 16
46 RRECT_LEFT_SIDE = 1
47 RRECT_RIGHT_SIDE = 2
50 def draw_rounded_rectangle(ctx, x, y, w, h, r=10, left_side_width=None,
51 sides_to_draw=0, close=False):
52 assert left_side_width is not None
54 x = int(x)
55 offset = 0
56 if close:
57 offset = 0.5
59 if sides_to_draw & RRECT_LEFT_SIDE:
60 ctx.move_to(x + int(left_side_width) - offset, y + h)
61 ctx.line_to(x + r, y + h)
62 ctx.curve_to(x, y + h, x, y + h, x, y + h - r)
63 ctx.line_to(x, y + r)
64 ctx.curve_to(x, y, x, y, x + r, y)
65 ctx.line_to(x + int(left_side_width) - offset, y)
66 if close:
67 ctx.line_to(x + int(left_side_width) - offset, y + h)
69 if sides_to_draw & RRECT_RIGHT_SIDE:
70 ctx.move_to(x + int(left_side_width) + offset, y)
71 ctx.line_to(x + w - r, y)
72 ctx.curve_to(x + w, y, x + w, y, x + w, y + r)
73 ctx.line_to(x + w, y + h - r)
74 ctx.curve_to(x + w, y + h, x + w, y + h, x + w - r, y + h)
75 ctx.line_to(x + int(left_side_width) + offset, y + h)
76 if close:
77 ctx.line_to(x + int(left_side_width) + offset, y)
80 def rounded_rectangle(ctx, x, y, width, height, radius=4.):
81 """Simple rounded rectangle algorithm
83 http://www.cairographics.org/samples/rounded_rectangle/
84 """
85 degrees = math.pi / 180.
86 ctx.new_sub_path()
87 if width > radius:
88 ctx.arc(x + width - radius, y + radius, radius, -90. * degrees, 0)
89 ctx.arc(x + width - radius, y + height - radius, radius, 0, 90. * degrees)
90 ctx.arc(x + radius, y + height - radius, radius, 90. * degrees, 180. * degrees)
91 ctx.arc(x + radius, y + radius, radius, 180. * degrees, 270. * degrees)
92 ctx.close_path()
95 def draw_text_box_centered(ctx, widget, w_width, w_height, text, font_desc=None, add_progress=None):
96 style_context = widget.get_style_context()
97 text_color = style_context.get_color(Gtk.StateFlags.PRELIGHT)
99 if font_desc is None:
100 font_desc = style_context.get_font(Gtk.StateFlags.NORMAL)
101 font_desc.set_size(14 * Pango.SCALE)
103 pango_context = widget.create_pango_context()
104 layout = Pango.Layout(pango_context)
105 layout.set_font_description(font_desc)
106 layout.set_text(text, -1)
107 width, height = layout.get_pixel_size()
109 ctx.move_to(w_width / 2 - width / 2, w_height / 2 - height / 2)
110 ctx.set_source_rgba(text_color.red, text_color.green, text_color.blue, 0.5)
111 PangoCairo.show_layout(ctx, layout)
113 # Draw an optional progress bar below the text (same width)
114 if add_progress is not None:
115 bar_height = 10
116 ctx.set_source_rgba(*text_color)
117 ctx.set_line_width(1.)
118 rounded_rectangle(ctx,
119 w_width / 2 - width / 2 - .5,
120 w_height / 2 + height - .5, width + 1, bar_height + 1)
121 ctx.stroke()
122 rounded_rectangle(ctx,
123 w_width / 2 - width / 2,
124 w_height / 2 + height, int(width * add_progress) + .5, bar_height)
125 ctx.fill()
128 def draw_cake(percentage, text=None, emblem=None, size=None):
129 # Download percentage bar icon - it turns out the cake is a lie (d'oh!)
130 # ..but the initial idea was to have a cake-style indicator, but that
131 # didn't work as well as the progress bar, but the name stuck..
133 if size is None:
134 size = EPISODE_LIST_ICON_SIZE
136 surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, size, size)
137 ctx = cairo.Context(surface)
139 bgc = get_background_color(Gtk.StateFlags.ACTIVE)
140 # fgc = get_background_color(Gtk.StateFlags.SELECTED)
141 txc = get_foreground_color(Gtk.StateFlags.NORMAL)
143 border = 1.5
144 height = int(size * .4)
145 width = size - 2 * border
146 y = (size - height) / 2 + .5
147 x = border
149 # Background
150 ctx.rectangle(x, y, width, height)
151 ctx.set_source_rgb(bgc.red, bgc.green, bgc.blue)
152 ctx.fill()
154 # Filling
155 if percentage > 0:
156 fill_width = max(1, min(width - 2, (width - 2) * percentage + .5))
157 ctx.rectangle(x + 1, y + 1, fill_width, height - 2)
158 ctx.set_source_rgb(0.289, 0.5625, 0.84765625)
159 ctx.fill()
161 # Border
162 ctx.rectangle(x, y, width, height)
163 ctx.set_source_rgb(txc.red, txc.green, txc.blue)
164 ctx.set_line_width(1)
165 ctx.stroke()
167 del ctx
168 return surface
171 def draw_text_pill(left_text, right_text, x=0, y=0, border=2, radius=14,
172 widget=None, scale=1):
174 # Padding (in px) at the right edge of the image (for Ubuntu; bug 1533)
175 padding_right = 7
177 x_border = border * 2
179 if widget is None:
180 # Use GTK+ style of a normal Button
181 widget = Gtk.Label()
183 style_context = widget.get_style_context()
184 font_desc = style_context.get_font(Gtk.StateFlags.NORMAL)
185 font_desc.set_weight(Pango.Weight.BOLD)
187 pango_context = widget.create_pango_context()
188 layout_left = Pango.Layout(pango_context)
189 layout_left.set_font_description(font_desc)
190 layout_left.set_text(left_text, -1)
191 layout_right = Pango.Layout(pango_context)
192 layout_right.set_font_description(font_desc)
193 layout_right.set_text(right_text, -1)
195 width_left, height_left = layout_left.get_pixel_size()
196 width_right, height_right = layout_right.get_pixel_size()
198 text_height = max(height_left, height_right)
200 left_side_width = width_left + x_border * 2
201 right_side_width = width_right + x_border * 2
203 image_height = int(scale * (y + text_height + border * 2))
204 image_width = int(scale * (x + left_side_width + right_side_width
205 + padding_right))
207 surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, image_width, image_height)
208 surface.set_device_scale(scale, scale)
210 ctx = cairo.Context(surface)
212 # Clip so as to not draw on the right padding (for Ubuntu; bug 1533)
213 ctx.rectangle(0, 0, image_width - padding_right, image_height)
214 ctx.clip()
216 if left_text == '0':
217 left_text = None
218 if right_text == '0':
219 right_text = None
221 rect_width = left_side_width + right_side_width
222 rect_height = text_height + border * 2
223 if left_text is not None:
224 draw_rounded_rectangle(ctx, x, y, rect_width, rect_height, radius,
225 left_side_width, RRECT_LEFT_SIDE, right_text is None)
226 linear = cairo.LinearGradient(x, y, x + left_side_width / 2, y + rect_height / 2)
227 linear.add_color_stop_rgba(0, .8, .8, .8, .5)
228 linear.add_color_stop_rgba(.4, .8, .8, .8, .7)
229 linear.add_color_stop_rgba(.6, .8, .8, .8, .6)
230 linear.add_color_stop_rgba(.9, .8, .8, .8, .8)
231 linear.add_color_stop_rgba(1, .8, .8, .8, .9)
232 ctx.set_source(linear)
233 ctx.fill()
234 xpos, ypos, width_left, height = x + 1, y + 1, left_side_width, rect_height - 2
235 if right_text is None:
236 width_left -= 2
237 draw_rounded_rectangle(ctx, xpos, ypos, rect_width, height, radius, width_left, RRECT_LEFT_SIDE, right_text is None)
238 ctx.set_source_rgba(1., 1., 1., .3)
239 ctx.set_line_width(1)
240 ctx.stroke()
241 draw_rounded_rectangle(ctx, x, y, rect_width, rect_height, radius,
242 left_side_width, RRECT_LEFT_SIDE, right_text is None)
243 ctx.set_source_rgba(.2, .2, .2, .6)
244 ctx.set_line_width(1)
245 ctx.stroke()
247 ctx.move_to(x + x_border, y + 1 + border)
248 ctx.set_source_rgba(0, 0, 0, 1)
249 PangoCairo.show_layout(ctx, layout_left)
250 ctx.move_to(x - 1 + x_border, y + border)
251 ctx.set_source_rgba(1, 1, 1, 1)
252 PangoCairo.show_layout(ctx, layout_left)
254 if right_text is not None:
255 draw_rounded_rectangle(ctx, x, y, rect_width, rect_height, radius, left_side_width, RRECT_RIGHT_SIDE, left_text is None)
256 linear = cairo.LinearGradient(
257 x + left_side_width,
259 x + left_side_width + right_side_width / 2,
260 y + rect_height)
261 linear.add_color_stop_rgba(0, .2, .2, .2, .9)
262 linear.add_color_stop_rgba(.4, .2, .2, .2, .8)
263 linear.add_color_stop_rgba(.6, .2, .2, .2, .6)
264 linear.add_color_stop_rgba(.9, .2, .2, .2, .7)
265 linear.add_color_stop_rgba(1, .2, .2, .2, .5)
266 ctx.set_source(linear)
267 ctx.fill()
268 xpos, ypos, width, height = x, y + 1, rect_width - 1, rect_height - 2
269 if left_text is None:
270 xpos, width = x + 1, rect_width - 2
271 draw_rounded_rectangle(ctx, xpos, ypos, width, height, radius, left_side_width, RRECT_RIGHT_SIDE, left_text is None)
272 ctx.set_source_rgba(1., 1., 1., .3)
273 ctx.set_line_width(1)
274 ctx.stroke()
275 draw_rounded_rectangle(ctx, x, y, rect_width, rect_height, radius, left_side_width, RRECT_RIGHT_SIDE, left_text is None)
276 ctx.set_source_rgba(.1, .1, .1, .6)
277 ctx.set_line_width(1)
278 ctx.stroke()
280 ctx.move_to(x + left_side_width + x_border, y + 1 + border)
281 ctx.set_source_rgba(0, 0, 0, 1)
282 PangoCairo.show_layout(ctx, layout_right)
283 ctx.move_to(x - 1 + left_side_width + x_border, y + border)
284 ctx.set_source_rgba(1, 1, 1, 1)
285 PangoCairo.show_layout(ctx, layout_right)
287 return surface
290 def draw_cake_pixbuf(percentage, text=None, emblem=None, size=None):
291 return cairo_surface_to_pixbuf(draw_cake(percentage, text, emblem, size=size))
294 def draw_pill_pixbuf(left_text, right_text, widget=None, scale=1):
295 return cairo_surface_to_pixbuf(draw_text_pill(left_text, right_text,
296 widget=widget, scale=scale))
299 def cake_size_from_widget(widget=None):
300 if widget is None:
301 # Use GTK+ style of a normal Button
302 widget = Gtk.Label()
303 style_context = widget.get_style_context()
304 font_desc = style_context.get_font(Gtk.StateFlags.NORMAL)
305 pango_context = widget.create_pango_context()
306 layout = Pango.Layout(pango_context)
307 layout.set_font_description(font_desc)
308 layout.set_text("1", -1)
309 # use text height as size
310 return layout.get_pixel_size()[1]
313 def cairo_surface_to_pixbuf(s):
315 Converts a Cairo surface to a Gtk Pixbuf by
316 encoding it as PNG and using the PixbufLoader.
318 bio = io.BytesIO()
319 try:
320 s.write_to_png(bio)
321 except:
322 # Write an empty PNG file to the StringIO, so
323 # in case of an error we have "something" to
324 # load. This happens in PyCairo < 1.1.6, see:
325 # http://webcvs.cairographics.org/pycairo/NEWS?view=markup
326 # Thanks to Chris Arnold for reporting this bug
327 bio.write('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4'
328 'c6QAAAAZiS0dEAP8A\n/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAA'
329 'AAd0SU1FB9cMEQkqIyxn3RkAAAAZdEVYdENv\nbW1lbnQAQ3JlYXRlZCB3a'
330 'XRoIEdJTVBXgQ4XAAAADUlEQVQI12NgYGBgAAAABQABXvMqOgAAAABJ\nRU'
331 '5ErkJggg==\n'.decode('base64'))
333 pbl = GdkPixbuf.PixbufLoader()
334 pbl.write(bio.getvalue())
335 pbl.close()
337 pixbuf = pbl.get_pixbuf()
338 return pixbuf
341 def progressbar_pixbuf(width, height, percentage):
342 COLOR_BG = (.4, .4, .4, .4)
343 COLOR_FG = (.2, .9, .2, 1.)
344 COLOR_FG_HIGH = (1., 1., 1., .5)
345 COLOR_BORDER = (0., 0., 0., 1.)
347 surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height)
348 ctx = cairo.Context(surface)
350 padding = int(width / 8.0)
351 bar_width = 2 * padding
352 bar_height = height - 2 * padding
353 bar_height_fill = bar_height * percentage
355 # Background
356 ctx.rectangle(padding, padding, bar_width, bar_height)
357 ctx.set_source_rgba(*COLOR_BG)
358 ctx.fill()
360 # Foreground
361 ctx.rectangle(padding, padding + bar_height - bar_height_fill, bar_width, bar_height_fill)
362 ctx.set_source_rgba(*COLOR_FG)
363 ctx.fill()
364 ctx.rectangle(padding + bar_width / 3,
365 padding + bar_height - bar_height_fill,
366 bar_width / 4,
367 bar_height_fill)
368 ctx.set_source_rgba(*COLOR_FG_HIGH)
369 ctx.fill()
371 # Border
372 ctx.rectangle(padding - .5, padding - .5, bar_width + 1, bar_height + 1)
373 ctx.set_source_rgba(* COLOR_BORDER)
374 ctx.set_line_width(1.)
375 ctx.stroke()
377 return cairo_surface_to_pixbuf(surface)
380 def get_background_color(state=Gtk.StateFlags.NORMAL, widget=Gtk.TreeView()):
382 @param state state flag (e.g. Gtk.StateFlags.SELECTED to get selected background)
383 @param widget specific widget to get info from.
384 defaults to TreeView which has all one usually wants.
385 @return background color from theme for widget or from its parents if transparent.
387 p = widget
388 color = Gdk.RGBA(0, 0, 0, 0)
389 while p is not None and color.alpha == 0:
390 style_context = p.get_style_context()
391 color = style_context.get_background_color(state)
392 p = p.get_parent()
393 return color
396 def get_foreground_color(state=Gtk.StateFlags.NORMAL, widget=Gtk.TreeView()):
398 @param state state flag (e.g. Gtk.StateFlags.SELECTED to get selected text color)
399 @param widget specific widget to get info from
400 defaults to TreeView which has all one usually wants.
401 @return text color from theme for widget or its parents if transparent
403 p = widget
404 color = Gdk.RGBA(0, 0, 0, 0)
405 while p is not None and color.alpha == 0:
406 style_context = p.get_style_context()
407 color = style_context.get_color(state)
408 p = p.get_parent()
409 return color
412 def investigate_widget_colors(type_classes_and_widgets):
414 investigate using Gtk.StyleContext to get widget style properties
415 I tried to compare values from static and live widgets.
416 To sum up, better use the live widget, because you'll get the correct path, classes, regions automatically.
417 See "CSS Nodes" in widget documentation for classes and sub-nodes (=regions).
418 WidgetPath and Region are replaced by CSSNodes in gtk4.
419 Not sure it's legitimate usage, though: I got different results from one run to another.
420 Run `GTK_DEBUG=interactive ./bin/gpodder` for gtk widget inspection
422 def investigate_stylecontext(style_ctx, label):
423 style_ctx.save()
424 for statename, state in [
425 ('normal', Gtk.StateFlags.NORMAL),
426 ('active', Gtk.StateFlags.ACTIVE),
427 ('link', Gtk.StateFlags.LINK),
428 ('visited', Gtk.StateFlags.VISITED)]:
429 f.write("<dt>%s %s</dt><dd>\n" % (label, statename))
430 colors = {
431 'get_color': style_ctx.get_color(state),
432 'get_background_color': style_ctx.get_background_color(state),
433 'color': style_ctx.get_property('color', state),
434 'background-color': style_ctx.get_property('background-color', state),
435 'outline-color': style_ctx.get_property('outline-color', state),
437 f.write("<p>PREVIEW: <span style='background-color: %s; color: %s'>get_color + get_background_color</span>"
438 % (colors['get_background_color'].to_string(),
439 colors['get_color'].to_string()))
440 f.write("<span style='background-color: %s; color: %s; border solid 2px %s;'>color + background-color properties</span></p>\n"
441 % (colors['background-color'].to_string(),
442 colors['color'].to_string(),
443 colors['outline-color'].to_string()))
444 f.write("<p>VALUES: ")
445 for p, v in colors.items():
446 f.write("%s=<span style='background-color: %s;'>%s</span>" % (p, v.to_string(), v.to_string()))
447 f.write("</p></dd>\n")
448 style_ctx.restore()
450 with open('/tmp/colors.html', 'w') as f:
451 f.write("""<html>
452 <style type='text/css'>
453 body {color: red; background: yellow;}
454 span { display: inline-block; margin-right: 1ch; }
455 dd { margin-bottom: 1em; }
456 td { vertical-align: top; }
457 </style>
458 <table>""")
459 for type_and_class, w in type_classes_and_widgets:
460 f.write("<tr><td><dl>\n")
461 # Create an empty style context
462 style_ctx = Gtk.StyleContext()
463 # Create an empty widget path
464 widget_path = Gtk.WidgetPath()
465 # Specify the widget class type you want to get colors from
466 for t, c, r in type_and_class:
467 widget_path.append_type(t)
468 if c:
469 widget_path.iter_add_class(widget_path.length() - 1, c)
470 if r:
471 widget_path.iter_add_region(widget_path.length() - 1, r, 0)
472 style_ctx.set_path(widget_path)
474 investigate_stylecontext(
475 style_ctx,
476 'STATIC {}'.format(' '.join('{}.{}({})'.format(t.__name__, c, r) for t, c, r in type_and_class)))
478 f.write("</dl></td><td><dl>\n")
480 investigate_stylecontext(w.get_style_context(), 'LIVE {}'.format(type(w).__name__))
482 f.write("</dl></td></tr>\n")
483 f.write("</table></html>\n")
486 def draw_iconcell_scale(column, cell, model, iterator, scale):
488 Draw cell's pixbuf to a surface with proper scaling for high resolution
489 displays. To be used as gtk.TreeViewColumn.set_cell_data_func.
491 :param column: gtk.TreeViewColumn (ignored)
492 :param cell: gtk.CellRenderer
493 :param model: gtk.TreeModel (ignored)
494 :param iter: gtk.TreeIter (ignored)
495 :param scale: factor of the target display (e.g. 1 or 2)
497 pixbuf = cell.props.pixbuf
498 if not pixbuf:
499 return
501 width = pixbuf.get_width()
502 height = pixbuf.get_height()
503 scale_inv = 1 / scale
505 surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height)
506 surface.set_device_scale(scale, scale)
508 cr = cairo.Context(surface)
509 cr.scale(scale_inv, scale_inv)
510 Gdk.cairo_set_source_pixbuf(cr, cell.props.pixbuf, 0, 0)
511 cr.paint()
513 cell.props.surface = surface