From 2624a6e998f7b5a78a38fa98be15bf3d25ed81a9 Mon Sep 17 00:00:00 2001 From: kozova1 Date: Sat, 5 Dec 2020 20:27:55 +0200 Subject: [PATCH] Added basic theming engine. This theming engine uses a bytestring (but supports anything indexable, as long as the index results are a byte long), stored as `wasp.system._theme`. It has a default value, which should not change anything about the way this looks currently. The theme can be set via `wasp.system.set_theme`, but this should *ONLY* be used in `main.py`. `wasp.system.set_theme` will return True if it was successful, or False if the theme is of an old format. Using an old format theme will *not* crash the watch, but will use the default theme instead. To theme this, one has to use tools/themer.py (use flag -h for complete explanation) to generate a bytestring that's added in main.py (see diff). The bytestring is then loaded into 'wasp.system._theme'. Theme values can be looked up by apps by using `wasp.system.theme("theme-key")`. Theme keys appear in the function body of `wasp.system.theme()`. I've took the liberty of converting existing apps to use this method, and it seems to work well. A test theme is provided in `tools/test_theme.py` Signed-off-by: kozova1 --- tools/test_theme.py | 15 +++++++++ tools/themer.py | 81 +++++++++++++++++++++++++++++++++++++++++++++ wasp/apps/clock.py | 15 ++++++--- wasp/wasp.py | 29 +++++++++++++++- wasp/widgets.py | 45 +++++++++++++++---------- 5 files changed, 162 insertions(+), 23 deletions(-) create mode 100644 tools/test_theme.py create mode 100755 tools/themer.py diff --git a/tools/test_theme.py b/tools/test_theme.py new file mode 100644 index 0000000..dc2b990 --- /dev/null +++ b/tools/test_theme.py @@ -0,0 +1,15 @@ +from themer import DefaultTheme + +class Theme(DefaultTheme): + # These colors were chosen specifically because they're hard to miss. + # Using this theme on an actual device is not advised + # The default theme was generated by removing all the lines below and adding `pass` instead. + BLE_COLOR = 0xfb80 + SCROLL_INDICATOR_COLOR = 0xf800 + BATTERY_CHARGING_COLOR = 0x07ff + SMALL_CLOCK_COLOR = 0x599f + NOTIFICATION_COLOR = 0x8fe0 + ACCENT_MID = 0xf800 + ACCENT_LO = 0x001f + ACCENT_HI = 0x07e0 + SLIDER_DEFAULT_COLOR = 0x7777 diff --git a/tools/themer.py b/tools/themer.py new file mode 100755 index 0000000..819c35c --- /dev/null +++ b/tools/themer.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Compiles themes for wasp-os""" + +from argparse import ArgumentParser, RawTextHelpFormatter +from importlib import import_module +from typing import Tuple + +class DefaultTheme(): + """This represents the default theme. + + Import this file and extend the Theme class, only changing the variables. + Export the resulting class as 'Theme'. + serialize() should NEVER be overriden! + """ + BLE_COLOR = 0x7bef + SCROLL_INDICATOR_COLOR = 0x7bef + BATTERY_CHARGING_COLOR = 0x7bef + SMALL_CLOCK_COLOR = 0xe73c + NOTIFICATION_COLOR = 0x7bef + ACCENT_MID = 0xb5b6 + ACCENT_LO = 0xbdb6 + ACCENT_HI = 0xffff + SLIDER_DEFAULT_COLOR = 0x39ff + + def serialize(self) -> bytes: + """Serializes the theme for use in wasp-os""" + def split_bytes(x: int) -> Tuple[int, int]: + return (x & 0xFF, (x >> 8) & 0xFF) + theme_bytes = bytes([ + *split_bytes(self.BLE_COLOR), + *split_bytes(self.SCROLL_INDICATOR_COLOR), + *split_bytes(self.BATTERY_CHARGING_COLOR), + *split_bytes(self.SMALL_CLOCK_COLOR), + *split_bytes(self.NOTIFICATION_COLOR), + *split_bytes(self.ACCENT_MID), + *split_bytes(self.ACCENT_LO), + *split_bytes(self.ACCENT_HI), + *split_bytes(self.SLIDER_DEFAULT_COLOR), + ]) + return theme_bytes + + +if __name__ == '__main__': + parser = ArgumentParser( + description='''Compiles themes into a format understood by wasp-os. + The resulting string should be put in main.py like this: + + theme_string = THEME_STRING_GOES_HERE + + for the theme to take effect. + ''', + epilog=''' To create a theme, + import this file and extend the DefaultTheme class, only changing the variables. + Export the resulting class as 'Theme'. + Example: + -------- + theme.py: + from themer import DefaultTheme + + class Theme(DefaultTheme): + BLE_ICON_COLOR = 0x041F + + shell: + $ ./themer.py theme # NOTE: do not include .py at end of file! + > b'\xef{\xef{\xef{<\xe7\xef{\xb6\xb5\xb6\xbd\xff\xff\xff9' + + main.py: + ... + wasp.system.set_theme(b'\xef{\xef{\xef{<\xe7\xef{\xb6\xb5\xb6\xbd\xff\xff\xff9') + ... + ''', + formatter_class=RawTextHelpFormatter + ) + + parser.add_argument('input_file', type=str, nargs=1) + args = parser.parse_args() + + theme = DefaultTheme() + theme = import_module(args.input_file[0]).Theme() + print(theme.serialize()) + diff --git a/wasp/apps/clock.py b/wasp/apps/clock.py index 106e712..c062d3e 100644 --- a/wasp/apps/clock.py +++ b/wasp/apps/clock.py @@ -76,7 +76,8 @@ class ClockApp(): # Clear the display and draw that static parts of the watch face draw.fill() - draw.rleblit(digits.clock_colon, pos=(2*48, 80), fg=0xb5b6) + draw.rleblit(digits.clock_colon, pos=(2*48, 80), + fg=wasp.system.theme('accent-mid')) # Redraw the status bar wasp.system.bar.draw() @@ -95,10 +96,14 @@ class ClockApp(): month = MONTH[month*3:(month+1)*3] # Draw the changeable parts of the watch face - draw.rleblit(DIGITS[now[4] % 10], pos=(4*48, 80)) - draw.rleblit(DIGITS[now[4] // 10], pos=(3*48, 80), fg=0xbdb6) - draw.rleblit(DIGITS[now[3] % 10], pos=(1*48, 80)) - draw.rleblit(DIGITS[now[3] // 10], pos=(0*48, 80), fg=0xbdb6) + draw.rleblit(DIGITS[now[4] % 10], pos=(4*48, 80), + fg=wasp.system.theme('accent-hi')) + draw.rleblit(DIGITS[now[4] // 10], pos=(3*48, 80), + fg=wasp.system.theme('accent-lo')) + draw.rleblit(DIGITS[now[3] % 10], pos=(1*48, 80), + fg=wasp.system.theme('accent-hi')) + draw.rleblit(DIGITS[now[3] // 10], pos=(0*48, 80), + fg=wasp.system.theme('accent-lo')) draw.string('{} {} {}'.format(now[2], month, now[0]), 0, 180, width=240) diff --git a/wasp/wasp.py b/wasp/wasp.py index 7cf2883..4837e12 100644 --- a/wasp/wasp.py +++ b/wasp/wasp.py @@ -14,7 +14,6 @@ wasp.watch is an import of :py:mod:`watch` and is simply provided as a shortcut (and to reduce memory by keeping it out of other namespaces). """ - import gc import machine import micropython @@ -118,6 +117,8 @@ class Manager(): self.musicstate = {} self.musicinfo = {} + self._theme = b'\xef{\xef{\xef{<\xe7\xef{\xb6\xb5\xb6\xbd\xff\xff\xff9' + self.blank_after = 15 self._alarms = [] @@ -514,4 +515,30 @@ class Manager(): self._scheduling = enable + def set_theme(self, new_theme) -> bool: + """Sets the system theme. + + Accepts anything that supports indexing, + and has a len() equivalent to the default theme.""" + if len(self._theme) != len(new_theme): + return False + self._theme = new_theme + return True + + def theme(self, theme_part: str) -> int: + """Returns the relevant part of theme. For more see ../tools/themer.py""" + theme_parts = ("ble", + "scroll-indicator", + "battery-charging", + "status-clock", + "notify-icon", + "accent-mid", + "accent-lo", + "accent-hi", + "slider-default") + if theme_part not in theme_parts: + raise IndexError('Theme part {} does not exist'.format(theme_part)) + idx = theme_parts.index(theme_part) * 2 + return self._theme[idx] | (self._theme[idx+1] << 8) + system = Manager() diff --git a/wasp/widgets.py b/wasp/widgets.py index d5abd16..1d79e22 100644 --- a/wasp/widgets.py +++ b/wasp/widgets.py @@ -39,7 +39,8 @@ class BatteryMeter: if watch.battery.charging(): if self.level != -1: - draw.rleblit(icon, pos=(239-icon[0], 0), fg=0x7bef) + draw.rleblit(icon, pos=(239-icon[0], 0), + fg=wasp.system.theme('battery-charging')) self.level = -1 else: level = watch.battery.level() @@ -108,7 +109,7 @@ class Clock: draw = wasp.watch.drawable draw.set_font(fonts.sans28) - draw.set_color(0xe73c) + draw.set_color(wasp.system.theme('status-clock')) draw.string(t1, 52, 12, 138) self.on_screen = now @@ -137,13 +138,15 @@ class NotificationBar: (x, y) = self._pos if wasp.watch.connected(): - draw.blit(icons.blestatus, x, y, fg=0x7bef) + draw.blit(icons.blestatus, x, y, fg=wasp.system.theme('ble')) if wasp.system.notifications: - draw.blit(icons.notification, x+22, y, fg=0x7bef) + draw.blit(icons.notification, x+22, y, + fg=wasp.system.theme('notify-icon')) else: draw.fill(0, x+22, y, 30, 32) elif wasp.system.notifications: - draw.blit(icons.notification, x, y, fg=0x7bef) + draw.blit(icons.notification, x, y, + fg=wasp.system.theme('notify-icon')) draw.fill(0, x+30, y, 22, 32) else: draw.fill(0, x, y, 52, 32) @@ -206,10 +209,13 @@ class ScrollIndicator: def update(self): """Update from scrolling indicator.""" draw = watch.drawable + color = wasp.system.theme('scroll-indicator') + if self.up: - draw.rleblit(icons.up_arrow, pos=self._pos, fg=0x7bef) + draw.rleblit(icons.up_arrow, pos=self._pos, fg=color) if self.down: - draw.rleblit(icons.down_arrow, pos=(self._pos[0], self._pos[1] + 13), fg=0x7bef) + draw.rleblit(icons.down_arrow, pos=(self._pos[0], self._pos[1] + 13), + fg=color) _SLIDER_KNOB_DIAMETER = const(40) _SLIDER_KNOB_RADIUS = const(_SLIDER_KNOB_DIAMETER // 2) @@ -221,22 +227,14 @@ _SLIDER_TRACK_Y2 = const(_SLIDER_TRACK_Y1 + _SLIDER_TRACK_HEIGHT) class Slider(): """A slider to select values.""" - def __init__(self, steps, x=10, y=90, color=0x39ff): + def __init__(self, steps, x=10, y=90, color=None): self.value = 0 self._steps = steps self._stepsize = _SLIDER_TRACK / (steps-1) self._x = x self._y = y self._color = color - - # Automatically generate a lowlight color - if color < 0b10110_000000_00000: - color = (color | 0b10110_000000_00000) & 0b10110_111111_11111 - if (color & 0b111111_00000) < 0b101100_00000: - color = (color | 0b101100_00000) & 0b11111_101100_11111 - if (color & 0b11111) < 0b10110: - color = (color | 0b11000) & 0b11111_111111_10110 - self._lowlight = color + self._lowlight = None def draw(self): """Draw the slider.""" @@ -244,6 +242,19 @@ class Slider(): x = self._x y = self._y color = self._color + if self._color is None: + self._color = wasp.system.theme('slider-default') + color = self._color + if self._lowlight is None: + # Automatically generate a lowlight color + if color < 0b10110_000000_00000: + color = (color | 0b10110_000000_00000) & 0b10110_111111_11111 + if (color & 0b111111_00000) < 0b101100_00000: + color = (color | 0b101100_00000) & 0b11111_101100_11111 + if (color & 0b11111) < 0b10110: + color = (color | 0b11000) & 0b11111_111111_10110 + self._lowlight = color + color = self._color light = self._lowlight knob_x = x + ((_SLIDER_TRACK * self.value) // (self._steps-1))