diff --git a/main_window.py b/main_window.py index 2b73431..2686a15 100644 --- a/main_window.py +++ b/main_window.py @@ -978,6 +978,22 @@ class MainWindow(QMainWindow): else: self._save_to(self.document.path) + # Choose theme for export + theme_msg = QMessageBox(self) + theme_msg.setIcon(QMessageBox.Icon.Question) + theme_msg.setWindowTitle("Выбор темы экспорта") + theme_msg.setText("Выберите цветовую тему для экспортируемого изображения:") + dark_btn = theme_msg.addButton("Тёмная тема", QMessageBox.ButtonRole.AcceptRole) + light_btn = theme_msg.addButton("Светлая тема", QMessageBox.ButtonRole.ActionRole) + cancel_theme_btn = theme_msg.addButton("Отмена", QMessageBox.ButtonRole.RejectRole) + theme_msg.exec() + + clicked_theme = theme_msg.clickedButton() + if clicked_theme == cancel_theme_btn: + return + + theme = "dark" if clicked_theme == dark_btn else "light" + default_name = "mapping.png" if self.document.path is not None: default_name = self.document.path.stem + ".png" @@ -996,7 +1012,7 @@ class MainWindow(QMainWindow): if not file_path: return - success = render_mapping_image(self.document, file_path) + success = render_mapping_image(self.document, file_path, theme=theme) if success: self.status.setText(f"Схема успешно экспортирована в: {file_path}") else: diff --git a/mapping_renderer.py b/mapping_renderer.py index e7fa0d5..0993de4 100644 --- a/mapping_renderer.py +++ b/mapping_renderer.py @@ -2,20 +2,44 @@ Draws the LED mapping directly from MappingDocument data, completely independent of the Qt scene/canvas so no UI labels or zoom state leak in. + +Layout +------ +* Top part : LED mapping (square canvas) +* Bottom part: colour legend arranged left-to-right for lines, + with blocks listed top-to-bottom inside each column. + +Connections +----------- +* Inner connections (within a block): solid line, full opacity. +* Bridge connections (between consecutive blocks of the same line): dashed + line, slightly thinner, same colour. +* Overlapping segments are deflected with a quadratic Bezier (same + algorithm as the Qt canvas). +* Mid-segment arrow heads indicate direction. + +Badges +------ +* Start badge (green, above dot) : first LED of a block. +* End badge (red, below dot) : last LED of a block. +* Single badge (blue, above dot) : block with exactly one LED. +* An anti-overlap pass shifts badges that would otherwise overlap. """ from __future__ import annotations +import math from pathlib import Path from typing import TYPE_CHECKING from PySide6.QtCore import QPointF, QRectF, Qt from PySide6.QtGui import ( - QColor, QBrush, + QColor, QFont, QFontMetricsF, QImage, QPainter, + QPainterPath, QPen, ) @@ -27,230 +51,437 @@ if TYPE_CHECKING: # --------------------------------------------------------------------------- -# Constants +# Layout constants # --------------------------------------------------------------------------- -MAPPING_WIDTH = 2400 # pixels for the main mapping area -SIDEBAR_WIDTH = 800 # pixels for the legend sidebar -IMAGE_HEIGHT = 2400 # total image height -PADDING_FRAC = 0.04 # 4% padding around the whole mapping -DARK_BG = "#0f172a" -DARK_SIDEBAR = "#1e293b" -DARK_DIVIDER = "#334155" -DARK_TITLE = "#f8fafc" -DARK_LINE_TEXT = "#f1f5f9" +MAPPING_MAX = 2200 # longest side of mapping area (pixels) +LEGEND_HEIGHT = 480 # legend strip at the bottom +PADDING_FRAC = 0.04 # % padding inside mapping area + +# Legend layout +LEGEND_H_PAD = 60 # horizontal padding of legend area +LEGEND_V_PAD = 28 # vertical padding of legend area +LEGEND_COL_GAP = 24 # gap between line columns +LEGEND_SWATCH_W = 22 +LEGEND_SWATCH_H = 22 +LEGEND_LINE_ROW = 38 # height of each line header row +LEGEND_BLOCK_ROW = 26 # height of each block row + +# Colours +DARK_BG = "#0f172a" +DARK_LEGEND_BG = "#111827" +DIVIDER = "#1e3a5f" +DARK_TITLE = "#f8fafc" +DARK_LINE_TEXT = "#f1f5f9" DARK_BLOCK_TEXT = "#94a3b8" -UNASSIGNED_DOT = "#3b4f6b" # colour for unassigned (no-line) dots -BADGE_START = "#10b981" # green - first LED of block -BADGE_END = "#ef4444" # red - last LED of block -BADGE_SINGLE = "#3b82f6" # blue - block with 1 LED +UNASSIGNED = "#3b4f6b" +BADGE_START = "#10b981" +BADGE_END = "#ef4444" +BADGE_SINGLE = "#3b82f6" -def render_mapping_image(doc: "MappingDocument", file_path: str | Path) -> bool: - """Render *doc* to *file_path* (PNG or JPEG determined by extension). +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- - Returns True on success, False on failure. - """ +def _overlap_control_point( + start: QPointF, + end: QPointF, + segments: list[tuple[QPointF, QPointF]], +) -> QPointF | None: + """Return a quadratic Bezier control point if *start-end* overlaps an + existing segment, otherwise None (straight line).""" + dx = end.x() - start.x() + dy = end.y() - start.y() + length = math.hypot(dx, dy) + if length < 0.001: + return None + + unit_ax = dx / length + unit_ay = dy / length + + previous_count = 0 + for prev_s, prev_e in segments: + # Parallel check + pdx = prev_e.x() - prev_s.x() + pdy = prev_e.y() - prev_s.y() + plen = math.hypot(pdx, pdy) + if plen < 0.001: + continue + unit_bx = pdx / plen + unit_by = pdy / plen + if abs(unit_ax * unit_bx + unit_ay * unit_by) < 0.985: + continue + # Perpendicular distance + d0 = abs((prev_s.x() - start.x()) * unit_ay - (prev_s.y() - start.y()) * unit_ax) + d1 = abs((prev_e.x() - start.x()) * unit_ay - (prev_e.y() - start.y()) * unit_ax) + if max(d0, d1) > 5.0: + continue + # Overlap projection + b0 = (prev_s.x() - start.x()) * unit_ax + (prev_s.y() - start.y()) * unit_ay + b1 = (prev_e.x() - start.x()) * unit_ax + (prev_e.y() - start.y()) * unit_ay + ov_s = max(0.0, min(b0, b1)) + ov_e = min(length, max(b0, b1)) + if (ov_e - ov_s) >= min(10.0, min(length, plen) * 0.35): + previous_count += 1 + + segments.append((QPointF(start), QPointF(end))) + if previous_count == 0: + return None + + bend = min(16.0, max(7.0, length * 0.24)) + side = 1.0 if previous_count % 2 == 1 else -1.0 + layer = 1.0 + (previous_count - 1) // 2 + offset = side * bend * layer + perp_x = -dy / length + perp_y = dx / length + mid_x = (start.x() + end.x()) / 2 + mid_y = (start.y() + end.y()) / 2 + return QPointF(mid_x + perp_x * offset, mid_y + perp_y * offset) + + +def _append_connection( + path: QPainterPath, + start: QPointF, + end: QPointF, + segments: list[tuple[QPointF, QPointF]], +) -> QPointF | None: + path.moveTo(start) + control = _overlap_control_point(start, end, segments) + if control is None: + path.lineTo(end) + else: + path.quadTo(control, end) + return control + + +def _append_arrow(path: QPainterPath, start: QPointF, end: QPointF, control: QPointF | None) -> None: + dx = end.x() - start.x() + dy = end.y() - start.y() + length = math.hypot(dx, dy) + if length < 8.0: + return + t = 0.55 + if control is None: + unit_x = dx / length + unit_y = dy / length + mid_x = start.x() + dx * t + mid_y = start.y() + dy * t + else: + inv_t = 1.0 - t + mid_x = inv_t*inv_t*start.x() + 2*inv_t*t*control.x() + t*t*end.x() + mid_y = inv_t*inv_t*start.y() + 2*inv_t*t*control.y() + t*t*end.y() + tan_x = 2*inv_t*(control.x()-start.x()) + 2*t*(end.x()-control.x()) + tan_y = 2*inv_t*(control.y()-start.y()) + 2*t*(end.y()-control.y()) + tl = math.hypot(tan_x, tan_y) + if tl < 0.001: + return + unit_x = tan_x / tl + unit_y = tan_y / tl + alen = min(7.0, length * 0.28) + awid = alen * 0.55 + base_x = mid_x - unit_x * alen + base_y = mid_y - unit_y * alen + perp_x = -unit_y + perp_y = unit_x + path.moveTo(QPointF(base_x + perp_x*awid, base_y + perp_y*awid)) + path.lineTo(QPointF(mid_x, mid_y)) + path.lineTo(QPointF(base_x - perp_x*awid, base_y - perp_y*awid)) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def render_mapping_image(doc: "MappingDocument", file_path: str | Path, theme: str = "dark") -> bool: + """Render *doc* to *file_path* (PNG or JPEG). Returns True on success.""" if not doc.points: return False - # 1. Compute world bounds + is_dark = theme == "dark" + bg_color = QColor(DARK_BG if is_dark else "#ffffff") + legend_bg_color = QColor(DARK_LEGEND_BG if is_dark else "#f8fafc") + divider_color = QColor(DIVIDER if is_dark else "#e2e8f0") + line_text_color = QColor(DARK_LINE_TEXT if is_dark else "#0f172a") + block_text_color = QColor(DARK_BLOCK_TEXT if is_dark else "#475569") + unassigned_color = QColor(UNASSIGNED if is_dark else "#cbd5e1") + + # ── 1. World bounds ───────────────────────────────────────────────────── xs = [p.x for p in doc.points] ys = [p.y for p in doc.points] min_x, max_x = min(xs), max(xs) min_y, max_y = min(ys), max(ys) - world_w = max(max_x - min_x, 1.0) world_h = max(max_y - min_y, 1.0) pad_x = world_w * PADDING_FRAC pad_y = world_h * PADDING_FRAC - draw_x0 = min_x - pad_x draw_y0 = min_y - pad_y draw_w = world_w + 2 * pad_x draw_h = world_h + 2 * pad_y - # 2. Scale to fit mapping area - scale = min(MAPPING_WIDTH / draw_w, IMAGE_HEIGHT / draw_h) - img_map_w = MAPPING_WIDTH - img_total_w = MAPPING_WIDTH + SIDEBAR_WIDTH + # Compute actual pixel map dimensions (preserve aspect ratio) + aspect = draw_w / draw_h + if aspect >= 1.0: + map_px_w = MAPPING_MAX + map_px_h = max(200, round(MAPPING_MAX / aspect)) + else: + map_px_h = MAPPING_MAX + map_px_w = max(200, round(MAPPING_MAX * aspect)) - def to_px(x: float, y: float) -> tuple[float, float]: + scale = map_px_w / draw_w # == map_px_h / draw_h + + def to_px(x: float, y: float) -> QPointF: px = (x - draw_x0) * scale - # flip y: in document coords Y grows up, but image Y grows down - py = ((max_y + pad_y) - y) * scale - return px, py + py = ((max_y + pad_y) - y) * scale # flip y + return QPointF(px, py) - dot_r = max(4.0, min(14.0, scale * 1.4)) + dot_r = max(5.0, min(14.0, scale * 1.6)) - # 3. Build line / block lookup maps + # ── 2. Lookups ─────────────────────────────────────────────────────────── point_by_uid: dict[int, object] = {p.uid: p for p in doc.points} point_line: dict[int, object] = {} - badge_info: dict[int, tuple[str, str, str]] = {} # uid -> (text, badge_type, line_color) + badge_info: dict[int, tuple[str, str]] = {} # uid → (text, badge_type) for line in doc.lines: - current_offset = 0 + cur = 0 for block in line.blocks: n = len(block.point_ids) if n == 0: continue - start_uid = block.point_ids[0] - end_uid = block.point_ids[-1] - start_idx = current_offset - end_idx = current_offset + n - 1 - current_offset += n - + s_uid = block.point_ids[0] + e_uid = block.point_ids[-1] for uid in block.point_ids: point_line[uid] = line - if n == 1: - badge_info[start_uid] = (f"#{start_idx}", "single", line.color) + badge_info[s_uid] = (f"#{cur}", "single") else: - badge_info[start_uid] = (f"#{start_idx}", "start", line.color) - badge_info[end_uid] = (f"#{end_idx}", "end", line.color) + badge_info[s_uid] = (f"#{cur}", "start") + badge_info[e_uid] = (f"#{cur + n - 1}", "end") + cur += n - # 4. Create image - image = QImage(img_total_w, IMAGE_HEIGHT, QImage.Format.Format_ARGB32) - image.fill(QColor(DARK_BG)) + # 3. Pre-compute legend height so image is created at the right size + _lf_pre = LEGEND_LINE_ROW + _bf_pre = LEGEND_BLOCK_ROW + max_col_h_pre = max( + (LEGEND_LINE_ROW + len(ln.blocks) * LEGEND_BLOCK_ROW for ln in doc.lines), + default=0, + ) + legend_height_pre = LEGEND_V_PAD * 2 + max(max_col_h_pre, 60) + + img_w = map_px_w + img_h = map_px_h + legend_height_pre + image = QImage(img_w, img_h, QImage.Format.Format_ARGB32) + image.fill(bg_color) painter = QPainter(image) painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.TextAntialiasing) - # 5. Draw block connection lines (drawn first so dots appear on top) + # ── 4. Connections ─────────────────────────────────────────────────────── + segments: list[tuple[QPointF, QPointF]] = [] + line_w_inner = max(1.2, dot_r * 0.30) + line_w_bridge = max(1.0, dot_r * 0.20) + line_w_arrow = max(1.0, dot_r * 0.22) + for line in doc.lines: - color = QColor(line.color) - color.setAlphaF(0.35) - pen = QPen(color, max(1.0, dot_r * 0.35)) + lc = QColor(line.color) + + # Inner paths + inner_path = QPainterPath() + arrow_path = QPainterPath() + for block in line.blocks: + pts = [point_by_uid.get(uid) for uid in block.point_ids] + pts = [p for p in pts if p is not None] + for a, b in zip(pts, pts[1:]): + s = to_px(a.x, a.y) # type: ignore[union-attr] + e = to_px(b.x, b.y) # type: ignore[union-attr] + ctrl = _append_connection(inner_path, s, e, segments) + _append_arrow(arrow_path, s, e, ctrl) + + c_inner = QColor(lc) + c_inner.setAlphaF(0.85) + pen = QPen(c_inner, line_w_inner) pen.setCapStyle(Qt.PenCapStyle.RoundCap) painter.setPen(pen) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawPath(inner_path) + + c_arrow = QColor(lc) + c_arrow.setAlphaF(0.95) + pen_a = QPen(c_arrow, line_w_arrow) + pen_a.setCapStyle(Qt.PenCapStyle.RoundCap) + pen_a.setJoinStyle(Qt.PenJoinStyle.RoundJoin) + painter.setPen(pen_a) + painter.drawPath(arrow_path) + + # Bridge paths (between blocks) + bridge_path = QPainterPath() + b_arrow_path = QPainterPath() + prev_end_pt = None for block in line.blocks: - prev = None - for uid in block.point_ids: - p = point_by_uid.get(uid) - if p is None: - continue - cur = to_px(p.x, p.y) - if prev is not None: - painter.drawLine(QPointF(*prev), QPointF(*cur)) - prev = cur + if not block.point_ids: + continue + first_pt = point_by_uid.get(block.point_ids[0]) + last_pt = point_by_uid.get(block.point_ids[-1]) + if first_pt is not None and prev_end_pt is not None: + s = to_px(prev_end_pt.x, prev_end_pt.y) # type: ignore[union-attr] + e = to_px(first_pt.x, first_pt.y) # type: ignore[union-attr] + ctrl = _append_connection(bridge_path, s, e, segments) + _append_arrow(b_arrow_path, s, e, ctrl) + if last_pt is not None: + prev_end_pt = last_pt - # 6. Draw LED dots + c_bridge = QColor(lc) + c_bridge.setAlphaF(0.5) + pen_b = QPen(c_bridge, line_w_bridge) + pen_b.setCapStyle(Qt.PenCapStyle.RoundCap) + pen_b.setStyle(Qt.PenStyle.DashLine) + painter.setPen(pen_b) + painter.drawPath(bridge_path) + + c_ba = QColor(lc) + c_ba.setAlphaF(0.75) + pen_ba = QPen(c_ba, line_w_arrow) + pen_ba.setCapStyle(Qt.PenCapStyle.RoundCap) + pen_ba.setStyle(Qt.PenStyle.SolidLine) + painter.setPen(pen_ba) + painter.drawPath(b_arrow_path) + + # ── 5. LED dots ────────────────────────────────────────────────────────── for point in doc.points: - px, py = to_px(point.x, point.y) + px = to_px(point.x, point.y) line = point_line.get(point.uid) - color = QColor(line.color if line else UNASSIGNED_DOT) - pen_color = color.darker(160) - - painter.setPen(QPen(pen_color, max(1.0, dot_r * 0.12))) + color = QColor(line.color if line else unassigned_color) + painter.setPen(QPen(color.darker(160), max(1.0, dot_r * 0.10))) painter.setBrush(QBrush(color)) - painter.drawEllipse(QPointF(px, py), dot_r, dot_r) + painter.drawEllipse(px, dot_r, dot_r) - # 7. Draw start/end badges - badge_font = QFont("Segoe UI", max(7, int(dot_r * 0.9))) + # ── 6. Badges (anti-overlap) ───────────────────────────────────────────── + badge_font = QFont("Segoe UI", max(7, int(dot_r * 0.82))) badge_font.setBold(True) painter.setFont(badge_font) fm = QFontMetricsF(badge_font) - for uid, (text, badge_type, line_color) in badge_info.items(): + + # First pass: compute proposed rect for each badge + # badge list: (uid, text, badge_type) sorted so start badges come first + badge_rects: list[tuple[str, str, QRectF, QColor]] = [] # (text, badge_type, rect, bg) + + def proposed_rect(uid: int, text: str, btype: str) -> tuple[QRectF, QColor]: p = point_by_uid.get(uid) if p is None: - continue - px, py = to_px(p.x, p.y) - - if badge_type == "start": - bg = QColor(BADGE_START) - offset_y = -(dot_r + fm.height() + 6) - elif badge_type == "end": - bg = QColor(BADGE_END) - offset_y = dot_r + 4 - else: # single - bg = QColor(BADGE_SINGLE) - offset_y = -(dot_r + fm.height() + 6) - + return QRectF(), QColor() + px = to_px(p.x, p.y) # type: ignore[union-attr] tw = fm.horizontalAdvance(text) th = fm.height() - bx = px - tw / 2 - 5 - by = py + offset_y + bx = px.x() - tw / 2 - 5 bw = tw + 10 bh = th + 4 + if btype in ("start", "single"): + by = px.y() - dot_r - bh - 4 + bg = QColor(BADGE_START if btype == "start" else BADGE_SINGLE) + else: + by = px.y() + dot_r + 4 + bg = QColor(BADGE_END) + return QRectF(bx, by, bw, bh), bg + # Collect all badges + raw_badges: list[tuple[int, str, str]] = [] + for uid, (text, btype) in badge_info.items(): + raw_badges.append((uid, text, btype)) + + # Build placed rects with nudge to avoid overlap + placed: list[QRectF] = [] + NUDGE_STEP = fm.height() + 6 + # Maximum y for badge bottom must stay within mapping area + badge_max_y = map_px_h - 4 + + for uid, text, btype in raw_badges: + rect, bg = proposed_rect(uid, text, btype) + if rect.isEmpty(): + continue + # Try shifting vertically until no overlap + direction = -1 if btype in ("start", "single") else 1 + for _ in range(12): + overlaps = any(rect.intersects(pr) for pr in placed) + if not overlaps: + break + rect = rect.translated(0, direction * NUDGE_STEP) + # Clamp: don't let badge go outside mapping area vertically + if rect.bottom() > badge_max_y: + rect = rect.translated(0, badge_max_y - rect.bottom()) + if rect.top() < 0: + rect = rect.translated(0, -rect.top()) + placed.append(rect) + badge_rects.append((text, btype, rect, bg)) + + # Second pass: draw + for text, btype, rect, bg in badge_rects: painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(QBrush(bg)) - painter.drawRoundedRect(QRectF(bx, by, bw, bh), 3, 3) - + painter.drawRoundedRect(rect, 3, 3) painter.setPen(QColor(Qt.GlobalColor.white)) - painter.drawText(QRectF(bx, by, bw, bh), Qt.AlignmentFlag.AlignCenter, text) + painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, text) - # 8. Fill sidebar - painter.setPen(Qt.PenStyle.NoPen) - painter.setBrush(QBrush(QColor(DARK_SIDEBAR))) - painter.drawRect(img_map_w, 0, SIDEBAR_WIDTH, IMAGE_HEIGHT) - - # Divider line - painter.setPen(QPen(QColor(DARK_DIVIDER), 2)) - painter.drawLine(img_map_w, 0, img_map_w, IMAGE_HEIGHT) - - # 9. Draw legend - SB_PAD = 40 - sb_x = img_map_w + SB_PAD - - title_font = QFont("Segoe UI", 22) + # ── 7. Legend strip ─────────────────────────────────────────────────────── + title_font = QFont("Segoe UI", 16) title_font.setBold(True) - painter.setFont(title_font) - painter.setPen(QColor(DARK_TITLE)) - painter.drawText( - QRectF(sb_x, 36, SIDEBAR_WIDTH - SB_PAD * 2, 48), - Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, - "ЛЕГЕНДА МАППИНГА", - ) + block_font = QFont("Segoe UI", 13) - y = 106.0 - line_font = QFont("Segoe UI", 15) - line_font.setBold(True) - block_font = QFont("Segoe UI", 12) + legend_y0 = map_px_h + legend_h = legend_height_pre - for line in doc.lines: - if y > IMAGE_HEIGHT - 60: - break + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QBrush(legend_bg_color)) + painter.drawRect(0, legend_y0, img_w, legend_h) - # Colour swatch - swatch_size = 24.0 - painter.setPen(Qt.PenStyle.NoPen) - painter.setBrush(QBrush(QColor(line.color))) - painter.drawRoundedRect(QRectF(sb_x, y + 2, swatch_size, swatch_size), 4, 4) + # divider + painter.setPen(QPen(divider_color, 2)) + painter.drawLine(0, legend_y0, img_w, legend_y0) - painter.setFont(line_font) - painter.setPen(QColor(DARK_LINE_TEXT)) - painter.drawText( - QRectF(sb_x + swatch_size + 12, y, SIDEBAR_WIDTH - SB_PAD * 2 - swatch_size - 12, 30), - Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, - line.name, - ) - y += 38 + if doc.lines: + n_lines = len(doc.lines) + avail_w = img_w - 2 * LEGEND_H_PAD + col_w = (avail_w - LEGEND_COL_GAP * (n_lines - 1)) / n_lines - painter.setFont(block_font) - current_offset = 0 - for block in line.blocks: - if y > IMAGE_HEIGHT - 40: - break - n = len(block.point_ids) - range_str = "пусто" if n == 0 else f"{current_offset}-{current_offset + n - 1}" - current_offset += n + for i, line in enumerate(doc.lines): + cx = float(LEGEND_H_PAD) + i * (col_w + LEGEND_COL_GAP) + cy = float(legend_y0 + LEGEND_V_PAD) - # Narrow colour strip left of block row + # Colour swatch + line name painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(QBrush(QColor(line.color))) - painter.drawRect(QRectF(sb_x + 8, y + 3, 4, 18)) - - painter.setPen(QColor(DARK_BLOCK_TEXT)) - painter.drawText( - QRectF(sb_x + 22, y, SIDEBAR_WIDTH - SB_PAD * 2 - 22, 24), - Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, - f"{block.name}: {range_str}", + painter.drawRoundedRect( + QRectF(cx, cy + 4, LEGEND_SWATCH_W, LEGEND_SWATCH_H), 3, 3 ) - y += 27 + painter.setFont(title_font) + painter.setPen(line_text_color) + painter.drawText( + QRectF(cx + LEGEND_SWATCH_W + 10, cy, col_w - LEGEND_SWATCH_W - 10, LEGEND_LINE_ROW), + Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, + line.name, + ) + cy += LEGEND_LINE_ROW - y += 14 + # Blocks (top-to-bottom within column) + cur = 0 + painter.setFont(block_font) + for block in line.blocks: + n = len(block.point_ids) + rng = f"{cur}-{cur + n - 1}" if n else "пусто" + cur += n + + # Thin colour bar on the left + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QBrush(QColor(line.color))) + painter.drawRect(QRectF(cx + 4, cy + 4, 3, LEGEND_BLOCK_ROW - 8)) + + painter.setPen(block_text_color) + painter.drawText( + QRectF(cx + 16, cy, col_w - 16, LEGEND_BLOCK_ROW), + Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, + f"{block.name}: {rng}", + ) + cy += LEGEND_BLOCK_ROW painter.end() - return image.save(str(file_path)) diff --git a/qa_smoke.py b/qa_smoke.py index e465a45..1f48948 100644 --- a/qa_smoke.py +++ b/qa_smoke.py @@ -1144,11 +1144,19 @@ def test_export_mapping_to_image() -> None: doc.add_manual_point(100.0, 0.0) doc.add_manual_point(100.0, 100.0) with tempfile.TemporaryDirectory() as temp_dir: - image_path = Path(temp_dir) / "output.png" - success = render_mapping_image(doc, str(image_path)) + # Test Dark Theme + image_path = Path(temp_dir) / "output_dark.png" + success = render_mapping_image(doc, str(image_path), theme="dark") assert success assert image_path.exists() assert image_path.stat().st_size > 0 + + # Test Light Theme + image_path_light = Path(temp_dir) / "output_light.png" + success_light = render_mapping_image(doc, str(image_path_light), theme="light") + assert success_light + assert image_path_light.exists() + assert image_path_light.stat().st_size > 0 def test_save_preserves_unknown_source_fields() -> None: