#!/usr/bin/env python3
"""Minimal Kodi WindowXML renderer (subset) for offline dialog design iteration.

Renders a dialog XML (1080p skin coordinates) to a PNG using PIL so the layout
can be compared with a design reference without running Kodi.

Supported controls: group, image, label, button, textbox, progress, scrollbar,
list/fixedlist (basic), grouplist (basic).
Supported features: $INFO[Window.Property(X)], $ADDON[plugin.video.milionar N],
visible conditions (String.IsEqual / String.IsEmpty / Control.HasFocus combos),
colordiffuse, diffuse masks, 9-slice border, multiple textures with visibility,
BBCode [COLOR]/[B]/[UPPERCASE]/[LOWERCASE], fonts from the Estuary skin.
"""
import os
import re
import sys
import xml.etree.ElementTree as ET

import numpy as np
from PIL import Image, ImageDraw, ImageFilter, ImageFont

HERE = os.path.dirname(os.path.abspath(__file__))
ADDON_DIR = os.path.abspath(os.path.join(HERE, '..', '..'))

KODI_APP = os.environ.get('KODI_APP', '/Applications/Kodi.app/Contents/Resources/Kodi')
FONT_XML = os.path.join(KODI_APP, 'addons', 'skin.estuary', 'xml', 'Font.xml')
FONTS_DIR = os.path.join(KODI_APP, 'addons', 'skin.estuary', 'fonts')
HARNESS_FONTS = os.path.join(HERE, 'fonts')
STRINGS_PO = os.path.join(ADDON_DIR, 'resources', 'language', 'resource.language.cs_cz', 'strings.po')

SCREEN_W, SCREEN_H = 1920, 1080

_texture_cache = {}
_font_cache = {}

# The harness Noto Sans copy lacks these symbols; the Estuary font (used by the
# real addon) renders them correctly, so fall back to it for these characters.
_STAR_CHARS = set('★☆✦✧⭐✩✪✫✬✭✮✯')
_GLYPH_FALLBACK_PATH = os.path.join(FONTS_DIR, 'NotoSans-Regular.ttf')


def parse_color(value, default=(255, 255, 255, 255)):
    """Parse Kodi AARRGGBB color into (r, g, b, a)."""
    if not value:
        return default
    value = value.strip().lstrip('#')
    if len(value) == 8:
        return (int(value[2:4], 16), int(value[4:6], 16), int(value[6:8], 16), int(value[0:2], 16))
    if len(value) == 6:
        return (int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16), 255)
    return default


def rgb_of(color):
    return color[1:4] if len(color) == 4 else color


def parse_po(path):
    strings = {}
    if not os.path.exists(path):
        return strings
    with open(path, encoding='utf-8') as fh:
        content = fh.read()
    for block in re.finditer(r'msgctxt "#(\d+)"\nmsgid "((?:[^"\\]|\\.)*)"\nmsgstr "((?:[^"\\]|\\.)*)"', content):
        strings[int(block.group(1))] = block.group(3)
    return strings


class Fonts:
    def __init__(self):
        self.defs = {}
        self.load()

    def load(self):
        if not os.path.exists(FONT_XML):
            return
        root = ET.parse(FONT_XML).getroot()
        fontset = root.find('fontset')
        for font in fontset.findall('font'):
            name = font.findtext('name')
            filename = font.findtext('filename')
            size = int(font.findtext('size') or 30)
            style = font.findtext('style') or ''
            path = os.path.join(FONTS_DIR, filename)
            self.defs[name] = (path, size, style)

    def get(self, name):
        key = name
        if key in _font_cache:
            return _font_cache[key]
        path, size, style = self.defs.get(name, (os.path.join(FONTS_DIR, 'NotoSans-Regular.ttf'), 30, ''))
        bold = 'bold' in style
        lighten = 'lighten' in style
        # The Estuary NotoSans file renders with broken metrics in PIL, use the
        # harness copy of Noto Sans (same family) with a real Bold face instead.
        harness_regular = os.path.join(HARNESS_FONTS, 'NotoSans-Regular.ttf')
        harness_bold = os.path.join(HARNESS_FONTS, 'NotoSans-Bold.ttf')
        if os.path.basename(path).startswith('NotoSans') and os.path.exists(harness_regular):
            if bold and os.path.exists(harness_bold):
                path = harness_bold
                bold = False  # real bold face, no synthetic stroke needed
            else:
                path = harness_regular
        font = ImageFont.truetype(path, size)
        _font_cache[key] = (font, size, bold, lighten)
        return _font_cache[key]


def load_texture(path):
    if path in _texture_cache:
        return _texture_cache[path]
    img = None
    if os.path.exists(path):
        try:
            img = Image.open(path).convert('RGBA')
        except Exception:
            img = None
    _texture_cache[path] = img
    return img


def nine_slice(img, w, h, border):
    b = int(border)
    iw, ih = img.size
    if b <= 0 or b * 2 > iw or b * 2 > ih:
        return img.resize((max(1, w), max(1, h)), Image.LANCZOS)
    out = Image.new('RGBA', (max(1, w), max(1, h)), (0, 0, 0, 0))
    cw, ch = max(1, w - 2 * b), max(1, h - 2 * b)
    out.paste(img.crop((0, 0, b, b)), (0, 0))
    out.paste(img.crop((iw - b, 0, iw, b)), (w - b, 0))
    out.paste(img.crop((0, ih - b, b, ih)), (0, h - b))
    out.paste(img.crop((iw - b, ih - b, iw, ih)), (w - b, h - b))
    out.paste(img.crop((b, 0, iw - b, b)).resize((cw, b), Image.LANCZOS), (b, 0))
    out.paste(img.crop((b, ih - b, iw - b, ih)).resize((cw, b), Image.LANCZOS), (b, h - b))
    out.paste(img.crop((0, b, b, ih - b)).resize((b, ch), Image.LANCZOS), (0, b))
    out.paste(img.crop((iw - b, b, iw, ih - b)).resize((b, ch), Image.LANCZOS), (w - b, b))
    out.paste(img.crop((b, b, iw - b, ih - b)).resize((cw, ch), Image.LANCZOS), (b, b))
    return out


def tint(img, color):
    r, g, b, a = color
    if (r, g, b) == (255, 255, 255) and a == 255:
        return img
    arr = np.asarray(img.convert('RGBA')).astype(np.uint16)
    if (r, g, b) != (255, 255, 255):
        arr[..., 0] = arr[..., 0] * r // 255
        arr[..., 1] = arr[..., 1] * g // 255
        arr[..., 2] = arr[..., 2] * b // 255
    if a != 255:
        arr[..., 3] = arr[..., 3] * a // 255
    return Image.fromarray(arr.astype(np.uint8), 'RGBA')


def scale_cover(img, w, h):
    iw, ih = img.size
    if iw == 0 or ih == 0:
        return img
    scale = max(w / iw, h / ih)
    nw, nh = max(1, int(round(iw * scale))), max(1, int(round(ih * scale)))
    resized = img.resize((nw, nh), Image.LANCZOS)
    left = (nw - w) // 2
    top = (nh - h) // 2
    return resized.crop((left, top, left + w, top + h))


def scale_keep(img, w, h):
    iw, ih = img.size
    if iw == 0 or ih == 0:
        return img
    scale = min(w / iw, h / ih)
    nw, nh = max(1, int(round(iw * scale))), max(1, int(round(ih * scale)))
    return img.resize((nw, nh), Image.LANCZOS)


class Control:
    def __init__(self, elem):
        self.elem = elem
        self.type = elem.get('type')
        raw_id = (elem.get('id') or '').strip()
        self.id = int(raw_id) if raw_id.isdigit() else None  # Kodi ignores non-numeric ids
        self.children = [Control(c) for c in elem if c.tag == 'control']
        self.layout = None
        self.focused_layout = None

    def val(self, tag, default=None):
        child = self.elem.find(tag)
        if child is None or child.text is None:
            return default
        return child.text.strip()


class Renderer:
    def __init__(self, props, focus_ids=None, strings=None):
        self.props = props or {}
        self.focus_ids = set(focus_ids or [])
        self.strings = strings or {}
        self.fonts = Fonts()
        self.items = props.get('__items__', {})
        self.glyph_fallback = {}
        if os.path.exists(_GLYPH_FALLBACK_PATH):
            self.glyph_fallback_path = _GLYPH_FALLBACK_PATH

    def _fallback_font(self, size):
        if size in self.glyph_fallback:
            return self.glyph_fallback[size]
        path = getattr(self, 'glyph_fallback_path', None)
        font = ImageFont.truetype(path, size) if path else None
        self.glyph_fallback[size] = font
        return font

    def _font_runs(self, text, font, size):
        fallback = self._fallback_font(size)
        if fallback is None:
            return [(text, font)]
        runs = []
        current = ''
        current_font = None
        for ch in text:
            use = fallback if ch in _STAR_CHARS else font
            if current_font is None:
                current_font = use
            if use is current_font:
                current += ch
            else:
                runs.append((current, current_font))
                current, current_font = ch, use
        if current:
            runs.append((current, current_font))
        return runs

    # ---------- info / conditions ----------
    def resolve(self, text):
        if text is None:
            return ''
        def repl_info(match):
            inner = match.group(1).strip()
            if inner.startswith('Window.Property(') and inner.endswith(')'):
                return str(self.props.get(inner[len('Window.Property('):-1], ''))
            if inner.startswith('ListItem.Property(') and inner.endswith(')'):
                return str(self.props.get('__item__:' + inner[len('ListItem.Property('):-1], ''))
            if inner.startswith('ListItem.'):
                return str(self.props.get('__item__:' + inner[len('ListItem.'):], ''))
            return ''
        def repl_addon(match):
            return str(self.strings.get(int(match.group(1)), ''))
        text = re.sub(r'\$INFO\[([^\]]+)\]', repl_info, text)
        text = re.sub(r'\$ADDON\[plugin\.video\.milionar\s+(\d+)\]', repl_addon, text)
        return text

    def eval_atom(self, atom):
        atom = atom.strip()
        negate = atom.startswith('!')
        if negate:
            atom = atom[1:].strip()
        result = False
        m = re.match(r'^String\.IsEqual\((.*),(.*)\)$', atom)
        if m:
            result = self._strip_resolve(m.group(1)) == self._strip_resolve(m.group(2))
        else:
            m = re.match(r'^String\.IsEmpty\((.*)\)$', atom)
            if m:
                result = self._strip_resolve(m.group(1)) == ''
            else:
                m = re.match(r'^Control\.HasFocus\((\d+)\)$', atom)
                if m:
                    result = int(m.group(1)) in self.focus_ids
                else:
                    m = re.match(r'^Control\.IsVisible\((\d+)\)$', atom)
                    result = bool(m)
        return (not result) if negate else result

    def _strip_resolve(self, token):
        token = token.strip()
        match = re.match(r'^Window\.Property\((.*)\)$', token)
        if match:
            return str(self.props.get(match.group(1), ''))
        match = re.match(r'^ListItem\.Property\((.*)\)$', token)
        if match:
            return str(self.props.get('__item__:' + match.group(1), ''))
        match = re.match(r'^ListItem\.Label$', token)
        if match:
            return str(self.props.get('__item__:Label', ''))
        return self.resolve(token)

    def eval_condition(self, expr):
        if expr is None:
            return True
        expr = expr.strip()
        if not expr:
            return True
        # OR has lower priority than AND
        or_parts = self._split_top(expr, '|')
        if len(or_parts) > 1:
            return any(self.eval_condition(p) for p in or_parts if p.strip())
        and_parts = self._split_top(expr, '+')
        if len(and_parts) > 1:
            return all(self.eval_condition(p) for p in and_parts if p.strip())
        return self.eval_atom(expr)

    @staticmethod
    def _split_top(expr, sep):
        parts, depth, current = [], 0, ''
        for ch in expr:
            if ch == '(':
                depth += 1
            elif ch == ')':
                depth -= 1
            if ch == sep and depth == 0:
                parts.append(current)
                current = ''
            else:
                current += ch
        parts.append(current)
        return parts

    def visible(self, control):
        if control.id is not None and control.id in self.hidden_ids:
            return False
        return self.eval_condition(control.val('visible'))

    # ---------- textures ----------
    def resolve_texture_path(self, path):
        if not path:
            return None
        if path.startswith('special://home/addons/plugin.video.milionar/'):
            rest = path[len('special://home/addons/plugin.video.milionar/'):]
            return os.path.join(ADDON_DIR, rest)
        if path.startswith('special://'):
            return None
        if os.path.isabs(path):
            return path
        return path

    def pick_texture_elem(self, control):
        textures = [c for c in control.elem if c.tag == 'texture']
        for tex in textures:
            v = tex.get('visible') or tex.findtext('visible')
            if v is not None and not self.eval_condition(v):
                continue
            return tex
        return None

    def draw_image(self, img, control, x, y, w, h, texture_elem=None, focus=False):
        tex = texture_elem
        if tex is None:
            if focus:
                tex = control.elem.find('texturefocus')
            else:
                tex = control.elem.find('texturenofocus') if control.elem.find('texturenofocus') is not None else control.elem.find('textureofocus')
            if tex is None:
                tex = self.pick_texture_elem(control)
        if tex is None or tex.text is None:
            return
        path = self.resolve_texture_path(self.resolve(tex.text.strip()))
        if not path:
            return
        source = load_texture(path)
        if source is None:
            return
        border = tex.get('border') or control.val('border')
        image = nine_slice(source, w, h, border) if border else source
        aspect = control.val('aspectratio') or 'stretch'
        if aspect == 'scale':
            image = scale_cover(source, w, h)
        elif aspect == 'keep':
            image = scale_keep(source, w, h)
        else:
            image = image.resize((max(1, w), max(1, h)), Image.LANCZOS)
        diffuse = tex.get('diffuse') or control.val('diffuse')
        if diffuse:
            mask_path = self.resolve_texture_path(self.resolve(diffuse))
            mask = load_texture(mask_path) if mask_path else None
            if mask is not None:
                alpha = mask.convert('L').resize((max(1, w), max(1, h)), Image.LANCZOS)
                r, g, b, a = image.split()
                a = Image.fromarray((np.asarray(a).astype(np.uint16) * np.asarray(alpha).astype(np.uint16) // 255).astype(np.uint8))
                if aspect == 'scale':
                    a = alpha
                image = Image.merge('RGBA', (r, g, b, a))
        tint_color = tex.get('colordiffuse') or control.val('colordiffuse')
        image = tint(image, parse_color(tint_color))
        self.base.alpha_composite(image, (int(x), int(y)))

    # ---------- text ----------
    def parse_bbcode(self, text):
        segments = []
        color = None
        bold = False
        uppercase = False
        lowercase = False
        pos = 0
        for match in re.finditer(r'\[(/?)(COLOR|B|UPPERCASE|LOWERCASE)(?: ([0-9A-Fa-f]{6,8}))?\]', text):
            if match.start() > pos:
                segments.append((text[pos:match.start()], color, bold, uppercase, lowercase))
            closing = match.group(1) == '/'
            tag = match.group(2)
            if tag == 'COLOR':
                color = None if closing else parse_color(match.group(3))
            elif tag == 'B':
                bold = not closing
            elif tag == 'UPPERCASE':
                uppercase = not closing
            elif tag == 'LOWERCASE':
                lowercase = not closing
            pos = match.end()
        if pos < len(text):
            segments.append((text[pos:], color, bold, uppercase, lowercase))
        return segments

    def draw_text(self, img, text, x, y, w, h, font_name, color, align='left', aligny='center'):
        font, size, bold, lighten = self.fonts.get(font_name)
        segments = self.parse_bbcode(text)
        prepared = []
        full_width = 0
        for seg_text, seg_color, seg_bold, upper, lower in segments:
            t = seg_text.upper() if upper else (seg_text.lower() if lower else seg_text)
            runs = self._font_runs(t, font, size)
            prepared.append((runs, seg_color or color, seg_bold))
            for run_text, run_font in runs:
                full_width += run_font.getlength(run_text) + (1 if seg_bold else 0)
        basey = y
        if aligny == 'center':
            basey = y + (h - size) / 2
        elif aligny == 'bottom':
            basey = y + h - size
        curx = x
        if align == 'center':
            curx = x + (w - full_width) / 2
        elif align == 'right':
            curx = x + w - full_width
        for runs, seg_color, seg_bold in prepared:
            for run_text, run_font in runs:
                if not run_text:
                    continue
                stroke = 0
                if bold or seg_bold:
                    stroke = 2 if size >= 40 else 1
                if lighten and not stroke:
                    glow = Image.new('RGBA', img.size, (seg_color[0], seg_color[1], seg_color[2], 0))
                    gdraw = ImageDraw.Draw(glow)
                    gdraw.text((curx, basey), run_text, font=run_font,
                               fill=(seg_color[0], seg_color[1], seg_color[2], 55))
                    glow = glow.filter(ImageFilter.GaussianBlur(1.5))
                    img.alpha_composite(glow)
                draw = ImageDraw.Draw(img)
                draw.text((curx, basey), run_text, font=run_font, fill=seg_color,
                          stroke_width=stroke, stroke_fill=seg_color)
                curx += run_font.getlength(run_text) + (stroke if stroke else 0)

    # ---------- rendering ----------
    def render(self, xml_path, hidden_ids=None):
        self.hidden_ids = set(hidden_ids or [])
        self.rects = {}  # control id -> (x, y, w, h) in 1920x1080 skin pixels
        tree = ET.parse(xml_path)
        root = tree.getroot()
        img = Image.new('RGBA', (SCREEN_W, SCREEN_H), (0, 0, 0, 0))
        self.base = img
        origin_x = origin_y = 0
        coords = root.find('coordinates')
        if coords is not None:
            origin_x = int(float((coords.findtext('left') or '0').strip() or 0))
            origin_y = int(float((coords.findtext('top') or '0').strip() or 0))
        controls = root.find('controls')
        for control_elem in controls.findall('control'):
            self.render_control(Control(control_elem), origin_x, origin_y, img)
        return img

    def render_control(self, control, ox, oy, img, parent_w=None, parent_h=None):
        if control.type == 'group':
            self.render_group(control, ox, oy, img)
        elif control.type == 'image':
            self.render_image_control(control, ox, oy, img)
        elif control.type == 'label':
            self.render_label(control, ox, oy, img)
        elif control.type == 'button':
            self.render_button(control, ox, oy, img)
        elif control.type == 'textbox':
            self.render_textbox(control, ox, oy, img)
        elif control.type in ('list', 'fixedlist', 'grouplist', 'panel'):
            self.render_list(control, ox, oy, img)
        elif control.type == 'progress':
            self.render_progress(control, ox, oy, img)
        elif control.type == 'scrollbar':
            pass

    def _geometry(self, control, ox, oy):
        left = int(float(control.val('left', 0)))
        top = int(float(control.val('top', 0)))
        width = control.val('width', '0')
        height = int(float(control.val('height', 0)))
        width = 0 if width == 'auto' else int(float(width))
        for animation in control.elem.findall('animation'):
            if animation.get('effect') != 'conditional' or (animation.text or '').strip() != 'Height':
                continue
            if self.eval_condition(animation.get('condition')):
                end = animation.get('end', '')
                if end.replace('.', '', 1).isdigit():
                    height = int(float(end))
        if control.id is not None:
            self.rects[control.id] = (ox + left, oy + top, width, height)
        return ox + left, oy + top, width, height

    def render_group(self, control, ox, oy, img):
        if not self.visible(control):
            return
        x, y, w, h = self._geometry(control, ox, oy)
        for child in control.children:
            self.render_control(child, x, y, img)

    def render_image_control(self, control, ox, oy, img):
        if not self.visible(control):
            return
        x, y, w, h = self._geometry(control, ox, oy)
        if w <= 0 or h <= 0:
            return
        self.draw_image(img, control, x, y, w, h)

    def wrap_text(self, text, font_name, width):
        """Word-wrap plain text to the given width (Kodi label behaviour)."""
        font, size, _, _ = self.fonts.get(font_name)
        if width <= 0 or font.getlength(text) <= width:
            return text.split('[CR]')
        lines = []
        for paragraph in text.split('[CR]'):
            if '[' in paragraph:
                lines.append(paragraph)
                continue
            current = ''
            for word in paragraph.split(' '):
                candidate = (current + ' ' + word).strip()
                if font.getlength(candidate) <= width or not current:
                    current = candidate
                else:
                    lines.append(current)
                    current = word
            lines.append(current)
        return lines

    def render_label(self, control, ox, oy, img):
        if not self.visible(control):
            return
        x, y, w, h = self._geometry(control, ox, oy)
        font_name = control.val('font', 'font13')
        color = parse_color(control.val('textcolor', 'FFFFFFFF'))
        text = self.resolve(control.val('label', ''))
        align = control.val('align', 'left')
        aligny = control.val('aligny', 'center')
        if w <= 0:
            font, size, _, _ = self.fonts.get(font_name)
            w = int(font.getlength(text)) + 4
        lines = self.wrap_text(text, font_name, w)
        if len(lines) > 1:
            font, size, _, _ = self.fonts.get(font_name)
            line_height = size * 1.36
            max_lines = max(1, int(h // line_height))
            lines = lines[:max_lines]
            block_height = line_height * len(lines)
            if aligny == 'center':
                y0 = y + (h - block_height) / 2
            elif aligny == 'bottom':
                y0 = y + h - block_height
            else:
                y0 = y
            for index, line in enumerate(lines):
                self.draw_text(img, line, x, y0 + index * line_height, w, line_height,
                               font_name, color, align, 'top')
            return
        self.draw_text(img, text, x, y, w, h, font_name, color, align, aligny)

    def render_button(self, control, ox, oy, img):
        if not self.visible(control):
            return
        x, y, w, h = self._geometry(control, ox, oy)
        focused = control.id in self.focus_ids
        self.draw_image(img, control, x, y, w, h, focus=focused)
        label = self.resolve(control.val('label', ''))
        if label:
            font_name = control.val('font', 'font13')
            color = parse_color(control.val('focusedcolor') if focused and control.val('focusedcolor') else control.val('textcolor', 'FFFFFFFF'))
            align = control.val('align', 'center')
            aligny = control.val('aligny', 'center')
            self.draw_text(img, label, x, y, w, h, font_name, color, align, aligny)

    def render_textbox(self, control, ox, oy, img):
        if not self.visible(control):
            return
        x, y, w, h = self._geometry(control, ox, oy)
        font_name = control.val('font', 'font13')
        color = parse_color(control.val('textcolor', 'FFFFFFFF'))
        text = self.resolve(control.val('label', ''))
        text = re.sub(r'\[/?COLOR(?: [0-9A-Fa-f]{6,8})?\]', '', text)
        text = re.sub(r'\[/?(?:B|I|UPPERCASE|LOWERCASE)\]', '', text)
        font, size, _, _ = self.fonts.get(font_name)
        draw = ImageDraw.Draw(img)
        line_height = int(size * 1.25)
        lines = []
        for paragraph in text.split('[CR]'):
            words = paragraph.replace('\n', ' ').split(' ')
            current = ''
            for word in words:
                candidate = (current + ' ' + word).strip()
                if font.getlength(candidate) <= w or not current:
                    current = candidate
                else:
                    lines.append(current)
                    current = word
            lines.append(current)
        yy = y
        for line in lines:
            if yy + line_height > y + h:
                break
            draw.text((x, yy), line, font=font, fill=color)
            yy += line_height

    def render_progress(self, control, ox, oy, img):
        if not self.visible(control):
            return
        x, y, w, h = self._geometry(control, ox, oy)
        info = control.val('info', '')
        percent = 0
        m = re.search(r'Window\.Property\((.*)\)', info)
        if m:
            try:
                percent = float(self.props.get(m.group(1), 0) or 0)
            except (TypeError, ValueError):
                percent = 0
        bg_tex = control.elem.find('texturebg')
        mid_tex = control.elem.find('midtexture')
        if bg_tex is not None and bg_tex.text:
            path = self.resolve_texture_path(self.resolve(bg_tex.text.strip()))
            source = load_texture(path)
            if source:
                border = bg_tex.get('border')
                image = nine_slice(source, w, h, border) if border else source.resize((w, h), Image.LANCZOS)
                self.base.alpha_composite(tint(image, parse_color(bg_tex.get('colordiffuse'))), (x, y))
        if mid_tex is not None and mid_tex.text:  # noqa: SIM102
            if mid_tex.text.strip() != '-' and percent > 0:
                path = self.resolve_texture_path(self.resolve(mid_tex.text.strip()))
                source = load_texture(path)
                if source:
                    fw = max(1, int(w * percent / 100))
                    border = mid_tex.get('border')
                    image = nine_slice(source, w, h, border) if border else source.resize((fw, h), Image.LANCZOS)
                    if border:
                        image = image.crop((0, 0, fw, h))
                    self.base.alpha_composite(tint(image, parse_color(mid_tex.get('colordiffuse'))), (x, y))

    def render_list(self, control, ox, oy, img):
        if not self.visible(control):
            return
        x, y, w, h = self._geometry(control, ox, oy)
        orientation = control.val('orientation', 'vertical')
        items = self.items.get(control.id, [])
        if not items:
            return
        item_layout = control.elem.find('itemlayout')
        focused_layout = control.elem.find('focusedlayout')
        for index, item_props in enumerate(items):
            focused = control.id in self.focus_ids and index == 0
            layout = focused_layout if (focused and focused_layout is not None) else item_layout
            if layout is None:
                continue
            item_w = int(float(layout.get('width', w)))
            item_h = int(float(layout.get('height', h)))
            if orientation == 'horizontal':
                ix, iy = x + index * item_w, y
            else:
                ix, iy = x, y + index * item_h
            updates = {'__item__:' + k: v for k, v in item_props.items()}
            self.props.update(updates)
            for sub in Control(layout).children:
                self.render_control(sub, ix, iy, img)
