Unihiker K10 UI Booster: Turbocharge Your Screen!

0 18 Medium

Ready to unleash the full potential of your Unihiker K10? With the standard MicroPython screen library, you get a solid foundation—but what if you want charts, more primitives (as dots, lines, circles and rects), and a workflow reminiscent of PyGame? That’s where ScreenExtension.py steps in!

This tutorial shows you how to supercharge the Unihiker K10 screen with a custom MicroPython extension. It will reduce repetitive code, let you draw shapes, text, and objects with ease, and help you craft stunning UIs with minimal effort.

Key Benefits:

- Predefined Colors: Skip defining RGB values over and over.
- Simplified Drawing API: Inspired by PyGame.
- Efficient Rendering: Combine multiple draw calls into clean UI updates.
- Expandable: Start with the provided features and extend further as you like!

HARDWARE LIST
1 Unihiker K10
STEP 1
The Full Code

Here is an overview of what is happening under the hood in ScreenExtension.py:

- Necessary MicroPython modules are imported
- Various colors are defined as constants (these can be imported if required)
- 4 classes (Validator, Draw, Add and Screen) are defined which provide specific methods

CODE
import micropython
from unihiker_k10 import screen
from math import radians, cos, sin, atan2


BLACK: int = micropython.const(0x000000)
WHITE: int = micropython.const(0xFFFFFF)
RED: int = micropython.const(0xFF0000)
GREEN: int = micropython.const(0x00FF00)
BLUE: int = micropython.const(0x0000FF)
YELLOW: int = micropython.const(0xFFFF00)
CYAN: int = micropython.const(0x00FFFF)
MAGENTA: int = micropython.const(0xFF00FF)
GRAY = micropython.const(0x808080)
ORANGE = micropython.const(0xFFA500)
BROWN = micropython.const(0x8B4513)
PINK = micropython.const(0xFFC0CB)


class Validator:
    """
    Provides static methods for validating various input data types.
    """

    @staticmethod
    def validate_color(color) -> None:
        if not isinstance(color, int):
            raise TypeError(f"color must be a int eq. 0xFF0000, not:{type(color).__name__}")

    @staticmethod
    def validate_pos(pos, name="pos") -> None:
        if not (isinstance(pos, tuple) and len(pos) == 2 and all(isinstance(c, int) for c in pos)):
            raise ValueError(f"{name} must be a tuple with 2 int, e.q. (x, y)")

    @staticmethod
    def validate_coords(coords, name="coords") -> None:
        if not (isinstance(coords, tuple) and len(coords) == 4 and all(isinstance(c, int) for c in coords)):
            raise ValueError(f"{name} must be a tuple with 4 int, e.q. (x, y, w, h)")

    @staticmethod
    def validate_int(val, name="width") -> None:
        if not isinstance(val, int) or val < 0:
            raise ValueError(f"{name} must be an int >= 0")

    @staticmethod
    def validate_pointlist(pointlist, name="pointlist"):
        if not (isinstance(pointlist, tuple) and len(pointlist) >= 3):
            raise ValueError(f"{name} must be a tuple with at least 3 (x,y) points")
        for idx, point in enumerate(pointlist):
            if not (isinstance(point, tuple) and len(point) == 2 and all(isinstance(c, int) for c in point)):
                raise ValueError(f"{name}[{idx}] must be a tuple of two int (x,y)")

    @staticmethod
    def validate_string(text) -> None:
        if not isinstance(text, str):
            raise ValueError("text must be a string")


