mirror of
https://github.com/odoo/runbot.git
synced 2025-03-15 15:35:46 +07:00
[IMP] runbot_merge: reorganise composition of PR dashboard pic
The previous version worked but was extremely plodding and procedural. Initially I wanted to compose the table in a single pass but that turns out not to really be possible as the goal for #908 is to have a "drawer" for extended information about the current batch: this means different cells of the same row can have different heights, so we can't one-pass the image either vertically (later cells of the current column might be wider) or horizontally (later cells of the current row might be taller). However what can be done is give the entire thing *structure*, essentially defining a very cut down and ad-hoc layout system before committing the layout to raster. This also deduplicates formatting and labelling information which was previously in both the computation first step and the rasterisation second step.
This commit is contained in:
parent
6cc9a6ca11
commit
f5eb7447fb
@ -8,11 +8,14 @@ import hashlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import pathlib
|
||||
from dataclasses import dataclass
|
||||
from email.utils import formatdate
|
||||
from enum import Flag, auto
|
||||
from functools import cached_property
|
||||
from itertools import chain, product
|
||||
from typing import Tuple, cast, Mapping
|
||||
from math import ceil
|
||||
from typing import Tuple, cast, Mapping, Optional, List
|
||||
|
||||
import markdown
|
||||
import markupsafe
|
||||
@ -23,6 +26,8 @@ from PIL import Image, ImageDraw, ImageFont
|
||||
from odoo.http import Controller, route, request
|
||||
from odoo.tools import file_open
|
||||
|
||||
HORIZONTAL_PADDING = 20
|
||||
VERTICAL_PADDING = 5
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@ -204,6 +209,83 @@ def raster_render(pr):
|
||||
im.save(buffer, 'png', optimize=True)
|
||||
return werkzeug.wrappers.Response(buffer.getvalue(), headers=headers)
|
||||
|
||||
class Decoration(Flag):
|
||||
STRIKETHROUGH = auto()
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Text:
|
||||
content: str
|
||||
font: ImageFont.FreeTypeFont
|
||||
color: Color
|
||||
decoration: Decoration = Decoration(0)
|
||||
|
||||
@cached_property
|
||||
def width(self) -> int:
|
||||
return ceil(self.font.getlength(self.content))
|
||||
|
||||
@property
|
||||
def height(self) -> int:
|
||||
return sum(self.font.getmetrics())
|
||||
|
||||
def draw(self, image: ImageDraw.ImageDraw, left: int, top: int):
|
||||
image.text((left, top), self.content, fill=self.color, font=self.font)
|
||||
if Decoration.STRIKETHROUGH in self.decoration:
|
||||
x1, _, x2, _ = self.font.getbbox(self.content)
|
||||
_, y1, _, y2 = self.font.getbbox('x')
|
||||
# put the strikethrough line about 1/3rd down the x (default seems
|
||||
# to be a bit above halfway down but that's ugly with numbers which
|
||||
# is most of our stuff)
|
||||
y = top + y1 + (y2 - y1) / 3
|
||||
image.line([(left + x1, y), (left + x2, y)], self.color)
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Line:
|
||||
spans: List[Text]
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
return sum(s.width for s in self.spans)
|
||||
|
||||
@property
|
||||
def height(self) -> int:
|
||||
return max(s.height for s in self.spans) if self.spans else 0
|
||||
|
||||
def draw(self, image: ImageDraw.ImageDraw, left: int, top: int):
|
||||
for span in self.spans:
|
||||
span.draw(image, left, top)
|
||||
left += span.width
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Lines:
|
||||
lines: List[Line]
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
return max(l.width for l in self.lines)
|
||||
|
||||
@property
|
||||
def height(self) -> int:
|
||||
return sum(l.height for l in self.lines)
|
||||
|
||||
def draw(self, image: ImageDraw.ImageDraw, left: int, top: int):
|
||||
for line in self.lines:
|
||||
line.draw(image, left, top)
|
||||
top += line.height
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Cell:
|
||||
content: Lines | Line | Text
|
||||
background: Color = (255, 255, 255)
|
||||
attached: bool = True
|
||||
|
||||
@cached_property
|
||||
def width(self) -> int:
|
||||
return self.content.width + 2 * HORIZONTAL_PADDING
|
||||
|
||||
@cached_property
|
||||
def height(self) -> int:
|
||||
return self.content.height + 2 * VERTICAL_PADDING
|
||||
|
||||
|
||||
def render_full_table(pr, branches, repos, batches):
|
||||
with file_open('web/static/fonts/google/Open_Sans/Open_Sans-Regular.ttf', 'rb') as f:
|
||||
@ -212,106 +294,84 @@ def render_full_table(pr, branches, repos, batches):
|
||||
supfont = ImageFont.truetype(f, size=13, layout_engine=0)
|
||||
with file_open('web/static/fonts/google/Open_Sans/Open_Sans-Bold.ttf', 'rb') as f:
|
||||
bold = ImageFont.truetype(f, size=16, layout_engine=0)
|
||||
# getbbox returns (left, top, right, bottom)
|
||||
rows = {b: font.getbbox(b.name)[3] for b in branches}
|
||||
rows[None] = max(bold.getbbox(r.name)[3] for r in repos)
|
||||
columns = {r: bold.getbbox(r.name)[2] for r in repos}
|
||||
columns[None] = max(font.getbbox(b.name)[2] for b in branches)
|
||||
for r, b in product(repos, branches):
|
||||
ps = batches[r, b]
|
||||
w = h = 0
|
||||
for p in ps['prs']:
|
||||
_, _, ww, hh = font.getbbox(f" #{p['number']}")
|
||||
w += ww + supfont.getbbox(' '.join(filter(None, [
|
||||
'error' if p['pr'].error else '',
|
||||
'' if p['checked'] else 'missing statuses',
|
||||
'' if p['reviewed'] else 'missing r+',
|
||||
'' if p['attached'] else 'detached',
|
||||
'staged' if p['pr'].staging_id else 'ready' if p['pr']._ready else '',
|
||||
])))[2]
|
||||
h = max(hh, h)
|
||||
rows[b] = max(rows.get(b, 0), h)
|
||||
columns[r] = max(columns.get(r, 0), w)
|
||||
|
||||
pad_w, pad_h = 20, 5
|
||||
image_height = sum(rows.values()) + 2 * pad_h * len(rows)
|
||||
image_width = sum(columns.values()) + 2 * pad_w * len(columns)
|
||||
im = Image.new("RGB", (image_width+1, image_height+1), color='white')
|
||||
rowheights = collections.defaultdict(int)
|
||||
colwidths = collections.defaultdict(int)
|
||||
cells = {}
|
||||
for b in chain([None], branches):
|
||||
for r in chain([None], repos):
|
||||
opacity = 1.0 if b is None or b.active else 0.5
|
||||
current_row = b == pr.target
|
||||
background = BG['info'] if current_row or r == pr.repository else BG[None]
|
||||
|
||||
if b is None: # first row
|
||||
cell = Cell(Text("" if r is None else r.name, bold, TEXT), background)
|
||||
elif r is None: # first column
|
||||
cell = Cell(Text(b.name, font, blend(TEXT, opacity, over=background)), background)
|
||||
else:
|
||||
ps = batches[r, b]
|
||||
bgcolor = lighten(BG[ps['state']], by=-0.05) if pr in ps['pr_ids'] else BG[ps['state']]
|
||||
background = blend(bgcolor, opacity, over=background)
|
||||
foreground = blend((39, 110, 114), opacity, over=background)
|
||||
|
||||
line = []
|
||||
attached = True
|
||||
for p in ps['prs']:
|
||||
line.append(Text(
|
||||
f"#{p['number']}",
|
||||
font,
|
||||
foreground,
|
||||
decoration=Decoration.STRIKETHROUGH if p['closed'] else Decoration(0),
|
||||
))
|
||||
attached = attached and p['attached']
|
||||
for attribute in filter(None, [
|
||||
'error' if p['pr'].error else '',
|
||||
'' if p['checked'] else 'missing statuses',
|
||||
'' if p['reviewed'] else 'missing r+',
|
||||
'' if p['attached'] else 'detached',
|
||||
'staged' if p['pr'].staging_id else 'ready' if p['pr']._ready else ''
|
||||
]):
|
||||
color = SUCCESS if attribute in ('staged', 'ready') else ERROR
|
||||
line.append(Text(f' {attribute}', supfont, blend(color, opacity, over=background)))
|
||||
line.append(Text(" ", font, foreground))
|
||||
cell = Cell(Line(line), background, attached)
|
||||
|
||||
cells[r, b] = cell
|
||||
rowheights[b] = max(rowheights[b], cell.height)
|
||||
colwidths[r] = max(colwidths[r], cell.width)
|
||||
|
||||
im = Image.new("RGB", (sum(colwidths.values()), sum(rowheights.values())), "white")
|
||||
# no need to set the font here because every text element has its own
|
||||
draw = ImageDraw.Draw(im, 'RGB')
|
||||
draw.font = font
|
||||
# for reasons of that being more convenient we store the bottom of the
|
||||
# current row, so getting the top edge requires subtracting h
|
||||
w = left = bottom = 0
|
||||
for b, r in product(chain([None], branches), chain([None], repos)):
|
||||
left += w
|
||||
top = 0
|
||||
for b in chain([None], branches):
|
||||
left = 0
|
||||
for r in chain([None], repos):
|
||||
cell = cells[r, b]
|
||||
|
||||
opacity = 1.0 if b is None or b.active else 0.5
|
||||
background = BG['info'] if b == pr.target or r == pr.repository else BG[None]
|
||||
w, h = columns[r] + 2 * pad_w, rows[b] + 2 * pad_h
|
||||
# for a given cell, we first print the background, then the text, then
|
||||
# the borders
|
||||
# need to subtract 1 because pillow uses inclusive rect coordinates
|
||||
right = left + colwidths[r] - 1
|
||||
bottom = top + rowheights[b] - 1
|
||||
draw.rectangle(
|
||||
(left, top, right, bottom),
|
||||
cell.background,
|
||||
)
|
||||
# draw content adding padding
|
||||
cell.content.draw(draw, left=left + HORIZONTAL_PADDING, top=top + VERTICAL_PADDING)
|
||||
# draw bottom-right border
|
||||
draw.line([
|
||||
(left, bottom),
|
||||
(right, bottom),
|
||||
(right, top),
|
||||
], fill=(172, 176, 170))
|
||||
if not cell.attached:
|
||||
# overdraw previous cell's bottom border
|
||||
draw.line([(left, top-1), (right-1, top-1)], fill=ERROR)
|
||||
|
||||
if r is None: # branch cell in row
|
||||
left = 0
|
||||
bottom += h
|
||||
if b:
|
||||
draw.rectangle(
|
||||
(left + 1, bottom - h + 1, left + w - 1, bottom - 1),
|
||||
background,
|
||||
)
|
||||
draw.text(
|
||||
(left + pad_w, bottom - h + pad_h),
|
||||
b.name,
|
||||
fill=blend(TEXT, opacity, over=background),
|
||||
)
|
||||
elif b is None: # repo cell in top row
|
||||
draw.rectangle((left + 1, bottom - h + 1, left + w - 1, bottom - 1), background)
|
||||
draw.text((left + pad_w, bottom - h + pad_h), r.name, fill=TEXT, font=bold)
|
||||
# draw the bottom-right edges of the cell
|
||||
draw.line([
|
||||
(left, bottom), # bottom-left
|
||||
(left + w, bottom), # bottom-right
|
||||
(left + w, bottom - h) # top-right
|
||||
], fill=(172, 176, 170))
|
||||
if r is None or b is None:
|
||||
continue
|
||||
|
||||
ps = batches[r, b]
|
||||
|
||||
bgcolor = BG[ps['state']]
|
||||
if pr in ps['pr_ids']:
|
||||
bgcolor = lighten(bgcolor, by=-0.05)
|
||||
background = blend(bgcolor, opacity, over=background)
|
||||
draw.rectangle((left + 1, bottom - h + 1, left + w - 1, bottom - 1), background)
|
||||
|
||||
top = bottom - h + pad_h
|
||||
offset = left + pad_w
|
||||
for p in ps['prs']:
|
||||
label = f"#{p['number']}"
|
||||
foreground = blend((39, 110, 114), opacity, over=background)
|
||||
draw.text((offset, top), label, fill=foreground)
|
||||
x, _, ww, hh = font.getbbox(label)
|
||||
if p['closed']:
|
||||
draw.line([
|
||||
(offset + x, top + hh - hh / 3),
|
||||
(offset + x + ww, top + hh - hh / 3),
|
||||
], fill=foreground)
|
||||
offset += ww
|
||||
if not p['attached']:
|
||||
# overdraw top border to mark the detachment
|
||||
draw.line([(left, bottom - h), (left + w, bottom - h)], fill=ERROR)
|
||||
for attribute in filter(None, [
|
||||
'error' if p['pr'].error else '',
|
||||
'' if p['checked'] else 'missing statuses',
|
||||
'' if p['reviewed'] else 'missing r+',
|
||||
'' if p['attached'] else 'detached',
|
||||
'staged' if p['pr'].staging_id else 'ready' if p['pr']._ready else ''
|
||||
]):
|
||||
color = SUCCESS if attribute in ('staged', 'ready') else ERROR
|
||||
label = f' {attribute}'
|
||||
draw.text((offset, top), label,
|
||||
fill=blend(color, opacity, over=background),
|
||||
font=supfont)
|
||||
offset += supfont.getbbox(label)[2]
|
||||
offset += math.ceil(supfont.getlength(" "))
|
||||
left += colwidths[r]
|
||||
top += rowheights[b]
|
||||
|
||||
return im
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user