diff --git a/python_payload/apps/gr33nhouse/__init__.py b/python_payload/apps/gr33nhouse/__init__.py index e3089becf3ff3b8363144b6966718e2377998e14..d5b24259fd9f468a4aac338e8002016587542014 100644 --- a/python_payload/apps/gr33nhouse/__init__.py +++ b/python_payload/apps/gr33nhouse/__init__.py @@ -34,30 +34,39 @@ class Gr33nhouseApp(Application): self._sc.set_item_count(3) self.acceptSdCard = False - self.state = ViewState.CONTENT + def on_enter(self, vm): + super().on_enter(vm) + self.update_state() + + def update_state(self): + if not self.acceptSdCard and sd_card_unreliable(): + self.state = ViewState.BAD_SDCARD + elif not st3m.wifi.is_connected(): + self.state = ViewState.NO_INTERNET + else: + self.state = ViewState.CONTENT def on_exit(self) -> bool: # request thinks after on_exit return True def draw(self, ctx: Context) -> None: + ctx.rgb(*colours.BLACK) + ctx.rectangle( + -120.0, + -120.0, + 240.0, + 240.0, + ).fill() + + ctx.rgb(*colours.WHITE) + ctx.font = "Camp Font 3" + ctx.font_size = 24 + ctx.text_align = ctx.CENTER + ctx.text_baseline = ctx.MIDDLE + if self.state == ViewState.BAD_SDCARD: - ctx.move_to(0, 0) - ctx.rgb(*colours.BLACK) - ctx.rectangle( - -120.0, - -120.0, - 240.0, - 240.0, - ).fill() - - ctx.save() - ctx.rgb(*colours.WHITE) - ctx.font = "Camp Font 3" ctx.font_size = 18 - ctx.text_align = ctx.CENTER - ctx.text_baseline = ctx.MIDDLE - ctx.move_to(0, -15) ctx.text("Unreliable SD card detected!") ctx.move_to(0, 5) @@ -70,25 +79,8 @@ class Gr33nhouseApp(Application): ctx.move_to(0, 55) ctx.text("continue anyway.") - ctx.restore() - return - if self.state == ViewState.NO_INTERNET: - ctx.move_to(0, 0) - ctx.rgb(*colours.BLACK) - ctx.rectangle( - -120.0, - -120.0, - 240.0, - 240.0, - ).fill() - - ctx.save() - ctx.rgb(*colours.WHITE) - ctx.font = "Camp Font 3" + elif self.state == ViewState.NO_INTERNET: ctx.font_size = 24 - ctx.text_align = ctx.CENTER - ctx.text_baseline = ctx.MIDDLE - ctx.move_to(0, -15) ctx.text("Connecting..." if st3m.wifi.is_connecting() else "No internet") @@ -99,84 +91,20 @@ class Gr33nhouseApp(Application): ctx.move_to(0, 55) ctx.text("enter Wi-Fi settings.") - ctx.restore() - return - - self.background.draw(ctx) - ctx.save() - - ctx.gray(1.0) - ctx.rectangle( - -120.0, - -15.0, - 240.0, - 30.0, - ).fill() - - ctx.translate(0, -30 * self._sc.current_position()) - - offset = 0 - - ctx.font = "Camp Font 3" - ctx.font_size = 24 - ctx.text_align = ctx.CENTER - ctx.text_baseline = ctx.MIDDLE - - for idx, item in enumerate(self.items): - if idx == self._sc.target_position(): - ctx.gray(0.0) - else: - ctx.gray(1.0) - - ctx.move_to(0, offset) - ctx.text(item) - offset += 30 - - ctx.restore() - def think(self, ins: InputState, delta_ms: int) -> None: super().think(ins, delta_ms) self._sc.think(ins, delta_ms) self.background.think(ins, delta_ms) - if not self.acceptSdCard: - if sd_card_unreliable(): - self.state = ViewState.BAD_SDCARD - else: - self.acceptSdCard = True - - if self.state == ViewState.BAD_SDCARD and self.input.buttons.app.middle.pressed: - self.state = ViewState.CONTENT - self.acceptSdCard = True - return - if self.state == ViewState.BAD_SDCARD: - return - - if not self.is_active(): - return - - if not st3m.wifi.is_connected(): - self.state = ViewState.NO_INTERNET + if self.input.buttons.app.middle.pressed: + self.state = ViewState.CONTENT + self.acceptSdCard = True + elif self.state == ViewState.NO_INTERNET: if self.input.buttons.app.middle.pressed: st3m.wifi.run_wifi_settings(self.vm) - return else: - self.state = ViewState.CONTENT + self.vm.replace(AppList()) - if self.input.buttons.app.left.pressed or self.input.buttons.app.left.repeated: - self._sc.scroll_left() - elif ( - self.input.buttons.app.right.pressed - or self.input.buttons.app.right.repeated - ): - self._sc.scroll_right() - elif self.input.buttons.app.middle.pressed: - pos = self._sc.target_position() - if pos == 0: - self.vm.push(AppList()) - elif pos == 1: - self.vm.push(RecordView()) - elif pos == 2: - self.vm.push(ManualInputView()) + self.update_state() diff --git a/python_payload/apps/gr33nhouse/applist.py b/python_payload/apps/gr33nhouse/applist.py index 688b48e9dcf5c84979ab27da019a6b54cbf05e4e..99788f1a55153426959a9795c666d4c9982c7fa9 100644 --- a/python_payload/apps/gr33nhouse/applist.py +++ b/python_payload/apps/gr33nhouse/applist.py @@ -7,8 +7,12 @@ from ctx import Context from math import sin import urequests import time +import os +import json from .background import Flow3rView from .confirmation import ConfirmationView +from .background import ColorTheme, broken_col, update_col, installed_col, color_themes +from .manual import ManualInputView class ViewState(Enum): @@ -18,19 +22,291 @@ class ViewState(Enum): LOADED = 4 +class AppDB: + class App: + class Version: + def __init__(self, version, url_dict=None): + # None indicates unknown value for most fields + + # version of the app as in its flow3r.toml + self.version = version + # urls + if url_dict is not None: + self.zip_url = url_dict["downloadUrl"] + self.tar_url = url_dict["tarDownloadUrl"] + # if this version is installed + self.installed = None + # if this version is considered an update + self.update = None + + # this version of the app was tested by flow3r team + self.tested = False + # which version was tested + self.tested_version = None + # test result: is app broken? + self.broken = None + + # true if app is patch by flow3r team + self.patch = None + # which original version this patch forks + self.patch_base_version = None + + def __init__(self, raw_app): + self.raw_app = raw_app + self.processed = False + + def process(self, toml_cache): + if self.processed: + return + raw_app = self.raw_app + self.category = raw_app.get("menu") + for field in ["name", "author", "description", "stars", "featured"]: + setattr(self, field, raw_app.get(field)) + slug_bits = raw_app.get("repoUrl").split("/") + self.slug = "-".join([slug_bits[-2], slug_bits[-1].lower()]) + + # get latest original version + orig = self.Version(self.raw_app["version"], url_dict=self.raw_app) + self.available_versions = [orig] + self.orig = orig + + # check what version is installed + installed = None + self.installed_version = None + self.installed = False + self.installed_path = None + for app_dir in ["/sd/apps/", "/sys/flash/apps/"]: + path = app_dir + self.slug + app_installed = toml_cache.get(path) + if not app_installed: + continue + if not os.path.exists(path): + print(f"app in database but not in filesystem: {path}") + continue + try: + installed = self.Version(app_installed["metadata"]["version"]) + except: + print("parsing installed version in toml.cache failed") + installed.patch = "patch_source" in app_installed + self.installed_path = path + self.installed_version = installed + self.installed = True + break + + # check for flow3r team status flags/patched version + patch = None + self.broken = None + if (status := raw_app.get("status")) is not None: + if (tested_version := status.get("tested_version")) is not None: + orig.tested_version = tested_version + orig.tested = orig.tested_version == orig.version + if (broken := status.get("broken")) is not None: + # if we specify a tested version, a new version shouldn't + # be flagged as broken. + if orig.tested_version is None or orig.tested: + orig.broken = broken + if (app_patch := status.get("patch")) is not None: + version = app_patch.get("version") + patch = self.Version(version, url_dict=app_patch) + patch.patch = True + patch.patch_base_version = orig.tested_version + # order of list is display order, so if the original + # isn't broken we wanna recommend it frist, else + # we default to the patched version + self.broken = False + if orig.broken: + self.available_versions.insert(0, patch) + else: + self.available_versions.append(patch) + else: + self.broken = orig.broken + self.patch = patch + + # check for style + self.colors = ColorTheme.from_style(raw_app["style"], self.category) + + # check if updates are available + self.processed = True + + self._update_versions() + + def _update_versions(self): + installed = self.installed_version + patch = self.patch + orig = self.orig + + orig.installed = False + orig.update = False + if patch: + patch.installed = False + patch.update = False + + if not installed: + self.installed = False + self.update_available = None + return + + if patch is None: + orig.installed = orig.version == installed.version + if orig.version > installed.version: + self.update_available = True + else: + if installed.patch: + orig.installed = False + patch.installed = patch.version == installed.version + + # option 1: new version of original came out + orig.update = orig.version > patch.patch_base_version + # option 2: new version of patch came out + patch.update = patch.version > installed.version + else: + orig.installed = orig.version == installed.version + patch.installed = False + + # option 1: new version of original came out + orig.update = orig.version > installed.version + # option 2: patch for installed version came out + patch.update = patch.patch_base_version == installed.version + + self.installed = True + self.update_available = any( + [version.update for version in self.available_versions] + ) + + def update_installed_version(self, version): + if False: + print(f"updating {self.slug}:") + print(f" previous at {self.installed_path}:") + for v in self.available_versions: + print( + f' {"patch" if v.patch else "orig"} v{v.version} {"installed" if v.installed else ""}' + ) + if self.installed_version is not None: + v = self.installed_version + print( + f' installed: {"patch" if v.patch else "orig"} v{v.version}' + ) + self.installed_version = version + self._update_versions() + if False: + print(f" now at {self.installed_path}:") + for v in self.available_versions: + print( + f' {"patch" if v.patch else "orig"} v{v.version} {"installed" if v.installed else ""}' + ) + if self.installed_version is not None: + v = self.installed_version + print( + f' installed: {"patch" if v.patch else "orig"} v{v.version}' + ) + + class Category: + def __init__(self, name, db): + self.name = name + self._db = db + self.apps = [] + + def add_app(self, app): + if self.name != app.category: + print("app seems to be in wrong category") + self.apps.append(app) + + def scan_all(self): + db = self._db + for x in range(db._process_index, len(db._raw_apps)): + raw_app = db._unprocessed_apps[x] + if raw_app.get("menu") == self.name: + db.add_raw_app(raw_app) + + db.applist_sort(self.apps) + + @staticmethod + def applist_sort_key(app): + if app.update_available: + return -1 + elif app.installed: + return 1 + return 0 + + @classmethod + def applist_sort(cls, apps): + apps.sort(key=cls.applist_sort_key) + + def __init__(self, raw_apps): + self._raw_apps = raw_apps + self._process_index = 0 + self.apps = [] + try: + with open("/sd/apps/toml_cache.json") as f: + self._toml_cache = json.load(f) + except: + self._toml_cache = {} + self._done = False + self.categories = {} + + def scan_incremental(self, increment=8): + if self._done: + return + while True: + if self._process_index >= len(self._raw_apps): + self.applist_sort(self.apps) + self._done = True + print("Database finished") + return + raw_app = self._raw_apps[self._process_index] + self._process_index += 1 + self.add_raw_app(raw_app) + if increment is not None: + increment -= 1 + if not increment: + break + + def scan_all_category_names(self): + for x in range(self._process_index, len(self._raw_apps)): + raw_app = self._raw_apps[x] + if (cat := raw_app.get("menu")) not in self.categories: + self.categories[cat] = None + + def scan_all(self): + self.scan_incremental(None) + + def add_raw_app(self, raw_app): + app = self.App(raw_app) + app.process(self._toml_cache) + self.apps.append(app) + + # write into category list + cat = app.category + # may exist as key but contain None, see scan_all_categories + if (category := self.categories.get(cat)) is None: + category = self.Category(cat, self) + self.categories[cat] = category + category.add_app(app) + + class AppSubList(BaseView): _scroll_pos: float = 0.0 - apps: list[Any] = [] - background: Flow3rView - def __init__(self, apps) -> None: + def __init__(self, apps, colors, hide_tags=tuple(), app_filter=None) -> None: super().__init__() - self.background = Flow3rView() + self.background = Flow3rView(colors) + self.colors = colors self._sc = ScrollController() - self.apps = apps - self._sc.set_item_count(len(self.apps)) + self.hide_tags = tuple(hide_tags) + self.app_filter = app_filter + if self.app_filter: + self.unfiltered_apps = apps + else: + self.apps = apps + self._sc.set_item_count(len(apps)) + + def on_enter(self, vm): + super().on_enter(vm) + if self.app_filter: + self.apps = [x for x in self.unfiltered_apps if self.app_filter(x)] + self._sc.set_item_count(len(self.apps)) def on_exit(self) -> bool: # request thinks after on_exit @@ -42,7 +318,7 @@ class AppSubList(BaseView): self.background.draw(ctx) ctx.save() - ctx.gray(1.0) + ctx.rgb(*self.colors.text_bg) ctx.rectangle( -120.0, -15.0, @@ -63,17 +339,43 @@ class AppSubList(BaseView): for idx, app in enumerate(self.apps): target = idx == self._sc.target_position() if target: - ctx.gray(0.0) + ctx.rgb(*self.colors.text_fg) else: - ctx.gray(1.0) + ctx.rgb(*self.colors.fg) if abs(self._sc.current_position() - idx) <= 5: xpos = 0.0 - if target and (width := ctx.text_width(app["name"])) > 220: + if target and (width := ctx.text_width(app.name)) > 220: xpos = sin(self._scroll_pos) * (width - 220) / 2 ctx.move_to(xpos, offset) - ctx.text(app["name"]) + ctx.text(app.name) + + if target: + text = None + col = (0, 0, 0) + if app.update_available: + col = update_col + text = "update" + elif app.installed: + col = installed_col + text = "installed" + elif app.broken: + col = broken_col + text = "broken" + + if text and not (text in self.hide_tags): + ctx.save() + ctx.rgb(*col) + ctx.font_size = 16 + ctx.text_align = ctx.LEFT + ctx.rel_move_to(0, -5) + ctx.text(text) + ctx.restore() + offset += 30 + if not self.apps: + ctx.rgb(*self.colors.text_fg) + ctx.text("(empty)") ctx.restore() @@ -99,18 +401,9 @@ class AppSubList(BaseView): elif self.input.buttons.app.middle.pressed: if self.vm is None: raise RuntimeError("vm is None") - - app = self.apps[self._sc.target_position()] - url = app["tarDownloadUrl"] - name = app["name"] - author = app["author"] - self.vm.push( - ConfirmationView( - url=url, - name=name, - author=author, - ) - ) + if self.apps: + app = self.apps[self._sc.target_position()] + self.vm.push(ConfirmationView(app)) class AppList(BaseView): @@ -118,15 +411,16 @@ class AppList(BaseView): _state: ViewState = ViewState.INITIAL items: list[Any] = ["All"] - category_order: list[Any] = ["Badge", "Music", "Media", "Apps", "Games", "Demos"] + category_order: list[Any] = ["Badge", "Music", "Games", "Media", "Apps", "Demos"] background: Flow3rView def __init__(self) -> None: super().__init__() - self.background = Flow3rView() + self.background = Flow3rView(ColorTheme.get("SolidBlack")) self._sc = ScrollController() self._sc.set_item_count(len(self.items)) + self.category_prev = "" def draw(self, ctx): ctx.move_to(0, 0) @@ -223,16 +517,18 @@ class AppList(BaseView): self._state = ViewState.LOADING print("Loading app list...") res = urequests.get("https://flow3r.garden/api/apps.json") - self.apps = res.json()["apps"] - - if self.apps == None: + raw_apps = res.json()["apps"] + if raw_apps == None: print(f"Invalid JSON or no apps: {res.json()}") self._state = ViewState.ERROR return - categories = [app.get("menu") for app in self.apps] - categories = [c for c in categories if c and isinstance(c, str)] - categories = list(set(categories)) + print("Initializing database...") + self.db = AppDB(raw_apps) + self.apps = self.db.apps + + self.db.scan_all_category_names() + categories = list(self.db.categories.keys()) def sortkey(obj): try: @@ -241,11 +537,11 @@ class AppList(BaseView): return len(self.category_order) categories.sort(key=sortkey) - self.items = ["All"] + categories + print("Found categories:", categories) + self.items = ["All"] + categories + ["Updates", "Installed", "Seeds"] self._sc.set_item_count(len(self.items)) self._state = ViewState.LOADED - print("App list loaded") except Exception as e: print(f"Load failed: {e}") self._state = ViewState.ERROR @@ -261,6 +557,8 @@ class AppList(BaseView): if not self.is_active(): return + self.db.scan_incremental() + if self.input.buttons.app.left.pressed or self.input.buttons.app.left.repeated: self._sc.scroll_left() self._scroll_pos = 0.0 @@ -273,9 +571,37 @@ class AppList(BaseView): elif self.input.buttons.app.middle.pressed: if self.vm is None: raise RuntimeError("vm is None") - if self._sc.target_position(): - category = self.items[self._sc.target_position()] - apps = [app for app in self.apps if app.get("menu") == category] + category = self.items[self._sc.target_position()] + colors = ColorTheme.get(category) + hide_tags = [] + apps = None + app_filter = None + if category == "All": + self.db.scan_all() + apps = self.apps + elif category == "Updates": + self.db.scan_all() + app_filter = lambda x: x.update_available + apps = self.apps + elif category == "Installed": + self.db.scan_all() + app_filter = lambda x: x.installed + apps = self.apps + hide_tags = ["installed"] + elif category == "Featured": + self.db.scan_all() + app_filter = lambda x: x.featured + apps = self.apps + elif category == "Seeds": + self.vm.push(ManualInputView(colors=colors)) else: - apps = list(self.apps) - self.vm.push(AppSubList(apps=apps)) + category = self.db.categories[category] + category.scan_all() + apps = category.apps + if apps is not None: + self.vm.push(AppSubList(apps, colors, hide_tags, app_filter)) + + category = self.items[self._sc.target_position()] + if category != self.category_prev: + self.category_prev = category + self.background.update_colors(ColorTheme.get(category)) diff --git a/python_payload/apps/gr33nhouse/background.py b/python_payload/apps/gr33nhouse/background.py index f3a58605ca63e73f344f9f1a0302af96caa1ded7..87c9c01142056dc38d51cc40219a719470ba9c0a 100644 --- a/python_payload/apps/gr33nhouse/background.py +++ b/python_payload/apps/gr33nhouse/background.py @@ -4,34 +4,167 @@ from st3m.input import InputState from st3m.ui.view import BaseView from ctx import Context +seed_cols = [ + (0, 0, 1), + (0, 1, 1), + (1, 1, 0), + (0, 1, 0), + (1, 0, 1), +] + +broken_col = (0, 0.4, 1) +update_col = (0.9, 0.7, 0) +installed_col = (0.5, 0.5, 0.5) + + +def color_blend(lo, hi, blend): + if blend >= 1: + return hi + elif blend <= 0: + return lo + num_x = 3 + if len(lo) > 3 or len(hi) > 3: + lo = list(lo) + [1] + hi = list(hi) + [1] + num_x = 4 + return tuple([lo[x] * (1 - blend) + hi[x] * blend for x in range(num_x)]) + + +class FlowerTheme: + def __init__(self, fg, solid=False): + self.fg = fg + self.solid = solid + + +class ColorTheme: + def __init__(self, bg, flowers, num_stars=8): + self.bg = bg + self.fg = (1, 1, 1) + self.text_bg = (1, 1, 1) + self.text_fg = (0, 0, 0) + self.flowers = flowers + self.num_stars = num_stars + + @classmethod + def from_style(cls, style, category): + def color_from_style(color): + try: + ret = [] + if color.startswith("rgb"): + ret = color[color.find("(") + 1 : color.find(")")].split(",") + ret = [float(x) for x in ret] + if color.startswith("#"): + ret = [ + int(color[x : x + 2], 16) / 255 for x in range(1, len(color), 2) + ] + if 2 < len(ret) < 5: + return tuple(ret) + except: + pass + + fallback = cls.get(category) + if style: + bg = color_from_style(style.get("background")) + color = color_from_style(style.get("color")) + else: + bg = None + color = None + if bg is None: + bg = fallback.bg + if color is None: + color = fallback.flowers[0].fg + flower_colors = [FlowerTheme(color)] + return cls(bg, flower_colors) + + @classmethod + def get(cls, key): + if key in color_themes: + return color_themes[key] + else: + return default_color_theme + + +color_themes = { + "Classic": ColorTheme((0.1, 0.4, 0.3), [FlowerTheme((1.0, 0.6, 0.4, 0.4), True)]), + "SolidBlack": ColorTheme((0, 0, 0), [FlowerTheme((0, 0, 0), True)]), + "Updates": ColorTheme((0.9, 0.8, 0), [FlowerTheme((0.9, 0.6, 0))]), + "Installed": ColorTheme(installed_col, [FlowerTheme((0, 0.8, 0.8))]), + "Music": ColorTheme((1, 0.4, 0.7), [FlowerTheme((1.0, 0.8, 0))]), + "Games": ColorTheme((0.9, 0.5, 0.1), [FlowerTheme((0.5, 0.3, 0.1))]), + "Badge": ColorTheme((0.3, 0.4, 0.7), [FlowerTheme((0, 0.8, 0.8))]), + "Apps": ColorTheme((0.7, 0, 0.7), [FlowerTheme((0, 0, 0))]), + "Demos": ColorTheme((0, 0, 0), [FlowerTheme((0.5, 0.5, 0.5))]), + "Media": ColorTheme((0, 0, 0), [FlowerTheme((0.5, 0.5, 0.5))]), + "All": ColorTheme((0.18, 0.81, 0.36), [FlowerTheme((0, 0.3, 0))]), + "Seeds": ColorTheme( + (0.1, 0.1, 0.1), [FlowerTheme([x * 0.7 for x in col]) for col in seed_cols] + ), + "Featured": ColorTheme( + (0.2, 0.0, 0.3), + [FlowerTheme((0.9, 0.6, 0)), FlowerTheme((1.0, 0.4, 0.7), True)], + ), +} + +default_color_theme = color_themes["Classic"] + class Flow3rView(BaseView): - def __init__(self) -> None: + def __init__(self, colors=None) -> None: super().__init__() + self.colors = default_color_theme if colors is None else colors self.flowers = [] - for i in range(8): - self.flowers.append( - Flower( - ((random.getrandbits(16) - 32767) / 32767.0) * 200, - ((random.getrandbits(16)) / 65535.0) * 240 - 120, - ((random.getrandbits(16)) / 65535.0) * 400 + 25, - ) + for i in range(self.colors.num_stars): + flower_color = random.choice(self.colors.flowers) + flower = Flower( + ((random.getrandbits(16) - 32767) / 32767.0) * 200, + ((random.getrandbits(16)) / 65535.0) * 240 - 120, + ((random.getrandbits(16)) / 65535.0) * 400 + 25, + colors=flower_color, ) + flower.fg_col_prev = flower.fg_col + flower.fg_col_target = flower.fg_col + self.flowers.append(flower) + self.bg_col = self.colors.bg + self.bg_col_prev = self.bg_col + self.bg_col_target = self.bg_col + self.color_blend = None + + def update_colors(self, colors): + self.colors = colors + self.bg_col_prev = self.bg_col + self.bg_col_target = self.colors.bg + for f in self.flowers: + flower_color = random.choice(self.colors.flowers) + f.fg_col_prev = f.fg_col + f.fg_col_target = flower_color.fg + self.color_blend = 0 def think(self, ins: InputState, delta_ms: int) -> None: super().think(ins, delta_ms) - for c in self.flowers: - c.y += (10 * delta_ms / 1000.0) * 200 / c.z - if c.y > 300: - c.y = -300 - c.rot += float(delta_ms) * c.rot_speed - self.flowers = sorted(self.flowers, key=lambda c: -c.z) + for f in self.flowers: + f.y += (10 * delta_ms / 1000.0) * 200 / f.z + if f.y > 300: + f.y = -300 + f.rot += float(delta_ms) * f.rot_speed + self.flowers = sorted(self.flowers, key=lambda f: -f.z) + + if self.color_blend is not None: + self.color_blend += delta_ms / 160 def draw(self, ctx: Context) -> None: ctx.save() ctx.rectangle(-120, -120, 240, 240) - ctx.rgb(0.1, 0.4, 0.3) + if self.color_blend is not None: + self.bg_col = color_blend( + self.bg_col_prev, self.bg_col_target, self.color_blend + ) + for f in self.flowers: + f.fg_col = color_blend(f.fg_col_prev, f.fg_col_target, self.color_blend) + if self.color_blend >= 1: + self.color_blend = None + + ctx.rgb(*self.bg_col) ctx.fill() for f in self.flowers: @@ -40,7 +173,12 @@ class Flow3rView(BaseView): class Flower: - def __init__(self, x: float, y: float, z: float) -> None: + def __init__(self, x: float, y: float, z: float, colors=None) -> None: + self.fg_col = (1.0, 0.6, 0.4, 0.4) + self.solid = True + if colors is not None: + self.fg_col = colors.fg + self.solid = colors.solid self.x = x self.y = y self.z = z @@ -49,12 +187,18 @@ class Flower: def draw(self, ctx: Context) -> None: ctx.save() + ctx.line_width = 4 ctx.translate(-78 + self.x, -70 + self.y) ctx.translate(50, 40) - ctx.rotate(self.rot) ctx.translate(-50, -40) ctx.scale(100 / self.z, 100.0 / self.z) + + if len(self.fg_col) == 4: + ctx.rgba(*self.fg_col) + else: + ctx.rgb(*self.fg_col) + ctx.move_to(76.221727, 3.9788409).curve_to( 94.027758, 31.627675, 91.038918, 37.561293, 94.653428, 48.340473 ).rel_curve_to( @@ -75,32 +219,11 @@ class Flower: 27.471602, 45.126773, 38.877997, 45.9184, 56.349456, 48.518302 ).curve_to( 59.03275, 31.351935, 64.893201, 16.103886, 76.221727, 3.9788409 - ).close_path().rgba( - 1.0, 0.6, 0.4, 0.4 - ).fill() - ctx.restore() - return - ctx.move_to(116.89842, 17.221179).rel_curve_to( - 6.77406, 15.003357, 9.99904, 35.088466, 0.27033, 47.639569 - ).curve_to( - 108.38621, 76.191194, 87.783414, 86.487988, 75.460015, 75.348373 - ).curve_to( - 64.051094, 64.686361, 61.318767, 54.582827, 67.499384, 36.894251 - ).curve_to( - 79.03955, 16.606134, 103.60918, 15.612261, 116.89842, 17.221179 - ).close_path().rgb( - 0.5, 0.3, 0.4 - ).fill() + ).close_path() + + if self.solid: + ctx.fill() + else: + ctx.stroke() - ctx.move_to(75.608612, 4.2453713).curve_to( - 85.516707, 17.987709, 93.630911, 33.119248, 94.486497, 49.201225 - ).curve_to( - 95.068862, 60.147617, 85.880014, 75.820834, 74.919761, 75.632395 - ).curve_to( - 63.886159, 75.442695, 57.545631, 61.257211, 57.434286, 50.22254 - ).curve_to( - 57.257291, 32.681814, 65.992688, 16.610811, 75.608612, 4.2453713 - ).close_path().rgb( - 0.2, 0.5, 0.8 - ).fill() ctx.restore() diff --git a/python_payload/apps/gr33nhouse/confirmation.py b/python_payload/apps/gr33nhouse/confirmation.py index 574fe85eb55c2cd32751c77dea0514dc5d70e188..acc221b45867eac932f41aeb7e8949de54ae4421 100644 --- a/python_payload/apps/gr33nhouse/confirmation.py +++ b/python_payload/apps/gr33nhouse/confirmation.py @@ -3,23 +3,185 @@ from st3m.ui import colours from st3m.ui.view import BaseView, ViewManager from ctx import Context from .background import Flow3rView +from .background import broken_col, update_col, installed_col from .download import DownloadView +from .delete import DeleteView +from st3m.utils import wrap_text +import math + + +class Button: + def __init__(self, pos, size): + self.len = list(size) + self.mid = list(pos) + self.min = [pos[x] - size[x] / 2 for x in range(2)] + self.shift = 0 + + self.top_tags = list() + self.bot_tags = list() + self.text = "back" + + def draw(self, ctx, highlight=False): + ctx.font_size = 18 + ctx.gray(1) + if not highlight: + ctx.move_to(*self.mid) + ctx.text(self.text) + return + + ctx.rectangle(*self.min, *self.len).fill() + ctx.fill() + + ctx.save() + ctx.gray(0) + ctx.move_to(self.shift + self.mid[0], self.mid[1]) + ctx.text_align = ctx.LEFT + ctx.text(self.text) + ctx.font_size = 14 + ctx.rel_move_to(0, -6) + x_start = ctx.x + for text, col in self.top_tags: + ctx.rgb(*col) + ctx.text(" " + text) + x_stop = ctx.x + ctx.rel_move_to(x_start - x_stop, 12) + for text, col in self.bot_tags: + ctx.rgb(*col) + ctx.text(" " + text) + x_stop = max(x_stop, ctx.x) + ctx.restore() + + if abs(x_asym := self.shift + x_stop) > 0.1: + self.shift -= x_asym / 2 + self.draw(ctx, True) + + +class InstallButton(Button): + def __init__(self, pos, size, version): + super().__init__(pos, size) + + self.version = version + self.text = "install" + if version.patch: + self.text += " patch" + tags = self.top_tags + if version.broken: + tags.append(("broken", broken_col)) + tags = self.bot_tags + if version.update: + tags.append(("update", update_col)) + tags = self.bot_tags + if version.installed: + tags.append(("installed", installed_col)) + tags = self.bot_tags + + +class DeleteButton(Button): + def __init__(self, pos, size): + super().__init__(pos, size) + self.text = "delete" + + +class ScrollBlock: + def __init__(self, raw_text): + self.num_lines = 2 + self.line_height = 18 + self.start = -87 + self.width = 175 + self.grad_len = 0 + self.end = self.start + self.num_lines * self.line_height + + self.raw_text = raw_text + self.speed = 0 + self.pos = self.start + self.line_height / 2 + self.grad = self.grad_len / (self.end - self.start + 2 * self.grad_len) + self.clip_min = self.start - self.grad_len + self.clip_max = self.end + self.grad_len + + def get_lines(self, ctx): + self.lines = wrap_text(self.raw_text, self.width, ctx) + if not self.lines: + return + + if len(self.lines) > self.num_lines: + self.speed = 1 / 160 + self.lines.append("") + else: + self.pos += (self.num_lines - len(self.lines)) * self.line_height / 2 + + self.overflow_pos = len(self.lines) * self.line_height + + def draw(self, ctx): + if not hasattr(self, "lines"): + self.get_lines(ctx) + if not self.lines: + return + + ctx.save() + ctx.rectangle( + -self.width / 2, self.clip_min, self.width, self.clip_max - self.clip_min + ).clip() + if not self.speed: + for x, line in enumerate(self.lines): + pos = x * self.line_height + self.pos + ctx.move_to(0, pos) + ctx.text(line) + else: + self.pos %= self.overflow_pos + x = int( + (self.clip_min - self.line_height / 2 - self.pos) // self.line_height + ) + while True: + line = self.lines[x % len(self.lines)] + pos = x * self.line_height + self.pos + if pos > self.clip_max + self.line_height / 2: + break + if pos > self.end - self.line_height / 2: + ctx.linear_gradient(0, self.clip_min, 0, self.clip_max) + ctx.add_stop(0, (1.0, 1.0, 1.0), 1) + ctx.add_stop(1 - self.grad, (1.0, 1.0, 1.0), 1) + ctx.add_stop(1, (1.0, 1.0, 1.0), 0) + elif pos < self.start + self.line_height / 2: + ctx.linear_gradient(0, self.clip_min, 0, self.clip_max) + ctx.add_stop(0, (1.0, 1.0, 1.0), 0) + ctx.add_stop(self.grad, (1.0, 1.0, 1.0), 1) + ctx.add_stop(1, (1.0, 1.0, 1.0), 1) + else: + ctx.gray(1) + ctx.move_to(0, pos) + ctx.text(line) + x += 1 + ctx.restore() + + def think(self, ins, delta_ms): + self.pos -= delta_ms * self.speed class ConfirmationView(BaseView): background: Flow3rView - url: str - name: str - author: str - - def __init__(self, url: str, name: str, author: str) -> None: + def __init__(self, app) -> None: super().__init__() - self.background = Flow3rView() - - self.url = url - self.name = name - self.author = author + self.background = Flow3rView(app.colors) + + self.app = app + + def on_enter(self, vm): + super().on_enter(vm) + self.buttons = [] + self.button_index = 0 + x = 0 + size = [240, 25] + for version in self.app.available_versions: + pos = [0, 48 + 25 * x] + self.buttons.append(InstallButton(pos, size, version)) + x += 1 + if self.app.installed: + pos = [0, 48 + 25 * x] + self.buttons.append(DeleteButton(pos, size)) + x += 1 + + self.desc = ScrollBlock(self.app.description) def on_exit(self) -> bool: # request thinks after on_exit @@ -30,50 +192,61 @@ class ConfirmationView(BaseView): self.background.draw(ctx) - ctx.save() + ctx.text_align = ctx.CENTER + ctx.text_baseline = ctx.MIDDLE + + ctx.font_size = 17 + + self.desc.draw(ctx) + ctx.rgb(*colours.WHITE) ctx.rectangle( -120.0, - -80.0, + -35.0, 240.0, - 160.0, + 40.0, ).fill() - ctx.rgb(*colours.BLACK) ctx.font = "Camp Font 3" - ctx.text_align = ctx.CENTER - ctx.text_baseline = ctx.MIDDLE - - ctx.font_size = 16 - ctx.move_to(0, -60) - ctx.text("Install") - + app = self.app + ctx.rgb(*colours.BLACK) ctx.font_size = 24 - ctx.move_to(0, -30) - ctx.text(self.name) - - if self.author: - ctx.font_size = 16 - ctx.move_to(0, 0) - ctx.text("by") + ctx.move_to(0, -15) + ctx.text(app.name) - ctx.font_size = 24 - ctx.move_to(0, 30) - ctx.text(self.author) + if app.author: + ctx.font_size = 17 + ctx.gray(1) + ctx.move_to(0, 21) + ctx.text("by " + app.author) - ctx.font_size = 16 - ctx.move_to(0, 60) - ctx.text("(OS shoulder to abort)") - - ctx.restore() + for x, button in enumerate(self.buttons): + button.draw(ctx, highlight=x == self.button_index) def think(self, ins: InputState, delta_ms: int) -> None: super().think(ins, delta_ms) self.background.think(ins, delta_ms) - - if self.is_active() and self.input.buttons.app.middle.pressed: - self.vm.replace( - DownloadView( - url=self.url, - ) - ) + self.desc.think(ins, delta_ms) + + if self.is_active(): + self.button_index += self.input.buttons.app.right.pressed + self.button_index -= self.input.buttons.app.left.pressed + self.button_index %= len(self.buttons) + if self.input.buttons.app.middle.pressed: + button = self.buttons[self.button_index] + if isinstance(button, InstallButton): + print("Installing", self.app.name, "from", button.version.tar_url) + self.vm.push( + DownloadView( + self.app, + button.version, + ) + ) + elif isinstance(button, DeleteButton): + self.vm.push( + DeleteView( + self.app, + ) + ) + else: + self.vm.pop() diff --git a/python_payload/apps/gr33nhouse/delete.py b/python_payload/apps/gr33nhouse/delete.py new file mode 100644 index 0000000000000000000000000000000000000000..8b5348c6f0f7088d200bbdd95a6beb914ddf2896 --- /dev/null +++ b/python_payload/apps/gr33nhouse/delete.py @@ -0,0 +1,55 @@ +from st3m.input import InputState +from st3m.goose import Optional, List +from st3m.ui import colours +from st3m.utils import sd_card_plugged +from st3m.ui.view import BaseView +from ctx import Context +import os + +from st3m import application_settings + + +class DeleteView(BaseView): + def __init__(self, app) -> None: + super().__init__() + self._app = app + self._delete = 0 + + def draw(self, ctx: Context) -> None: + ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill() + + ctx.gray(1) + ctx.move_to(0, 0) + ctx.font = "Camp Font 3" + ctx.text_align = ctx.CENTER + ctx.text_baseline = ctx.MIDDLE + ctx.font_size = 24 + ctx.text(self._app.name) + + ctx.move_to(0, -40) + + ctx.font_size = 20 + ctx.text("Delete app?") + + for x in range(2): + x = 1 - x + ctx.translate(0, 35) + ctx.move_to(0, 0) + ctx.gray(1) + if x == self._delete: + ctx.rectangle(-120, -15, 240, 30).fill() + ctx.gray(0) + ctx.text("yes" if x else "no") + + def think(self, ins: InputState, delta_ms: int) -> None: + super().think(ins, delta_ms) # Let BaseView do its thing + + self._delete += self.input.buttons.app.right.pressed + self._delete -= self.input.buttons.app.left.pressed + self._delete %= 2 + + if self.input.buttons.app.middle.pressed: + if self._delete: + application_settings.delete_app(self._app.installed_path) + self._app.update_installed_version(None) + self.vm.pop() diff --git a/python_payload/apps/gr33nhouse/download.py b/python_payload/apps/gr33nhouse/download.py index 1d06ac630977d87aefca73c630b0ccbbaa582585..c2711bbf4584bc66e6ab906c6abdc4aea57356bc 100644 --- a/python_payload/apps/gr33nhouse/download.py +++ b/python_payload/apps/gr33nhouse/download.py @@ -11,6 +11,7 @@ import gc import math from st3m.ui.view import BaseView from ctx import Context +from st3m import application_settings class DownloadView(BaseView): @@ -30,11 +31,13 @@ class DownloadView(BaseView): """ _state: int - def __init__(self, url: str) -> None: + def __init__(self, app, version) -> None: super().__init__() self._state = 1 self._try = 1 - self._url = url + self._app = app + self._version = version + self._url = version.tar_url self.response = b"" self._download_instance = None self.download_percentage = 0 @@ -197,6 +200,11 @@ class DownloadView(BaseView): print(f"making {app_folder}") os.mkdir(app_folder) + installed_path = app_folder + self._app.slug + if os.path.exists(installed_path): + print("removing old files at", installed_path) + application_settings.delete_app(installed_path) + for i in t: print(i.name) @@ -216,4 +224,8 @@ class DownloadView(BaseView): while data := f.read(): of.write(data) + self._app.installed_path = installed_path + self._app.update_installed_version(self._version) + print("installed at", installed_path) + self._state = 5 diff --git a/python_payload/apps/gr33nhouse/manual.py b/python_payload/apps/gr33nhouse/manual.py index 44e9c84f95a2418290a69cb6e0bfa9ffd828fd3d..0d1e05065a053365a10e92ef8a0b4aea83adac54 100644 --- a/python_payload/apps/gr33nhouse/manual.py +++ b/python_payload/apps/gr33nhouse/manual.py @@ -5,19 +5,13 @@ from st3m.ui.view import BaseView, ViewManager from ctx import Context from .confirmation import ConfirmationView from .background import Flow3rView +from .background import seed_cols as PETAL_COLORS import math import urequests import time import gc -PETAL_COLORS = [ - (0, 0, 1), - (0, 1, 1), - (1, 1, 0), - (0, 1, 0), - (1, 0, 1), -] PETAL_MAP = [0, 2, 4, 6, 8] ONE_FIFTH = math.pi * 2 / 5 ONE_TENTH = math.pi * 2 / 10 @@ -33,9 +27,9 @@ class ManualInputView(BaseView): current_petal: Optional[int] wait_timer: Optional[int] - def __init__(self) -> None: + def __init__(self, colors=None) -> None: super().__init__() - self.background = Flow3rView() + self.background = Flow3rView(colors) self.flow3r_seed = "" self.current_petal = None diff --git a/python_payload/st3m/application_settings.py b/python_payload/st3m/application_settings.py index ed65cb6d4d4aa1a83d5b3374186389bbc4666287..2b478ee4eb15508228457780e8f16807d62d30e1 100644 --- a/python_payload/st3m/application_settings.py +++ b/python_payload/st3m/application_settings.py @@ -4,6 +4,7 @@ import os from st3m import logging from st3m import utils + log = logging.Log(__name__, level=logging.INFO) # we are conciously only storing this on sd. @@ -28,6 +29,8 @@ _application_data = None # currently used when application settings file doesn't exist. _application_settings_nothing_new = False +_delete_callback = None + def get_autostart(): global _autostart_data @@ -197,6 +200,29 @@ def create_app_data(slug): _save_application_data() +def delete_app(path): + path = utils.simplify_path(path) + if path.startswith("/sd/apps/") or path.startswith("/flash/sys/apps/"): + if os.path.exists(path): + print(f"deleting {path}") + utils.rm_recursive(path) + else: + print(f"can't delete {path}, doesn't exist") + else: + print(f"can't delete {path}, not in appication folder") + slug = path.split("/")[-1] + if not slug in _application_data: + print(f"unknown slug: {slug}") + delete_app_data(slug) + if _delete_callback: + _delete_callback(slug) + + +def set_delete_callback(fun): + global _delete_callback + _delete_callback = fun + + class ApplicationTag: def __init__(self, rating, text="tag", color=(1, 1, 1)): # rating affects sort order. at rating = 0 diff --git a/python_payload/st3m/main_menu.py b/python_payload/st3m/main_menu.py index 23c1763ccefb81e9ed897b9d2483ce64238aa07f..3b0aaff87cefcaf5782c4054ac2de31662c43c4f 100644 --- a/python_payload/st3m/main_menu.py +++ b/python_payload/st3m/main_menu.py @@ -38,7 +38,7 @@ import bl00mbox class ApplicationTinyMenu(BaseView): - def __init__(self, item, item_destructor=None) -> None: + def __init__(self, item) -> None: super().__init__() self._vm = None self._index = 0 @@ -50,7 +50,6 @@ class ApplicationTinyMenu(BaseView): if not isinstance(item, MenuItemAppLaunch): self.exit() self._bundle = item._bundle - self._item_destructor = item_destructor def draw(self, ctx): phase = self._col_anim @@ -148,14 +147,7 @@ class ApplicationTinyMenu(BaseView): self._delete_confirm_index = 0 elif self._delete_confirm_index == 2: # delete - path = simplify_path(self._bundle.path) - if path.startswith("/sd/apps") or path.startswith("/flash/sys/apps"): - print(f"deleting {path}...") - rm_recursive(path) - else: - print(f"can't delete {path}") - application_settings.delete_app_data(self._bundle.path) - self._item_destructor() + application_settings.delete_app(self._bundle.path) self.exit() elif self._index == 0: # change tag @@ -259,6 +251,20 @@ class ApplicationMenu(RestoreMenu): return ret + def delete_app(self, slug): + pos = 0 + for item in self._items: + target = self._scroll_controller.target_position() + if isinstance(item, MenuItemAppLaunch): + if item._bundle.path.endswith(slug): + print(slug, item._bundle.path) + self._items.pop(pos) + if pos >= target: + self._scroll_controller.set_position(pos - 1) + pos -= 1 + self._scroll_controller.set_item_count(len(self._items)) + pos += 1 + def _update_tags(self): for item in self._items: if isinstance(item, MenuItemAppLaunch): @@ -268,12 +274,6 @@ class ApplicationMenu(RestoreMenu): return self.tags = [] - def _pop_target_item(self): - pos = self._scroll_controller.target_position() - self._items.pop(pos) - self._scroll_controller.set_position(pos - 1) - self._scroll_controller.set_item_count(len(self._items)) - def _sort(self): def sort_key(item): if isinstance(item, MenuItemBack): @@ -299,7 +299,7 @@ class ApplicationMenu(RestoreMenu): if self.input.buttons.app.middle.repeated and self._vm: item = self._items[self._scroll_controller.target_position()] self._vm.push( - ApplicationTinyMenu(item, self._pop_target_item), + ApplicationTinyMenu(item), ViewTransitionNone(), ) elif self.input.buttons.app.middle.released or any( @@ -439,20 +439,28 @@ class MainMenu(SunMenu): for kind in app_kinds: if kind not in ["Hidden", "System"] and kind not in menu_categories: menu_categories.append(kind) - - categories = [ - MenuItemApplicationMenu( - kind, ApplicationMenu([MenuItemApplicationBack()] + entries, name=kind) - ) + self._app_menus = [ + ApplicationMenu([MenuItemApplicationBack()] + entries, name=kind) for kind in menu_categories if (entries := _get_bundle_menu_entries(self._bundles, kind)) ] + + categories = [ + MenuItemApplicationMenu(app_menu._name, app_menu) + for app_menu in self._app_menus + ] categories.append(MenuItemForeground("System", menu_system)) self._items = categories # # self._scroll_controller = ScrollController() # self._scroll_controller.set_item_count(len(categories)) + application_settings.set_delete_callback(self.delete_app) + + def delete_app(self, slug): + for app_menu in self._app_menus: + app_menu.delete_app(slug) + def on_enter(self, vm): super().on_enter(vm) self.sensitivity = 1