class Draw:
    """
    Provides various static methods for drawing graphical elements such as points,
    lines, rectangles, circles, polygons, arcs, gradient rectangles, and text.
    """

    @staticmethod
    def point(color: int, pos: tuple) -> None:
        Validator.validate_color(color)
        Validator.validate_pos(pos)

        x, y = pos
        screen.draw_point(x=x, y=y, color=color)

    @staticmethod
    def line(color: int, start_pos: tuple, end_pos: tuple, width: int = 1) -> None:
        Validator.validate_color(color)
        Validator.validate_pos(start_pos, "start_pos")
        Validator.validate_pos(end_pos, "end_pos")
        Validator.validate_int(width, "width")

        screen.set_width(width)

        x0, y0 = start_pos
        x1, y1 = end_pos
        screen.draw_line(x0=x0, y0=y0, x1=x1, y1=y1, color=color)

        if width > 1:
            screen.set_width(1)

    @staticmethod
    def rect(color: int, coords: tuple, width: int = 0, outline: int = None):
        Validator.validate_color(color)
        Validator.validate_coords(coords, "coords")

        if outline is not None and width > 0:
            Validator.validate_int(width, "width")
            Validator.validate_color(outline)
            screen.set_width(width)
            b_color = outline
        else:
            b_color = color

        x, y, w, h = coords
        screen.draw_rect(x=x, y=y, w=w, h=h, fcolor=color, bcolor=b_color)

        if width > 1:
            screen.set_width(1)

    @staticmethod
    def circle(color: int, center: tuple, radius: int, width: int = 0, outline: int = None) -> None:
        Validator.validate_color(color)
        Validator.validate_pos(center, "center")
        Validator.validate_int(radius, "radius")

        if outline is not None and width > 0:
            Validator.validate_int(width, "width")
            Validator.validate_color(outline)
            screen.set_width(width)
            b_color = outline
        else:
            b_color = color

        x, y = center
        screen.draw_circle(x=x ,y=y ,r=radius, fcolor=color, bcolor=b_color)

        if width > 1:
            screen.set_width(1)

    @staticmethod
    @micropython.native
    def polygon(color: int, points: tuple, width: int = 0, outline: int = None) -> None:
        Validator.validate_color(color)
        Validator.validate_pointlist(points)

        points_sorted = [(x, y) for x, y in points]
        min_y = min(y for (x, y) in points_sorted)
        max_y = max(y for (x, y) in points_sorted)
        edges = []
        n = len(points_sorted)

        for i in range(n):
            x0, y0 = points_sorted[i]
            x1, y1 = points_sorted[(i + 1) % n]

            if y0 == y1:
                continue

            if y0 > y1:
                x0, y0, x1, y1 = x1, y1, x0, y0

            edges.append({'x0': x0, 'y0': y0, 'x1': x1, 'y1': y1, 'inv_slope': (x1 - x0) / (y1 - y0)})

        for y in range(min_y, max_y + 1):
            intersections = []
            for edge in edges:
                if edge['y0'] <= y < edge['y1']:
                    x = edge['x0'] + (y - edge['y0']) * edge['inv_slope']
                    intersections.append(int(x))

            intersections.sort()

            for i in range(0, len(intersections), 2):
                if i + 1 < len(intersections):
                    x_start, x_end = intersections[i], intersections[i + 1]
                    screen.draw_line(x0=x_start, y0=y, x1=x_end, y1=y, color=color)

        if outline is not None and width > 0:
            Validator.validate_int(width, "width")
            Validator.validate_color(outline)
            screen.set_width(width)

            for i in range(len(points)):
                x0, y0 = points[i]
                x1, y1 = points[(i + 1) % len(points)]
                screen.draw_line(x0=x0, y0=y0, x1=x1, y1=y1, color=outline)

        if width > 1:
            screen.set_width(1)

    
    @staticmethod
    @micropython.native
    def arc(color: int, center: tuple, radius: int, start_angle: int, end_angle: int, width: int = 0, outline: int = None) -> None:
        Validator.validate_color(color)
        Validator.validate_pos(center, "center")
        Validator.validate_int(radius, "radius")
        Validator.validate_int(start_angle, "start_angle")
        Validator.validate_int(end_angle, "end_angle")

        cx, cy = center
        start_rad = radians(start_angle)
        end_rad = radians(end_angle)
        step = radians(1)
        points = [center]
        angle = start_rad

        while angle <= end_rad:
            x = int(cx + cos(angle) * radius)
            y = int(cy + sin(angle) * radius)
            points.append((x, y))
            angle += step

        x_end = int(cx + cos(end_rad) * radius)
        y_end = int(cy + sin(end_rad) * radius)
        points.append((x_end, y_end))

        Draw.polygon(color=color, points=tuple(points))

        if outline is not None and width > 0:
            Validator.validate_int(width, "width")
            screen.set_width(width)

            for i in range(1, len(points)-1):
                x0, y0 = points[i]
                x1, y1 = points[i+1]
                screen.draw_line(x0=x0, y0=y0, x1=x1, y1=y1, color=outline)

            x0, y0 = points[0]
            x_start, y_start = points[1]
            x_end, y_end = points[-1]

            screen.draw_line(x0=x0, y0=y0, x1=x_start, y1=y_start, color=outline)
            screen.draw_line(x0=x0, y0=y0, x1=x_end, y1=y_end, color=outline)

        if width > 1:
            screen.set_width(1)

    @staticmethod
    @micropython.native
    def gradient_rect(pos: tuple, size: tuple, color_start: int, color_end: int, horizontal: bool = False) -> None:
        Validator.validate_pos(pos, "pos")
        Validator.validate_color(color_start)
        Validator.validate_color(color_end)

        x, y = pos
        width, height = size
        steps = width if horizontal else height

        for i in range(steps):
            ratio = i / steps
            r = int(((color_end >> 16 & 0xFF) * ratio) + ((color_start >> 16 & 0xFF) * (1 - ratio)))
            g = int(((color_end >> 8 & 0xFF) * ratio) + ((color_start >> 8 & 0xFF) * (1 - ratio)))
            b = int(((color_end & 0xFF) * ratio) + ((color_start & 0xFF) * (1 - ratio)))
            color = (r << 16) | (g << 8) | b

            if horizontal:
                Draw.line(color=color, start_pos=(x + i, y), end_pos=(x + i, y + height))
            else:
                Draw.line(color=color, start_pos=(x, y + i), end_pos=(x + width, y + i))

    @staticmethod
    def text(color: int, text: str, pos: tuple)  -> None:
        Validator.validate_color(color)
        Validator.validate_string(text)
        Validator.validate_pos(pos, "pos")

        x, y = pos

        screen.draw_text(text=text, x=x, y=y , color=color)


class Add:
    """
    Provides various static utility methods for graphical drawing, including grid, arrow,
    ruler, pie chart, gauge, and clock.
    """

    RULER_SCALE_SHORT: int = micropython.const(5)
    RULER_SCALE_LONG: int = micropython.const(10)
    ARROW_HEAD_LENGTH: int = micropython.const(16)
    ARROW_HEAD_WIDTH: int = micropython.const(10)
    HOUR_LINES: int = micropython.const(6)
    MINUTE_LINES: int = micropython.const(3)

    @staticmethod
    @micropython.native
    def grid(color: int, pos: tuple, width: int, height: int, cell_size: int) -> None:
        Validator.validate_color(color)
        Validator.validate_pos(pos, "pos")
        Validator.validate_int(width, "width")
        Validator.validate_int(height, "height")
        Validator.validate_int(cell_size, "cell_size")

        if cell_size >= width:
            raise ValueError(f"cell_size {cell_size} must be smaller {width}")

        if cell_size >= height:
            raise ValueError(f"cell_size {cell_size} must be smaller {height}")

        x, y = pos

        for i in range(0, width + 1, cell_size):
            Draw.line(color=color, start_pos=(x + i, y), end_pos=(x + i, y + height))

        for j in range(0, height + 1, cell_size):
            Draw.line(color=color, start_pos=(x, y + j), end_pos=(x + width, y + j))

    @staticmethod
    @micropython.native
    def arrow(color: int, start_pos: tuple, end_pos: tuple) -> None:
        Validator.validate_color(color)
        Validator.validate_pos(start_pos, "start_pos")
        Validator.validate_pos(end_pos, "end_pos")

        width: int = 2

        x0, y0 = start_pos
        x1, y1 = end_pos

        Draw.line(color=color, start_pos=start_pos, end_pos=end_pos, width=width)

        dx = x1 - x0
        dy = y1 - y0
        angle = radians(0) if dx == 0 and dy == 0 else atan2(dy, dx)

        back_x = x1 - Add.ARROW_HEAD_LENGTH * cos(angle)
        back_y = y1 - Add.ARROW_HEAD_LENGTH * sin(angle)

        left_x = back_x + Add.ARROW_HEAD_WIDTH / 2 * sin(angle)
        left_y = back_y - Add.ARROW_HEAD_WIDTH / 2 * cos(angle)

        right_x = back_x - Add.ARROW_HEAD_WIDTH / 2 * sin(angle)
        right_y = back_y + Add.ARROW_HEAD_WIDTH / 2 * cos(angle)

        points = (
            (x1, y1),
            (int(left_x), int(left_y)),
            (int(right_x), int(right_y))
        )

        Draw.polygon(color=color, points=points)
    
    @staticmethod
    @micropython.native
    def ruler(color: int, orientation: str, pos: tuple, length: int, steps: int, long_steps: int = 5, invert: bool = False) -> None:
        Validator.validate_color(color)
        Validator.validate_pos(pos, "pos")
        Validator.validate_int(length, "length")
        Validator.validate_int(steps, "steps")
        Validator.validate_int(long_steps, "long_steps")
        
        if orientation not in ("horizontal", "vertical"):
            raise ValueError("orientation must be 'horizontal' or 'vertical'")
        
        x, y = pos
        step_size = length // steps
        
        if orientation == "horizontal":
            Draw.line(color=color, start_pos=(x, y), end_pos=(x + length, y), width=1)
            
            for i in range(steps + 1):
                sx = x + i * step_size
                
                step_length = Add.RULER_SCALE_LONG if i % long_steps == 0 else Add.RULER_SCALE_SHORT
                
                if invert:
                    start_point = (sx, y)
                    end_point = (sx, y + step_length)
                else:
                    start_point = (sx, y)
                    end_point = (sx, y - step_length)
                
                Draw.line(color=color, start_pos=start_point, end_pos=end_point, width=1)
        else:
            Draw.line(color=color, start_pos=(x, y), end_pos=(x, y + length), width=1)
            
            for i in range(steps + 1):
                sy = y + i * step_size
                
                step_length = Add.RULER_SCALE_LONG if i % long_steps == 0 else Add.RULER_SCALE_SHORT
                
                if invert:
                    start_point = (x, sy)
                    end_point = (x - step_length, sy)
                else:
                    start_point = (x, sy)
                    end_point = (x + step_length, sy)
                
                Draw.line(color=color, start_pos=start_point, end_pos=end_point, width=1)

    @staticmethod
    def pie_chart(pos: tuple, radius: int, values: tuple, colors: tuple) -> None:
        Validator.validate_pos(pos, "pos")
        Validator.validate_int(radius, "radius")
        
        if not (isinstance(values, tuple) and 1 <= len(values) <= 5):
            raise ValueError("values must be a tuple with 1 to 5 elements")
        
        total = sum(values)
        if total != 100:
            raise ValueError("values must sum to 100")
        
        if not (isinstance(colors, tuple) and len(colors) == len(values)):
            raise ValueError("colors must be a tuple matching the number of values")
    
        for idx, color in enumerate(colors):
            Validator.validate_color(color)

        start_angle: int = 0

        for value, color in zip(values, colors):
            angle_span = int(value * 360 / 100)
            end_angle = start_angle + angle_span
        
            Draw.arc(color=color, center=pos, radius=radius, start_angle=start_angle, end_angle=end_angle)
        
            start_angle = end_angle
    
    @staticmethod
    @micropython.native
    def gauge(color: int, pos: tuple, value: int, value_range: tuple = (0, 100), radius: int = 50) -> None:
        Validator.validate_color(color)
        Validator.validate_pos(pos, "pos")
        Validator.validate_int(value, "value")
        Validator.validate_int(value_range[0], "range_min")
        Validator.validate_int(value_range[1], "range_max")
        Validator.validate_int(radius, "radius")
        
        if not (value_range[0] <= value <= value_range[1]):
            raise ValueError(f"value {value} out of range {range}")
        
        width: int = 2
        step: int = 1
        angle_span: int = 180

        x, y = pos
        min_val, max_val = value_range
        pointer_length = radius - 10
        screen.set_width(width)
        
        for angle in range(180, 361, step):
            px = int(x + cos(radians(angle)) * radius)
            py = int(y + sin(radians(angle)) * radius)
            screen.draw_point(x=px, y=py, color=color)

        if width > 1:
            screen.set_width(1)

        value_angle = 180 + ((value - min_val) / (max_val - min_val)) * angle_span
        pointer_x = int(x + cos(radians(value_angle)) * pointer_length)
        pointer_y = int(y + sin(radians(value_angle)) * pointer_length)

        Draw.line(color=color, start_pos=pos, end_pos=(pointer_x, pointer_y), width=width)
        Draw.circle(color=color, center=pos, radius=3)

    @staticmethod
    @micropython.native
    def clock(color: int, center: tuple, radius: int, pointer: int, hour: int, minute: int) -> None:
        Validator.validate_color(color)
        Validator.validate_pos(center, "center")
        Validator.validate_int(radius, "radius")
        Validator.validate_color(pointer)
        Validator.validate_int(hour, "hour")
        Validator.validate_int(minute, "minute")

        if not (0 <= hour < 24):
            raise ValueError("hour must be int between 0 and 23")

        if not (0 <= minute < 60):
            raise ValueError("minute must be int between 0 and 59")

        Draw.circle(color=color, center=center, radius=radius)

        for i in range(12):
            angle = radians(30 * i)
            x_outer = center[0] + int(radius * cos(angle))
            y_outer = center[1] + int(radius * sin(angle))
            x_inner = center[0] + int((radius - Add.HOUR_LINES) * cos(angle))
            y_inner = center[1] + int((radius - Add.HOUR_LINES) * sin(angle))

            Draw.line(color=pointer, start_pos=(x_inner, y_inner), end_pos=(x_outer, y_outer), width=1)

        if radius > 50:
            for i in range(60):
                angle = radians(6 * i)
                x_outer = center[0] + int(radius * cos(angle))
                y_outer = center[1] + int(radius * sin(angle))
                x_inner = center[0] + int((radius - Add.MINUTE_LINES) * cos(angle))
                y_inner = center[1] + int((radius - Add.MINUTE_LINES) * sin(angle))

                Draw.line(color=pointer, start_pos=(x_inner, y_inner), end_pos=(x_outer, y_outer), width=1)

        hour_angle = radians(30 * hour - 90)
        hx = center[0] + int(radius * 0.5 * cos(hour_angle))
        hy = center[1] + int(radius * 0.5 * sin(hour_angle))

        Draw.line(color=pointer, start_pos=center, end_pos=(hx, hy), width=2)

        min_angle = radians(6 * minute - 90)
        mx = center[0] + int(radius * 0.8 * cos(min_angle))
        my = center[1] + int(radius * 0.8 * sin(min_angle))

        Draw.line(color=pointer, start_pos=center, end_pos=(mx, my), width=1)


class Screen:
    """
    Represents the Screen functionality including initialization, background
    manipulation, and rendering.
    """

    def __init__(self, direction: int = 2, bg_color: int = BLACK):
        screen.init(dir=direction)
        screen.set_width(width=1)
        screen.show_bg(color=bg_color)
        
        self.draw = Draw()
        self.add = Add()

    @staticmethod
    def show() -> None:
        screen.show_draw()

    @staticmethod
    def clear(bg_color: int = None) -> None:
        screen.clear()

        if bg_color:
            Validator.validate_color(bg_color)
            screen.show_bg(color=bg_color)
STEP 2
Few Examples

Upload the following main.py examples into the root directory, create a new directory called lib, and inside this newly created folder upload ScreenExtension.py. After you are done, just run Unihiker K10.

Each demo structure should look like this:

CODE
root/
├── boot.py
├── main.py
├── lib/
│   └── ScreenExtension.py

Note: boot.py is not required, just optional.

Example 1:

CODE
from lib.ScreenExtension import Screen, RED, CYAN, BLACK, GREEN, BLUE, ORANGE


if __name__ == '__main__':
    display = Screen(bg_color=BLACK)

    display.draw.line(GREEN, (20, 10), (100, 100), 5)
    display.draw.rect(CYAN, (150, 150, 50, 50), 6, BLUE)
    display.draw.circle(ORANGE, (100, 200), 25, 4, RED)

    display.show()

Example 2:

CODE
from lib.ScreenExtension import Screen, BLACK, YELLOW, BLUE, RED, GRAY, GREEN


if __name__ == '__main__':
    display = Screen(bg_color=BLACK)

    display.draw.polygon(YELLOW, ((20, 20), (100, 100), (40, 150)), 2, BLUE)
    display.draw.arc(RED, (175, 100), 50, 0, 180, 3, GRAY)
    display.draw.gradient_rect((100, 175), (100, 100), RED, GREEN, True)

    display.show()

Example 3:

CODE
from lib.ScreenExtension import Screen, BLACK, WHITE, RED, BLUE, GREEN, ORANGE


if __name__ == '__main__':
    display = Screen(bg_color=BLACK)

    display.add.ruler(WHITE, "vertical", (20, 50), 200, 50, 10, False)
    display.add.pie_chart((120, 160), 75, (40, 30, 10, 20), (RED, BLUE, GREEN, ORANGE))
    display.add.ruler(WHITE, "vertical", (220, 50), 200, 50, 10, True)

    display.show()

Example 4:

CODE
from lib.ScreenExtension import Screen, BLACK, PINK, YELLOW, RED, WHITE


if __name__ == '__main__':
    display = Screen(bg_color=BLACK)

    display.add.gauge(PINK, (120, 100), 75, (0, 100), 50)
    display.add.arrow(YELLOW, (150, 150), (100, 125))
    display.add.grid(RED, (20, 200), 50, 50, 5)
    display.add.clock(WHITE, (170, 220), 50, BLACK, 8, 0)

    display.show()

