# (c) Copyright 2023 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
# lcd_display.py - LCD rendering for Q1's 320x240 pixel *colour* display!
#
import machine, uzlib, utime, array
from graphics_q1 import Graphics
from st7788 import ST7788
from utils import xfp2str, word_wrap, chunk_address
from ucollections import namedtuple
from charcodes import OUT_CTRL_TITLE, OUT_CTRL_ADDRESS

# the one font: fixed-width (except for a few double-width chars)
from font_iosevka import CELL_W, CELL_H, TEXT_PALETTES, COL_TEXT, COL_DARK_TEXT, COL_SCROLL_DARK
from font_iosevka import FontIosevka

#WIDTH = const(320)
#HEIGHT = const(240)
LEFT_MARGIN = const(7)      # equal on right side, but used for scroll bar
TOP_MARGIN = const(15)
PROGRESS_BAR_H = const(5)
ACTIVE_H = const(240 - TOP_MARGIN - PROGRESS_BAR_H)
CHARS_W = const(34)
CHARS_H = const(10)

# colouuurs: RGB565
COL_WHITE = 0xffff
COL_BLACK = 0x0000
COL_PROGRESS = COL_TEXT

# Attribute stored per-char; really just an index into TEXT_PALETTES
FLAG_INVERT = 0x10000
FLAG_DARK   = 0x20000
ATTR_MASK   = 0x30000

# use this to describe cursor you need.
# - outline leaves most of the cell unaffected (just 1px inside border)
# - solid/outline available in double-width as well
CURSOR_SOLID = 0x01
CURSOR_OUTLINE = 0x02
CURSOR_MENU = 0x03
CURSOR_DW_OUTLINE = 0x11
CURSOR_DW_SOLID = 0x12

CursorSpec = namedtuple('CursorSpec', 'x y cur_type')
CURSOR_DW_Mask = 0x10

def grey_level(amt):
    # give percent 0..1.0
    r = int(amt * 0x1f)
    g = int(amt * 0x3f)
    #b = int(amt * 0x1f)        # same as Red

    return (r<<11) | (g << 5) | r

def rgb(r,g,b):
    # as if 24-bit, but we're 16
    r = int(r/255 * 0x1f)
    g = int(g/255 * 0x3f)
    b = int(b/255 * 0x1f)
    return (r<<11) | (g << 5) | b


def get_sys_status():
    # Read current values for all status-bar items
    # - normally we update as we go along.
    # - return a dict
    from battery import get_batt_threshold

    rv = dict(shift=0, caps=0, symbol=0)
    rv['bat'] = get_batt_threshold()

    from stash import bip39_passphrase
    rv['bip39'] = int(bool(bip39_passphrase))

    from pincodes import pa
    rv['tmp'] = int(bool(pa.tmp_value))

    from glob import settings
    if settings:
        rv['xfp'] = settings.get('xfp')

    from version import is_edge, is_devmode
    if is_edge:
        rv['edge'] = 1
    elif is_devmode:
        rv['devmode'] = 1

    return rv


class Display:

    # TODO: move to global, but rest of system looks at these member vars
    WIDTH = 320
    HEIGHT = 240

    # use these negative X values for auto layout features
    CENTER = -2
    RJUST = -1

    # use this to know if on Q1 or earlier 
    has_lcd = True

    # icon names and their values (0 / 1)
    status_icons = {}

    def __init__(self):
        self.dis = ST7788()

        from gpu import GPUAccess
        self.gpu = GPUAccess()
        try:
            self.gpu.upgrade_if_needed()
        except:
            print("GPU upgrade failed")

        self.last_buf = self.make_buf(0)
        self.next_buf = self.make_buf(32)

        # state of progress bar (bottom edge)
        self.last_prog = (-1, -1)
        self.next_prog = (0, -1)

        # state of scroll bar (right side)
        self.last_scroll = 0.0
        self.next_scroll = None

        self.last_bar_update = 0
        #self.dis.fill_screen()     # defer a bit
        self.draw_status(full=True)

    def make_buf(self, ch=32):
        # make a screen-state storage buffer. One spot per character, but needs to
        # store attributes as well as support 16-bit unicode
        return [array.array('I', (ch for i in range(CHARS_W))) for y in range(CHARS_H)]

    def redraw_metakeys(self, new_state):
        # called when metakeys have changed state
        self.draw_status(**new_state)

    async def async_draw_status(self, **kws):
        self.draw_status(**kws)

    def set_lcd_brightness(self, on_battery=None, tmp_override=None):
        # Call when battery changes state, or if you want max for a bit (QR display)
        # - call w/o args to get back to state we're supposed to be in.
        from glob import settings
        from battery import get_batt_threshold, DEFAULT_BATT_BRIGHTNESS

        if tmp_override is not None:
            self.dis.backlight.intensity(tmp_override)
            return

        # otherwise: respect setting

        if on_battery is None:
            on_battery = (get_batt_threshold() is not None)

        if on_battery:
            # user-defined brightness when running on batteries.
            lvl = DEFAULT_BATT_BRIGHTNESS
            if settings:
                lvl = settings.get('bright', DEFAULT_BATT_BRIGHTNESS)
            self.dis.backlight.intensity(lvl)
        else:
            # full brightness when on VBUS and when showing QR's
            self.dis.backlight.intensity(255)

    def draw_status(self, full=False, **kws):
        animating = self.gpu.take_spi()

        if full:
            self.dis.fill_rect(0, 0, WIDTH, TOP_MARGIN-1, 0x0)
            self.dis.fill_rect(0, TOP_MARGIN-1, WIDTH, 1, grey_level(0.25))
            kws = get_sys_status()

        b_x = 292
        if 'bat' in kws:
            if kws['bat'] is None:
                self.image(b_x, 0, 'plugged')
                self.set_lcd_brightness(False)
            else:
                self.image(b_x, 0, 'bat_%d' % kws['bat'])
                self.set_lcd_brightness(True)

        if 'bip39' in kws:
            self.image(102, 0, 'bip39_%d' % kws['bip39'])

        if 'tmp' in kws:
            self.image(165, 0, 'tmp_%d' % kws['tmp'])

        xfp = kws.get('xfp', None)      # expects an integer
        if xfp is not None:
            x = 217
            for ch in xfp2str(xfp).lower():
                self.image(x, 0, 'ch_'+ch)
                x += 6

        x = 265
        if 'edge' in kws:
            self.image(x, 0, 'edge')
        elif 'devmode' in kws:
            self.image(x+5, 0, 'devmode')

        x = 8
        for dx, meta in [(5, 'shift'), (39, 'symbol'), (61, 'caps')]:
            if meta in kws:
                self.image(x+dx, 0, '%s_%d' % (meta, kws[meta]))

        if animating:
            self.gpu.give_spi()
        

    def image(self, x, y, name):
        # display a graphics image, immediately
        w,h, data = getattr(Graphics, name)
        if x is None:
            x = max(0, (WIDTH - w) // 2)
        self.gpu.take_spi()
        self.dis.show_zpixels(x, y, w, h, data)
        self.mark_correct(x, y, w, h)
        self.show()

    def mark_correct(self, px, py, w, h):
        # mark a subset of the screen as already drawn correctly
        # - because we drew an image in that spot already (immediate)
        # - hard: need to convert from pixel coord space to chars
        if py < TOP_MARGIN:
            # status icons not a concern
            return

        cy = (py - TOP_MARGIN) // CELL_H
        cx = (px - LEFT_MARGIN) // CELL_W
        cw = (w+CELL_W) // CELL_W
        ch = (h+CELL_H) // CELL_H
        #print('pixel %dx%d @ (%d,%d) => %dx%d @ (%d,%d)' % (w, h, px,py,  cw, ch, cx,cy))

        for y in range(cy, cy+ch):
            for x in range(cx, cx+cw+1):
                try:
                    self.last_buf[y][x] = self.next_buf[y][x] = 0xfffe
                except IndexError:
                    pass

    def icon(self, x, y, name, invert=0):
        # plan is these are chars or images
        raise NotImplementedError

    def width(self, msg):
        # length of text msg in char cells
        # - typically 1:1 but we have a few double-width chars
        rv = len(msg)
        rv += sum(1 for ch in msg if ch in FontIosevka.DOUBLE_WIDE)
        return rv

    def text(self, x,y, msg, font=None, invert=False, dark=False):
        # Draw at x,y (in cell positions, not pixels)
        # - use invert=1 to get reverse video
        # - returns ending X position, where you might want a cursor after
        end_x = None

        # encode text attribute for this part
        attr = 0
        if invert:
            attr = FLAG_INVERT
        if dark:
            attr = FLAG_DARK

        if x is None or x < 0:
            w = self.width(msg)
            if x is None:
                # center: also blanks rest of line
                x = max(0, (CHARS_W - w) // 2)
                end_x = x + w
                msg = ((' '*x) + msg + (' ' * CHARS_W))[0:CHARS_W]
                x = 0
            else:
                # measure from right edge (right justify)
                x = max(0, CHARS_W - w + 1 + x)
                end_x = x + w

        if y < 0:
            # measure up from bottom edge
            y = CHARS_H + y

        if y >= CHARS_H: 
            #print("BAD Draw '%s' at y=%d" % (msg, y))
            return     # past bottom

        for ch in msg:
            if x >= CHARS_W: break
            self.next_buf[y][x] = ord(ch) + attr
            x += 1
            if ch in FontIosevka.DOUBLE_WIDE:
                if x >= CHARS_W-1:
                    # trying to draw double-wide in last (half) of right edge: avoid
                    #self.next_buf[y][x-1] = 32
                    continue
                self.next_buf[y][x] = 0
                x += 1

        return end_x if end_x is not None else x

    def real_clear(self, _internal=False):
        # fill to black, but only text area, not status bar
        if not _internal:
            self.gpu.take_spi()
            self.dis.fill_rect(0, TOP_MARGIN, WIDTH, HEIGHT-TOP_MARGIN, 0x0)
        self.last_buf = self.make_buf(32)
        self.next_buf = self.make_buf(32)
        self.next_prog = (0, -1)
        self.next_scroll = None

    def clear(self):
        # clear text
        self.next_buf = self.make_buf(32)
        # clear progress bar & scroll bar
        self.next_prog = (0, -1)
        self.next_scroll = None

    def show(self, just_lines=None, cursor=None, max_bright=False):
        # Push internal screen representation to device, effeciently
        self.gpu.take_spi()

        if hasattr(self, '_full_redraw'):
            # redraw every pixel, assume nothing about display contents
            del self._full_redraw
            self.dis.fill_rect(0, TOP_MARGIN, WIDTH, HEIGHT-TOP_MARGIN, 0x0)
            self.draw_status(full=True)
            self.last_buf = self.make_buf(0xfff0)

        lines = just_lines or range(CHARS_H)
        for y in lines:
            py = TOP_MARGIN + (y * CELL_H)
            x = 0
            while x < CHARS_W:
                if self.next_buf[y][x] == self.last_buf[y][x]:
                    # already correct
                    x += 1
                    continue

                px = LEFT_MARGIN + (x * CELL_W)
                ch = chr(self.next_buf[y][x] & ~ATTR_MASK)
                attr = (self.next_buf[y][x] & ATTR_MASK)

                if ch == ' ':
                    # space - look for horz runs & fill w/ blank
                    run = 1
                    here = self.next_buf[y][x]
                    for x2 in range(x+1, CHARS_W):
                        if self.next_buf[y][x2] != here:
                            break                                        
                        run += 1

                    self.dis.fill_rect(px, py, run*CELL_W, CELL_H, 
                                COL_TEXT if attr == FLAG_INVERT else 0)
                    x += run
                    continue

                fn = FontIosevka.lookup(ch)
                if not fn:
                    # unknown char
                    x += 1
                    continue

                self.dis.show_pal_pixels(px, py, fn.w, fn.h, TEXT_PALETTES[attr >> 16], fn.bits)

                x += fn.w // CELL_W

            self.last_buf[y][:] = self.next_buf[y]

        # maybe update progress bar
        if self.next_prog != self.last_prog:
            self._draw_progress_bar()

        # maybe update right hand scroll bar
        if self.next_scroll != self.last_scroll:
            self._draw_scroll_bar(self.next_scroll)
            self.last_scroll = self.next_scroll

        # implement cursor based on CursorSpec values
        if cursor:
            assert 0 <= cursor.x < CHARS_W, 'cur x'
            assert 0 <= cursor.y < CHARS_H, 'cur y'
            self.gpu.cursor_at(cursor.x, cursor.y, cursor.cur_type)
            self.last_buf[cursor.y][cursor.x] = 0xfffd
            if (cursor.cur_type & CURSOR_DW_Mask) and (cursor.x < CHARS_W-1):
                self.last_buf[cursor.y][cursor.x+1] = 0xfffd

        # modulate the LCD brightness if we're showing QR or something
        if max_bright:
            self.set_lcd_brightness(tmp_override=255)
            self._max_bright = True
        elif hasattr(self, '_max_bright'):
            self.set_lcd_brightness()       # back to normal
            del self._max_bright
            

    # When drawing another screen for a bit, then coming back, use these
    def save_state(self):
        # should really be a dataclass w/ all our state details
        return ([array.array('I', ln) for ln in self.last_buf],
                    self.last_prog, self.last_scroll)

    def restore_state(self, old_state):
        rows, self.next_prog, self.next_scroll = old_state
        for y in range(CHARS_H):
            self.next_buf[y][:] = rows[y]
        self.show()

    # obsolete OLED approach
    def save(self):
        raise NotImplementedError
    def restore(self):
        raise NotImplementedError

    def hline(self, y):
        # used only in hsm_ux.py
        #self.dis.fill_rect(0,y, WIDTH, 1, 0xffff)
        pass

    def vline(self, x):
        # used only in hsm_ux.py
        #self.dis.fill_rect(x,TOP_MARGIN, 1, ACTIVE_H, 0xffff)
        pass

    def clear_rect(self, x,y, w,h):
        # but see clear_box() instead
        raise NotImplementedError

    def scroll_bar(self, offset, count, per_page=CHARS_H):
        # next show(), we will draw a scroll bar on right edge
        assert count >= 1
        self.next_scroll = (offset, count, per_page)

    def _draw_progress_bar(self):
        # show the "part 1 of 10" style bar used for BBQr
        h = PROGRESS_BAR_H          # NOTE: misc/gpu/lcd.c will need update if H changes
        w, x = self.next_prog
        if x == -1:
            # normal old-style progress bar
            lw, lx = self.last_prog
            if lw <= w and lx == -1:
                # no need to erase, we can just draw on top
                pass
            else:
                # erase under / clear it
                self.dis.fill_rect(0, HEIGHT-h, WIDTH, h, COL_BLACK)

            if w:
                self.dis.fill_rect(0, HEIGHT-h, w, h, COL_PROGRESS)
        else:
            # draw new bar
            self.dis.fill_rect(0, HEIGHT-h, WIDTH, h, COL_DARK_TEXT)
            self.dis.fill_rect(x, HEIGHT-h, w, h, COL_PROGRESS)

        self.last_prog = self.next_prog

    def _draw_scroll_bar(self, values):
        # Immediately draw bar along right edge.
        bw = 5      # bar width
        if values is None:
            # clear old display
            self.dis.fill_rect(WIDTH-bw, TOP_MARGIN, bw, ACTIVE_H, COL_BLACK)
            return

        offset, count, per_page = values

        assert 0 <= offset <= count, (offset, count, per_page)
        num_pages = max(count / per_page, 2)
        bh = max(int(ACTIVE_H / num_pages), 4)
        pos = int((ACTIVE_H - bh) * (offset / count))

        # "round up" the final page so touches bottom always
        is_last = offset and (offset + per_page >= count)
        if is_last:
            pos = ACTIVE_H - bh

        self.dis.fill_rect(WIDTH-bw, TOP_MARGIN, bw, ACTIVE_H, COL_SCROLL_DARK)
        self.dis.fill_rect(WIDTH-bw, TOP_MARGIN+pos, bw, bh, COL_TEXT)

    def fullscreen(self, msg, percent=None, line2=None):
        # show a simple message "fullscreen". 
        self.clear()
        y = CHARS_H // 3
        self.text(None, y, msg)
        if line2:
            y += 2
            for ln in word_wrap(line2, CHARS_W):
                self.text(None, y, ln, dark=True)
                y += 1
        if percent is not None:
            self.progress_bar(percent)
        self.show()

    def splash(self):
        # display a splash screen with some version numbers
        self.real_clear()

        y = 6
        self.image(None, 90, 'splash')
        self.text(None, y, "Don't Trust. Verify.")

        from version import get_mpy_version
        timestamp, label, *_ = get_mpy_version()

        self.text(0,  -1, 'Version '+label)
        self.text(-1, -1, timestamp)
        self.show([y, CHARS_H-1])

    def progress_bar(self, percent):
        # Horizontal progress bar
        # takes 0.0 .. 1.0 as fraction of doneness
        percent = max(0, min(1.0, percent))
        self.next_prog = (int(WIDTH * percent), -1)

    def progress_part_bar(self, n_of_m):
        # for BBQr: a part of a bar (segment N of M parts)
        n, m = n_of_m
        assert n <= m
        if m <= 1:
            # turn off bar segment display if one or none of them
            self.next_prog = (0, -1)
        else:
            w = WIDTH // m
            if n == m-1:
                # be sure last bar touchs right edge
                self.next_prog = (w, WIDTH-w)
            else:
                self.next_prog = (w, (n * w))

    def progress_sofar(self, done, total):
        # Update progress bar, but only if it's been a while since last update
        if utime.ticks_diff(utime.ticks_ms(), self.last_bar_update) < 100:
            return
        self.last_bar_update = utime.ticks_ms()
        self.progress_bar_show(done / total)

    def progress_bar_show(self, percent):
        # useful as a callback
        self.progress_bar(percent)
        self.show()

    def busy_bar(self, enable):
        # activate the GPU to render/animate this.
        # - show() in this funct is relied-upon by callers
        if enable:
            self.last_prog = (-1, -1)
            self.show()
            self.gpu.busy_bar(True)
        else:
            # - self.show will stop animation
            # - and redraw w/ no bar visible
            self.last_prog = (WIDTH, -1)
            self.next_prog = (0, -1)
            self.show()

    def set_brightness(self, val):
        # - was only used by HSM ux code
        # - QR code display brightness is done in show_qr_data() now
        # - see self.set_lcd_brightness()
        return 

    def menu_draw(self, ry, msg, is_sel, is_checked, space_indicators):
        # draw a menu item, perhaps selected, checked.
        assert CHARS_W == 34

        if ry >= CHARS_H:
            # higher layer tries to draw partial line past bottom, and that's
            # ok because the mk4 had a 5th, half-line as a hint
            return

        if msg[0] == ' ' and space_indicators:
            # unused, but might need?
            msg = '␣' + msg[1:]

        x = 0
        self.text(x, ry, ' '+msg+' ', invert=is_sel)

        if is_checked:
            self.text(len(msg)+2, ry, '✔')

    def menu_show(self, cursor_y):
        cs = CursorSpec(0, cursor_y or 0, CURSOR_MENU)
        self.show(cursor=cs)

    def show_yikes(self, lines):
        # dump a stack trace
        # - intended for photos, sent to support!

        self.clear()
        self.text(None, 0, '>>>> Yikes!! <<<<')

        y = 1
        for num, ln in enumerate(lines):
            ln = ln.strip()

            if ln[0:6] == 'File "':
                # convert: File "main.py", line 63, in interact
                #    into: main.py:63  interact
                ln = ln[6:].replace('", line ', ':').replace(', in ', '  ')
                if ln[0] == '/':
                    ln = ln.split('/')[-1]

            for second, l in enumerate(word_wrap(ln, CHARS_W)):
                self.text(1 if second else 0, y, l)
                y += 1

        self.show()

    def draw_story(self, lines, top, num_lines, is_sensitive, hint_icons=''):
        self.clear()

        y=0
        prev_x = None
        for ln in lines:
            if ln == 'EOT':
                self.text(0, y, '┅'*CHARS_W, dark=True)
                continue

            elif ln and ln[0] == OUT_CTRL_TITLE:
                # title ... but we have no special font? Inverse!
                self.text(0, y, ' '+ln[1:]+' ', invert=True)
                if hint_icons:
                    # hint_icons not shown if is story without title
                    # maybe show that [QR,NFC] can do something
                    self.text(-1, y, hint_icons, dark=True)

            elif ln and ln[0] == OUT_CTRL_ADDRESS:
                # we can assume this will be a single line for our display
                # thanks to code in utils.word_wrap
                prev_x = self._draw_addr(y, ln[1:], prev_x=prev_x)

            else:
                self.text(0, y, ln)
                prev_x = None

            y += 1

        self.scroll_bar(top, num_lines, CHARS_H)
        self.show()

    def _draw_addr(self, y, addr, prev_x=None):
        # Draw a single-line of an address
        # - use prev_x=0 to start centered
        if prev_x is None:
            # left justify (for stories)
            prev_x = x = 1
        elif prev_x == 0:
            # center first line, following line(s) will be left-justified to match that
            prev_x = x = max(((CHARS_W - (len(addr) * 5) // 4) // 2), 0)
        else:
            x = prev_x

        self.text(x, y, ' '+' '.join(chunk_address(addr))+' ', invert=True)

        return prev_x

    @staticmethod
    def handle_qr_msg(msg, max_lines=False):
        if len(msg) <= CHARS_W:
            parts = [msg]
        elif ' ' not in msg and (len(msg) <= (CHARS_W * 2)):
            # fits in two lines, but has no spaces
            hh = len(msg) // 2
            parts = [msg[0:hh], msg[hh:]]
        else:
            if not max_lines:
                # do word wrap
                parts = list(word_wrap(msg, CHARS_W))
            else:
                # 2 lines max
                parts = [msg[:30] + "⋯", "⋯" + msg[-30:]]

        return parts

    def draw_qr_lines(self, lines, is_addr):
        y = CHARS_H - len(lines)
        prev_x = 0
        for line in lines:
            if not is_addr:
                self.text(None, y, line)
            else:
                prev_x = self._draw_addr(y, line, prev_x=prev_x)
            y += 1

    def draw_qr_idx_hint(self, str_idx):
        lh = len(str_idx)
        assert lh <= 10
        if lh > 5:
            # needs 2 lines
            self.text(-1, 0, str_idx[:5])
            self.text(-1, 1, str_idx[5:])
        else:
            self.text(-1, 0, str_idx)

    def draw_side_msg(self, msg, has_idx):
        right_sub = 2 if has_idx else 0
        start_right = right_msg = None
        if len(msg) <= CHARS_H:
            # we only need left side
            start_left = CHARS_H - len(msg)
            left_msg = msg
        else:
            split_msg = msg.split()
            if len(split_msg) == 1 or len(split_msg) > 2:
                return  # not possible

            left_msg, right_msg = split_msg
            if len(left_msg) > CHARS_H:
                return
            if len(right_msg) > (CHARS_H - right_sub):
                return

            start_left = CHARS_H - len(left_msg)
            start_right = CHARS_H  - len(right_msg)

        for i, c in enumerate(left_msg, start=start_left):
            self.text(1, i, c)

        if start_right:
            for i, c in enumerate(right_msg, start=start_right):
                self.text(-1, i, c)

    def draw_qr_error(self, idx_hint, msg=None):
        x = 85
        y = 30
        w = 150
        self.clear()
        self.dis.fill_rect(x, y, w, w, COL_TEXT)
        self.dis.fill_rect(x + 1, y + 1, w - 2, w - 2)  # Black
        self.text(12, 3, "QR too big")
        if msg:
            lines = self.handle_qr_msg(msg, max_lines=True)
            self.draw_qr_lines(lines, False)

        self.draw_qr_idx_hint(idx_hint)
        self.show()

    def draw_qr_display(self, qr_data, msg, is_alnum, sidebar, idx_hint, invert, partial_bar=None,
                        is_addr=False, force_msg=False, side_msg=None):
        # Show a QR code on screen w/ some text under it
        # - invert not supported on Q1
        # - sidebar not supported here (see users.py)

        assert not sidebar

        # maybe show something other than QR contents under it
        if is_addr:
            # With fancy display, no address, even classic can fit in single line,
            # so always split nicely in middle and at mod4
            hh = len(msg) // 2
            if hh <= 20:
                hh = (hh + 3) & ~0x3
                parts = [msg[0:hh], msg[hh:]]
                num_lines = 2
            else:
                # p2wsh address would need 3 lines to show, so we won't
                num_lines = 0
        elif msg:
            parts = self.handle_qr_msg(msg)
            num_lines = len(parts)
        else:
            num_lines = 0

        if num_lines > 2:
            # show no text if it would be too big (example: 18, 24 seed words)
            num_lines = 0
            del parts

        # send packed pixel data to C level to decode and expand onto LCD
        # - 8-bit aligned rows of data
        scan_w, w, data = qr_data.packed() if hasattr(qr_data, 'packed') else qr_data

        # always draw as large as possible (vertical is limit)
        # - even if that's a bit more than 240 and might even cover status bar
        # - see bbqr.CHARS_PER_VERSION for specific versions to support
        expand = max(1, (ACTIVE_H - (num_lines * CELL_H))  // (w+2))
        fullscreen = False
        trim_lines = 0

        # always try to show the biggest possible QR code if not force_msg
        if not force_msg:
            if num_lines:
                # better with text dropped?
                e2 = max(1, ACTIVE_H // (w + 2))
                if e2 > expand:
                    num_lines = 0
                    expand = e2

            # fullscreen ?
            e3 = (ACTIVE_H + 20) // (w + 2)
            if expand < e3:
                expand = e3
                fullscreen = True
                num_lines = 0

        # vert center in available space
        qw = (w+2) * expand
        if fullscreen:
            usable = HEIGHT - PROGRESS_BAR_H
            if qw > usable:
                # we will skip displaying some interior lines; slightly squishing it
                y = 0
                trim_lines = qw - usable
            else:
                y = max(0, (usable - (num_lines * CELL_H) - qw) // 2)

            if not hasattr(self, '_full_redraw'):
                # blank status bar area, but only first time thru
                self.dis.fill_rect(0, 0, WIDTH, TOP_MARGIN, 0x0)
        else:
            y = TOP_MARGIN + max(0, (ACTIVE_H - (num_lines * CELL_H) - qw) // 2)

        # horz center - easy
        x = (WIDTH - qw) // 2

        self.clear()

        self.dis.show_qr_data(x, y, w, expand, scan_w, data, trim_lines)

        if partial_bar is not None:
            self.progress_part_bar(partial_bar)

        if not fullscreen:
            self.mark_correct(x, y, qw, qw)

            if num_lines:
                # centered text under that
                self.draw_qr_lines(parts, is_addr)

            if idx_hint:
                self.draw_qr_idx_hint(idx_hint)

            if side_msg:
                self.draw_side_msg(side_msg, idx_hint)

            # pass a max brightness flag here, which will be cleared after next show
            self.show(max_bright=True)
        else:
            # not much left to draw: just the progress bar
            self._draw_progress_bar()
            self.set_lcd_brightness(tmp_override=255)
            self._max_bright = True
            self._full_redraw = True

    def draw_bbqr_progress(self, hdr, got_parts, corrupt=False):
        # we've seen at least one BBQr QR, so update display w/ progress bar
        # - lots of data so we can show nice animation
        # - hdr:BBQrHeader instance
        count = len(got_parts)
        if 2 <= hdr.num_parts < (CHARS_W // 2):
            # if not too many parts, show - or 3 as they arrive
            pat = []
            for i in range(hdr.num_parts):
                if i in got_parts:
                    pat.append(str(i+1))
                else:
                    wl = 1 if i < 9 else 2
                    if corrupt and i == hdr.which:
                        pat.append('X'*wl)
                    else:
                        pat.append('-'*wl)

            pat = ('  ' if hdr.num_parts <= 8 else ' ').join(pat)
            if len(pat) > CHARS_W:
                pat = ''
        else:
            pat = ''                # clear line

        if count == hdr.num_parts and count == 1:
            # skip the BS, it's a simple one
            self.progress_bar_show(1)
            return

        self.text(None, -3, pat)
        self.text(None, -2, 'Keep scanning more...' if count < hdr.num_parts else 'Got all parts!')
        self.text(None, -1, '%s: %d of %d parts' % (hdr.file_label(), count, hdr.num_parts),
                                                        dark=True)
        percent = count / hdr.num_parts
        self.progress_bar(percent)
        self.show()

    def draw_box(self, x, y, w, h, **kw):
        # using line-drawing chars, draw a box
        # returns X pos of first inside char
        assert 0 <= h <= CHARS_H-2      # 8 max
        assert 0 <= w <= CHARS_W-2      # 32 max

        if x is None:
            x = (CHARS_W - w - 2) // 2
        ln = '┏' + ('━'*w) + '┓'
        self.text(x, y, ln, **kw)
        for yy in range(y+1, y+h+1):
            self.text(x, yy,  '┃', **kw)
            self.text(x+w+1,  yy, '┇', **kw)

        ln = '┗' + ('┅'*w) + '┛'
        self.text(x, y+h+1, ln, **kw)

        return x+1

    def clear_box(self, x, y, w, h, fill=32):
        # clear (w/ spaces) a box on screen
        for Y in range(y, y+h):
            for X in range(x, x+w):
                assert 0 <= X < CHARS_W, X
                assert 0 <= Y < CHARS_H, Y
                self.next_buf[Y][X] = fill

    def bootrom_takeover(self):
        # we are going to go into the bootrom and have it do stuff on the
        # screen... we need to redraw completely on return
        self.gpu.take_spi()     # blocks until xfer complete
        self.last_buf = self.make_buf(0)
        self.last_prog = (-1, -1)

        
# here for mpy reasons
WIDTH = const(320)
HEIGHT = const(240)

# EOF