Example 5:

Here is an example of how to create a small animation without clear().

CODE
from micropython import const
from lib.ScreenExtension import Screen, WHITE, BLACK, BLUE
from time import sleep_ms


DELAY: int = const(25)


if __name__ == '__main__':
    display = Screen(bg_color=BLACK)

    start_y = 50
    max_y = 200
    step = 2
    direction = 1
    current_y = start_y

    display.add.ruler(WHITE, "vertical", (15, 10), 200, 50, 10, False)

    while True:
        clear_region = (30, current_y - 10, 50 - 30 + 10, 55 - 45 + 20)
        display.draw.rect(BLACK, clear_region)

        polygon_points = (
            (30, current_y),
            (35, current_y - 5),
            (50, current_y - 5),
            (50, current_y + 5),
            (35, current_y + 5),
        )
        display.draw.polygon(BLUE, polygon_points)

        display.show()

        current_y += step * direction
        if current_y >= max_y or current_y <= start_y:
            direction *= -1

        sleep_ms(DELAY)
STEP 3
Ideas and Annotations

Want to take it further? Here are some sparks to ignite your creativity:

- Implement bezier curves or splines for smooth, artistic lines
- Create rounded rectangles for softer UI elements
- Add gradient fills for circles, lines, or radial gradients
- Add an icon class to support for quick UI elements
- Increase performance with the options listed here convert to *.mpy

License
All Rights
Reserved
licensBg
0