diff --git a/CHANGELOG.md b/CHANGELOG.md index f6bc1bc..519b207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ All notable changes to this project are listed here. The format follows ### Changed - Each release has its SHA-256 hashes in a file you can download, `ZOrbit--SHA256.txt`, instead of in the release notes. +- The ring shows up sooner, the first time after ZOrbit starts as well as every time after, and moving over its slots, opening its fans and tapping the hotkey are smoother. A large ring at high display scaling gains the most. It looks exactly as before. + +### Fixed + +- The auto slots of a submenu that opens as a ring of its own could change order when you opened it, while one of them was still empty. They now stay where they were, as in any other ring. +- When a tag's fan has no room by a screen edge and its items open as a ring of their own, they now have the tag's colour, as they do in the fan. Before, each had the colour of its own first tag. ## [0.23.0] - 2026-11-10 diff --git a/tests/test_auto_order.py b/tests/test_auto_order.py new file mode 100644 index 0000000..16f670c --- /dev/null +++ b/tests/test_auto_order.py @@ -0,0 +1,103 @@ +"""A submenu with auto slots that opens as a ring keeps its auto slots where they were when it opens: the path +radiald.App._on_ring_done -> Expander.resolve -> App.open_menu fills the menu once, not twice. No window is made.""" +import json +import time + +import pytest + +import radiald +import ring +import usage + +MENUS = {"h": {"title": "H", "hotkey": "Q", "items": [{"brush": "Move"}, {"menu": "s", "label": "Sub"}]}, + "s": {"title": "S", "items": [{"auto": "brush"}, {"auto": "brush"}, {"auto": "brush"}]}} + + +@pytest.fixture +def app(tmp_path, monkeypatch): + for name in ("_prefetch", "_schedule_prewarm", "_prune_icons"): + monkeypatch.setattr(radiald.App, name, lambda self: None) + monkeypatch.setattr(radiald.App, "icon_for", lambda self, item: None) + monkeypatch.setattr(ring.Ring, "show", lambda self, x, y: None) # no window + monkeypatch.setattr(radiald, "log", lambda m: None) + now = time.time() + # Saved in an order that is not the ranking: Beta has the higher score, but Alpha holds the first slot. + raw = {"brushes": {"alpha": {"name": "Alpha", "score": 1.0, "t": now - 3600}, + "beta": {"name": "Beta", "score": 3.0, "t": now}}, + "menus": {"s": {"slots": ["Alpha", "Beta", None], "pending": []}}} + (tmp_path / "usage.json").write_text(json.dumps(raw), encoding="utf-8") + radial = tmp_path / "radial.json" + radial.write_text(json.dumps({"menus": MENUS}), encoding="utf-8") + a = radiald.App(str(radial)) + a.usage = usage.Usage(str(tmp_path / "usage.json")).load() + a.load_config(force=True, balloon=False) + return a + + +def shown(app): + return [it.label for it in app.expander.resolve("s").items] + + +def open_sub(app): + """Clicks the Sub slot of the hotkey ring: the submenu opens as a ring of its own, as the app opens it.""" + sub = app.expander.menu(app.cfg.menus["h"]).items[1] + app._on_ring_done(sub, "click") + labels = [it.label for it in app.ring.items] + app.ring = None + return labels + + +def state(app): + return json.loads(json.dumps(app.usage.menus["s"])) + + +def test_opening_a_submenu_with_auto_slots_keeps_their_order(app): + before = state(app) + assert shown(app) == ["Alpha", "Beta", "Auto"] + for _ in range(2): + assert open_sub(app) == ["Alpha", "Beta", "Auto"] + assert state(app) == before and shown(app) == ["Alpha", "Beta", "Auto"] + + +def test_a_menu_filled_once_is_not_filled_again(app): + once = app.expander.resolve("s") + assert app.expander.menu(once) is once + assert app.expander.menu(app.expander.menu(app.cfg.menus["h"])).items == app.expander.menu(app.cfg.menus["h"]).items + + +def test_the_order_still_changes_by_the_top_of_the_list_twice_in_a_row(app): + pick = radiald.C.Item(label="Clay", kind="brush", data={"brush": "Clay"}) + app._record_pick(pick) # an empty slot takes a newly picked brush + assert state(app)["slots"] == ["Alpha", "Beta", "Clay"] + app.usage.brushes["clay"]["score"] = 0.1 # Clay has faded since + app.usage.save() + delta = radiald.C.Item(label="Delta", kind="brush", data={"brush": "Delta"}) + for _ in range(4): + app.usage.record("Delta") # Delta is used a lot, so it makes the top three + app._record_pick(delta) # the first time at the top: it waits + first = state(app) + assert first["slots"] == ["Alpha", "Beta", "Clay"] and "Delta" in first["pending"] + open_sub(app) + open_sub(app) + assert state(app) == first # opening the submenu changes nothing + app._record_pick(delta) # the second time in a row: it takes the weakest slot + assert state(app)["slots"] == ["Alpha", "Beta", "Delta"] + assert open_sub(app) == ["Alpha", "Beta", "Delta"] + + +def test_a_pick_lands_in_the_slot_the_ranking_gives_it(tmp_path, monkeypatch): + """Two empty auto slots and an older brush, Clay, that never filled one. Picking Smooth counts it first and then + fills the slots from the ranking, so Smooth (picked just now) comes before Clay (a week old).""" + for name in ("_prefetch", "_schedule_prewarm", "_prune_icons"): + monkeypatch.setattr(radiald.App, name, lambda self: None) + monkeypatch.setattr(radiald, "log", lambda m: None) + raw = {"brushes": {"clay": {"name": "Clay", "score": 1.0, "t": time.time() - 7 * 86400}}} + (tmp_path / "usage.json").write_text(json.dumps(raw), encoding="utf-8") + radial = tmp_path / "radial.json" + radial.write_text(json.dumps({"menus": {"m": {"title": "M", "hotkey": "Q", + "items": [{"auto": "brush"}, {"auto": "brush"}]}}}), encoding="utf-8") + a = radiald.App(str(radial)) + a.usage = usage.Usage(str(tmp_path / "usage.json")).load() + a.load_config(force=True, balloon=False) + a._record_pick(radiald.C.Item(label="Smooth", kind="brush", data={"brush": "Smooth"})) + assert a.usage.menus["m"]["slots"] == ["Smooth", "Clay"] \ No newline at end of file diff --git a/tests/test_ring.py b/tests/test_ring.py index c8fa7cf..9abb277 100644 --- a/tests/test_ring.py +++ b/tests/test_ring.py @@ -41,11 +41,12 @@ def make_ring(menu=None, icon_for=None, log=None, **over): @pytest.fixture(autouse=True) def empty_sprite_cache(): - ring._sprite_cache.clear() - ring._slot_cache.clear() + caches = (ring._sprite_cache, ring._slot_cache, ring._static_layers, ring._fan_line_cache) + for c in caches: + c.clear() yield - ring._sprite_cache.clear() - ring._slot_cache.clear() + for c in caches: + c.clear() # --- radius ------------------------------------------------------------------------------------ @@ -861,18 +862,18 @@ def test_a_look_names_its_colour_roles_for_the_hub_and_the_labels(): # --- fans with a look of their own ------------------------------------------------------------- -def fan_ring(fan_look=None, ring_look=None): +def fan_ring(fan_look=None, ring_look=None, n=3, scale=1.0, parent_colour=""): sub = SimpleNamespace(id="s", title="Sub", animation="", fan=True, - items=[config.Item(f"S{i}", "brush", {"brush": f"S{i}"}) for i in range(3)]) + items=[config.Item(f"S{i}", "brush", {"brush": f"S{i}"}) for i in range(n)]) if fan_look is not None: sub.look = fan_look menu = make_menu(4) - menu.items[1] = config.Item("Sub", "menu", {"menu": "s"}) + menu.items[1] = config.Item("Sub", "menu", {"menu": "s"}, color=parent_colour) if ring_look is not None: menu.look = ring_look r = ring.Ring(menu, make_cfg(), lambda it: None, lambda *a: None, lambda m: None, held=False, submenus=lambda mid: sub if mid == "s" else None) - r._geometry(1.0) + r._geometry(scale) r._relayout() r._prepare() return r, r._fan((1,)) @@ -1645,3 +1646,245 @@ def test_an_empty_recent_slot_cannot_be_flicked(): picked = [] r._finish = lambda *a: picked.append(a) assert r._flicked((0,), True, False) is False and picked == [] + + +# --- layers and lines kept between opens ----------------------------------------------------------------------------- +def tinted_menu(title="Test", colour="#ff0000"): + """Four brushes, the second with a tag colour, so the tag band shows.""" + menu = make_menu(4, title=title) + menu.items[1] = dataclasses.replace(menu.items[1], color=colour) + return menu + + +def static_layer(menu=None, scale=1.0, held=False, armed=True, **over): + r = ring.Ring(menu or tinted_menu(), make_cfg(**over), lambda it: None, lambda *a: None, lambda m: None, held=held) + r._geometry(scale) + r._prepare() + r._armed = armed + return r._static() + + +def test_a_second_open_with_the_same_inputs_reuses_the_static_layer(): + first = static_layer() + assert static_layer() is first + assert len(ring._static_layers) == 1 + + +def tinted_with(k, item): + """tinted_menu with item k replaced.""" + menu = tinted_menu() + menu.items[k] = item + return menu + + +def tinted_of(n): + """tinted_menu with n items.""" + menu = make_menu(n) + menu.items[1] = dataclasses.replace(menu.items[1], color="#ff0000") + return menu + + +# (the base's settings, the changed settings): each a function, so every call makes fresh menus +STATIC_INPUTS = { + "title": (dict, lambda: dict(menu=tinted_menu(title="Other"))), + "CJK title": (dict, lambda: dict(menu=tinted_menu(title="笔刷"))), + "hint off": (dict, lambda: dict(hint=False)), + "hold hint": (dict, lambda: dict(held=True)), + "flick off changes the hold hint": (lambda: dict(held=True), lambda: dict(held=True, flick=False)), + "not armed": (dict, lambda: dict(armed=False)), + "band off": (dict, lambda: dict(tag_band="none")), + "band width": (dict, lambda: dict(tag_band="wide")), + "band opacity": (dict, lambda: dict(tag_band_opacity=0.9)), + "scale": (dict, lambda: dict(scale=1.5)), + "hub": (dict, lambda: dict(hub=30)), + "hub style": (lambda: dict(hub=30), lambda: dict(hub=30, hub_style="ring")), + "theme": (dict, lambda: dict(theme="light")), + "lines colour": (dict, lambda: dict(colors=dict(config.DEFAULT_RING["colors"], lines="#00ff00"))), + "an item's colour": (dict, lambda: dict(menu=tinted_menu(colour="#0000ff"))), + "an item becomes empty": (dict, lambda: dict(menu=tinted_with(2, config.Item("", "empty", {"empty": True})))), + "an item becomes a submenu": (dict, lambda: dict(menu=tinted_with(2, config.Item("Sub", "menu", {"menu": "x"})))), + "another item count": (dict, lambda: dict(menu=tinted_of(5))), +} + + +@pytest.mark.parametrize("inputs", STATIC_INPUTS.values(), ids=STATIC_INPUTS.keys()) +def test_each_input_of_the_static_layer_gives_its_own_layer(inputs): + base_of, change_of = inputs + base = static_layer(**base_of()) + other = static_layer(**change_of()) + assert other is not base and other.tobytes() != base.tobytes() + assert static_layer(**base_of()) is base # going back finds the first one again + assert static_layer(**change_of()) is other + + +def test_a_ring_that_changes_its_hint_or_armed_state_draws_the_new_layer_and_leaves_the_old_one_alone(): + r = make_ring(tinted_menu()) + r._geometry(1.0) + r._relayout() + r._prepare() + click = r._static() + before = click.tobytes() + r._stay_open("tap") # the click hint again: no change + assert r._static() is click + r.mode = "hold" + r._static_cache = None + hold = r._static() + assert hold is not click and click.tobytes() == before + r._base(50) # the fade of the first 100 ms works on a copy + r._base(500) + assert hold.tobytes() == static_layer(held=True).tobytes() and click.tobytes() == before + + +def test_a_preview_follows_a_slider_drag_and_comes_back(): + pics = [ring.Ring.preview(tinted_menu(), make_cfg(radius=v, tag_band_opacity=o), lambda it: None).tobytes() + for v, o in ((145, 0.45), (170, 0.45), (170, 0.8), (145, 0.45))] + assert len(set(pics[:3])) == 3 and pics[3] == pics[0] + + +def static_bytes(): + return sum(im.size[0] * im.size[1] * 4 for im, _n in ring._static_layers._d.values()) + + +def test_the_static_layers_stay_within_their_bound(monkeypatch): + for k in range(ring.STATIC_LAYERS + 4): + static_layer(menu=tinted_menu(title=f"T{k}")) + cache = ring._static_layers + assert len(cache) < ring.STATIC_LAYERS # at 100% the bytes run out before the count + one = next(iter(cache._d.values()))[1] + assert cache.bytes == static_bytes() <= ring.STATIC_BYTES < cache.bytes + one # full: one more would not fit + monkeypatch.setattr(cache, "max_bytes", 2**40) # with room to spare, the count stops it + for k in range(ring.STATIC_LAYERS + 4): + static_layer(menu=tinted_menu(title=f"U{k}")) + assert len(cache) == ring.STATIC_LAYERS and cache.bytes == static_bytes() + monkeypatch.setattr(ring._static_layers, "max_bytes", 1) # one layer is over the budget: only the newest stays + newest = static_layer(menu=tinted_menu(title="newest")) + assert len(ring._static_layers) == 1 and static_layer(menu=tinted_menu(title="newest")) is newest + + +def test_an_image_lru_drops_the_least_recently_used_by_count_and_by_bytes(): + lru = ring._ImageLRU(max_bytes=100, max_entries=3) + for k in "abc": + lru.put(k, k.upper(), 30) + assert lru.get("a") == "A" # a is used again: b is now the oldest + lru.put("d", "D", 30) + assert lru.get("b") is None and len(lru) == 3 and lru.bytes == 90 + lru.put("e", "E", 50) # over 100 bytes: the oldest go until it fits + assert lru.get("c") is None and lru.get("a") is None and lru.get("d") == "D" and lru.bytes == 80 + lru.put("f", "F", 500) # bigger than the budget on its own: kept alone + assert len(lru) == 1 and lru.get("f") == "F" + + +def test_fan_lines_are_kept_between_opens_and_follow_their_inputs(): + _, a = fan_ring() + _, b = fan_ring() + assert b.lines is a.lines + _, colour = fan_ring(fan_look={"colors": {"lines": "#00ff00"}}) + _, more = fan_ring(n=5) + _, bigger = fan_ring(scale=1.5) + _, tagged = fan_ring(parent_colour="#ff00ff") # the slot it opens from carries a tag colour + for other in (colour, more, bigger, tagged): + assert other.lines is not a.lines and other.lines[0].tobytes() != a.lines[0].tobytes() + assert fan_ring()[1].lines is a.lines + + +def fan_line_bytes(): + """What the fan-line cache holds, counted from the images themselves.""" + return sum(im.size[0] * im.size[1] * 4 for (im, _), _n in ring._fan_line_cache._d.values()) + + +def test_the_fan_lines_stay_within_their_bound(monkeypatch): + for n in range(2, 2 + ring.FAN_LINE_IMAGES + 4): + fan_ring(n=n % 9 + 2, scale=1.0 + n / 100) + cache = ring._fan_line_cache + assert len(cache) < ring.FAN_LINE_IMAGES + 4 # some went: the byte budget held them + assert cache.bytes == fan_line_bytes() <= ring.FAN_LINE_BYTES + monkeypatch.setattr(cache, "max_bytes", 2**40) # with room to spare, the count bound stops it + for n in range(ring.FAN_LINE_IMAGES + 4): + fan_ring(n=3, scale=0.5 + n / 1000) + assert len(cache) == ring.FAN_LINE_IMAGES and cache.bytes == fan_line_bytes() + lines = fan_ring(n=3, scale=2.0)[1].lines[0] + one = lines.size[0] * lines.size[1] * 4 + monkeypatch.setattr(cache, "max_bytes", int(one * 2.5)) # room for two of these lines, not three + for n in range(1, 4): + fan_ring(n=3, scale=2.0 + n / 100) + assert cache.bytes == fan_line_bytes() <= cache.max_bytes and len(cache) == 2 + monkeypatch.setattr(cache, "max_bytes", 1) # a single entry larger than the budget stays, alone + fan_ring(n=4, scale=3.0) + assert len(cache) == 1 and cache.bytes == fan_line_bytes() > cache.max_bytes + + +def test_the_icon_signature_is_worked_out_once_and_follows_a_replaced_icon(): + red, green = Image.new("RGBA", (16, 16), (255, 0, 0, 255)), Image.new("RGBA", (16, 16), (0, 255, 0, 255)) + sig = ring._icon_sig(red) + assert ring._icon_sig(red) is sig # remembered for this image + assert ring._icon_sig(green) != sig # another picture + assert ring._icon_sig(red.copy()) == sig # the same picture in another image + icon = {"pic": red} + menu = make_menu(3) + a = prepared(menu, icon_for=lambda it: icon["pic"]) + icon["pic"] = green # the icon file was replaced + b = prepared(menu, icon_for=lambda it: icon["pic"]) + assert b._sprite_key() != a._sprite_key() and b.sprites[0] is not a.sprites[0] + + +def test_an_icon_signature_goes_with_its_image(monkeypatch): + im = Image.new("RGBA", (8, 8), (1, 2, 3, 4)) + ring._icon_sig(im) + key = id(im) + assert key in ring._icon_sigs + del im + assert key not in ring._icon_sigs + monkeypatch.setattr(ring, "ICON_SIGS", 4) + keep = [Image.new("RGBA", (8, 8), (k, 0, 0, 255)) for k in range(10)] + for im in keep: + ring._icon_sig(im) + assert len(ring._icon_sigs) <= 4 + + +def settled(menu=None, hover=1): + r = make_ring(menu or make_menu(6)) + r._geometry(1.0) + r._relayout() + r._prepare() + r.t0 = 0.0 + r.hover = (hover,) + r._apply_hover(10.0, instant=True) + return r + + +def test_a_settled_frame_resizes_no_slot_again(monkeypatch): + r = settled() + first = r._compose(10.0).tobytes() + calls = [] + real = Image.Image.resize + monkeypatch.setattr(Image.Image, "resize", lambda self, *a, **k: calls.append(1) or real(self, *a, **k)) + again = r._compose(10.0).tobytes() + assert calls == [] and again == first + monkeypatch.setattr(Image.Image, "resize", real) + assert settled()._compose(10.0).tobytes() == first # a ring that never kept anything draws the same + + +def test_a_slot_easing_its_hover_is_resized_to_each_new_size(): + r = settled() + r.hover = (3,) + r._apply_hover(10.0) # slot 3 grows over 120 ms, slot 1 shrinks + sizes = set() + for k in range(1, 13): + r._compose(10.0 + k / 100) + sizes.add(r._resized[((), 3)][1]) + assert len(sizes) > 3 + + +def test_the_premultiplied_bytes_are_within_one_of_the_three_pass_way(): + from PIL import ImageChops + im = Image.new("RGBA", (256, 256)) + im.putdata([(x, 255 - x, (x * 7) % 256, y) for y in range(256) for x in range(256)]) + fast = ring._premultiplied_bgra(im) + r, g, b, a = im.split() + r, g, b = (ImageChops.multiply(ch, a) for ch in (r, g, b)) + slow = Image.merge("RGBA", (r, g, b, a)).tobytes("raw", "BGRA") + diff = ImageChops.difference(Image.frombytes("RGBA", im.size, fast), Image.frombytes("RGBA", im.size, slow)) + assert max(hi for _, hi in diff.getextrema()) <= 1 + assert fast[3::4] == slow[3::4] # alpha untouched + for x in (0, 255): # transparent and opaque pixels are exact + assert fast[(x * 256 + 37) * 4:(x * 256 + 38) * 4] == slow[(x * 256 + 37) * 4:(x * 256 + 38) * 4] \ No newline at end of file diff --git a/tests/test_ring_warm.py b/tests/test_ring_warm.py new file mode 100644 index 0000000..def3997 --- /dev/null +++ b/tests/test_ring_warm.py @@ -0,0 +1,494 @@ +"""radiald.App draws the ring's pictures ahead of the first open (the ring warm-up), on its own thread, in steps. + +No window, hook or timer is made: SetTimer and KillTimer are recorded, the monitor is a made-up 1920x1080 one, and an +open is played the way open_menu and Ring.show set a ring up, without the window. +""" +import ctypes.wintypes as w +import json +import threading +import time + +import pytest +from PIL import Image + +import radiald +import recent +import ring +import usage +import win32 as W + +REAL_PREFETCH = radiald.App._prefetch # the env fixture puts a no-op in its place +WORK = w.RECT(0, 0, 1920, 1040) +RECENT = ["D:/Projects/One.zpr", "D:/Models/Two.ztl", "D:/Projects/Three.zpr"] +MENUS = { + "a": {"title": "A", "hotkey": "Q", "items": [{"brush": "Move"}, {"brush": "Clay"}, {"auto": "brush"}, + {"menu": "f", "label": "Fan"}, {"menu": "s", "label": "Sub"}, + {"recent": "any", "label": "Recent"}]}, + "b": {"title": "B", "hotkey": "W", "items": [{"brush": "Pinch"}, {"brush": "Flatten"}, {"menu": "f", "label": "Fan"}, + {"menu": "t", "label": "Autos"}]}, + "f": {"title": "F", "fan": True, "items": [{"brush": "Layer"}, {"brush": "Standard"}, + {"menu": "g", "label": "Deeper"}]}, + "g": {"title": "G", "fan": True, "items": [{"brush": "Slash3"}, {"brush": "Inflat"}]}, + "s": {"title": "S", "items": [{"brush": "ClayTubes"}, {"brush": "SnakeHook"}, {"brush": "Move Topological"}]}, + "t": {"title": "T", "items": [{"auto": "brush"}, {"auto": "brush"}, {"auto": "brush"}, {"brush": "Dam"}]}, +} + + +class Counts: + """How many pictures the ring code draws, by kind.""" + + def __init__(self, monkeypatch): + self.n = {"slot": 0, "menu": 0, "static": 0, "lines": 0} + for kind, owner, name in (("slot", ring.Ring, "_slot_sprites"), ("static", ring.Ring, "_draw_static"), + ("lines", ring.Ring, "_draw_fan_lines"), ("menu", ring, "_sprite_put")): + real = getattr(owner, name) + + def counted(*a, real=real, kind=kind, **k): + self.n[kind] += 1 + return real(*a, **k) + monkeypatch.setattr(owner, name, counted) + + def take(self): + out, self.n = self.n, dict.fromkeys(self.n, 0) + return out + + +@pytest.fixture +def env(tmp_path, monkeypatch): + for c in (ring._sprite_cache, ring._slot_cache, ring._static_layers, ring._fan_line_cache): + c.clear() + for name in ("_prefetch", "_schedule_prewarm", "_prune_icons"): + monkeypatch.setattr(radiald.App, name, lambda self: None) + icons = {} + + def icon_for(self, item): # one picture per brush, the same object each time, as App.icon_for keeps + name = item.data.get("brush") or item.icon + if not name: + return None + if name not in icons: + icons[name] = Image.new("RGBA", (32, 32), (len(name) * 20 % 256, 90, 160, 255)) + return icons[name] + monkeypatch.setattr(radiald.App, "icon_for", icon_for) + timers, lines = {}, [] + monkeypatch.setattr(W, "SetTimer", lambda hwnd, tid, ms, cb: timers.__setitem__(tid, ms)) + monkeypatch.setattr(W, "KillTimer", lambda hwnd, tid: timers.pop(tid, None)) + monkeypatch.setattr(W, "GetForegroundWindow", lambda: 0) + monkeypatch.setattr(W, "monitor_for_point", lambda x, y: (WORK, 1.0)) + monkeypatch.setattr(W, "monitor_for_window", lambda hwnd: (WORK, 1.5)) + monkeypatch.setattr(radiald, "log", lines.append) + listed = list(RECENT) + monkeypatch.setattr(recent, "entries", lambda kind, limit, install=None, log=None: listed[:limit]) + monkeypatch.setattr(recent, "prefetch", lambda *a, **k: None) # never the real list + monkeypatch.setattr(recent, "known", lambda kind, limit, install=None: True) + radial = tmp_path / "radial.json" + radial.write_text(json.dumps({"menus": MENUS}), encoding="utf-8") + app = radiald.App(str(radial)) + app.usage = usage.Usage(str(tmp_path / "usage.json")) + app.ctrl = 1 + app.load_config(force=True, balloon=False) + return app, timers, lines, Counts(monkeypatch), listed, radial + + +def run_warm(app, timers, limit=5000): + ticks = 0 + while radiald.TIMER_RING_WARM in timers and ticks < limit: + app._warm_tick() + ticks += 1 + return ticks + + +def opened(app, menu, held=True, nested=False): + """A ring set up as open_menu and show() set it up (centred on the monitor), without the window.""" + r = ring.Ring(app.expander.menu(menu), app.cfg.ring, app.icon_for, lambda *a: None, lambda m: None, held=held, + nested=nested, submenus=app.expander.resolve) + work, scale = W.monitor_for_point(960, 520) + r._place(960, 520, work, scale) + r._prepare() + r._static() + return r + + +def open_fans(r): + todo = [(i,) for i in range(r.n)] + while todo: + path = todo.pop(0) + if r._fannable(path): + fan = r._fan(path) + todo += [path + (k,) for k in range(len(fan.items))] + + +def tap(r): + r._stay_open("tap") + r.mode = "click" + r._static_cache = None + r._static() + + +def open_everything(app): + """Every open warm-up plans for: the hotkey menus, their fans, the rings their slots open, and a tap on each.""" + rings = [] + for m in app.slots: + r = opened(app, m) + open_fans(r) + rings.append(r) + for mid in ("s", "t"): + rings.append(opened(app, app.expander.resolve(mid), nested=True)) + for r in rings: + tap(r) + + +def test_after_the_warm_up_the_first_opens_draw_nothing(env): + app, timers, lines, counts, *_ = env + assert timers[radiald.TIMER_RING_WARM] == radiald.RING_WARM_WAIT_MS + run_warm(app, timers) + drawn = counts.take() + assert drawn["slot"] >= 15 and drawn["static"] >= 4 and drawn["lines"] >= 3 and drawn["menu"] >= 3 + open_everything(app) + assert counts.take() == {"slot": 0, "menu": 0, "static": 0, "lines": 0} + + +def test_without_the_warm_up_the_same_opens_draw_everything(env): + app, timers, lines, counts, *_ = env + open_everything(app) + drawn = counts.take() + assert drawn["slot"] >= 15 and drawn["static"] >= 4 and drawn["lines"] >= 3 + + +def test_one_picture_per_step_and_one_log_line_at_the_end(env): + app, timers, lines, counts, *_ = env + lines.clear() + app._warm_tick() # the plan + while radiald.TIMER_RING_WARM in timers: + before = sum(counts.n.values()) + app._warm_tick() + assert sum(counts.n.values()) - before <= 1 + assert len(lines) == 1 and lines[0].startswith("ring warm-up (menus loaded): ") and "pictures drawn" in lines[0] + + +def test_a_step_does_nothing_while_a_ring_is_open_and_carries_on_after(env): + app, timers, lines, counts, *_ = env + app._warm_tick() + app._warm_tick() + counts.take() + app.ring = object() + for _ in range(5): + app._warm_tick() + assert counts.take() == {"slot": 0, "menu": 0, "static": 0, "lines": 0} + assert timers[radiald.TIMER_RING_WARM] == radiald.RING_WARM_HOLD_MS + app.ring = None + run_warm(app, timers) + assert sum(counts.take().values()) > 0 # it carried on where it was + open_everything(app) + assert counts.take() == {"slot": 0, "menu": 0, "static": 0, "lines": 0} + + +@pytest.mark.parametrize("busy", ["held_vk", "_ack", "_browser_pick", "_commands", "_apply_watch", "_prefetch_threads"]) +def test_a_step_waits_for_a_hotkey_a_pick_or_the_thumbnails(env, busy): + app, timers, lines, counts, *_ = env + app._warm_tick() + counts.take() + value = {"held_vk": 0x51, "_commands": [1], + "_prefetch_threads": [type("T", (), {"is_alive": lambda self: True})()]} + setattr(app, busy, value.get(busy, object())) + app._warm_tick() + assert sum(counts.take().values()) == 0 and timers[radiald.TIMER_RING_WARM] == radiald.RING_WARM_HOLD_MS + + +def test_a_step_waits_for_an_older_thumbnail_thread_when_a_newer_one_is_done(env, monkeypatch): + app, timers, lines, counts, *_ = env + reading, release, calls = threading.Event(), threading.Event(), [] + + def prefetch(pairs, size): # the first load's thread reads until it is let go, the second ends at once + calls.append(size) + if len(calls) == 1: + reading.set() + release.wait(10) + monkeypatch.setattr(radiald.assets, "prefetch", prefetch) + REAL_PREFETCH(app) + assert reading.wait(5) + REAL_PREFETCH(app) + first, second = app._prefetch_threads + second.join(5) + assert first.is_alive() and not second.is_alive() + app._warm_tick() + counts.take() + app._warm_tick() + assert sum(counts.take().values()) == 0 and timers[radiald.TIMER_RING_WARM] == radiald.RING_WARM_HOLD_MS + release.set() + first.join(5) + run_warm(app, timers) + assert sum(counts.take().values()) > 0 + REAL_PREFETCH(app) # the threads that have ended are not kept + assert len(app._prefetch_threads) == 1 + app._prefetch_threads[0].join(5) + + +def test_a_change_mid_way_drops_the_old_plan_and_the_new_one_completes(env): + app, timers, lines, counts, listed, radial = env + for _ in range(4): + app._warm_tick() + old = app._warm + assert old is not None + menus = json.loads(json.dumps(MENUS)) + menus["a"]["title"] = "A again" + menus["b"]["items"].append({"brush": "Layer"}) + radial.write_text(json.dumps({"menus": menus}), encoding="utf-8") + app.load_config(force=True, balloon=False) + assert app._warm is None and timers[radiald.TIMER_RING_WARM] == radiald.RING_WARM_WAIT_MS + run_warm(app, timers) + assert app._warm is None and radiald.TIMER_RING_WARM not in timers + counts.take() + open_everything(app) + assert counts.take() == {"slot": 0, "menu": 0, "static": 0, "lines": 0} + + +def test_a_burst_of_changes_starts_one_warm_up(env, monkeypatch): + app, timers, lines, counts, *_ = env + plans = [] + real = radiald.App._warm_jobs + monkeypatch.setattr(radiald.App, "_warm_jobs", lambda self, *a: plans.append(1) or real(self, *a)) + for _ in range(6): # Studio saving on every step of a slider drag + app._warm_soon("menus loaded") + assert timers[radiald.TIMER_RING_WARM] == radiald.RING_WARM_WAIT_MS # the wait starts again each time + run_warm(app, timers) + assert plans == [1] + + +def test_the_timer_is_gone_when_the_plan_is_done_and_nothing_runs_again(env): + app, timers, lines, counts, *_ = env + run_warm(app, timers) + assert radiald.TIMER_RING_WARM not in timers + lines.clear() + counts.take() + app._warm_soon("menus loaded") # everything is there already + ticks = run_warm(app, timers) + assert counts.take() == {"slot": 0, "menu": 0, "static": 0, "lines": 0} + assert lines == [] and radiald.TIMER_RING_WARM not in timers and ticks < 10 + + +def test_with_no_hotkey_menus_there_is_nothing_to_do(env): + app, timers, lines, counts, *_ = env + app._set_slots([], None) + app._warm_soon("menus loaded") + assert run_warm(app, timers) == 1 and radiald.TIMER_RING_WARM not in timers + assert sum(counts.take().values()) == 0 and lines[-1].startswith("config loaded") + + +def test_the_warm_up_stays_inside_every_cache_bound_and_keeps_what_it_drew(env, monkeypatch): + app, timers, lines, counts, *_ = env + monkeypatch.setattr(ring, "SLOT_SPRITES", 16) # room for the two hotkey menus' slots, not their fans + monkeypatch.setattr(ring, "SPRITE_MENUS", 4) + run_warm(app, timers) + limits = app._warm_limits() + assert len(ring._slot_cache) <= limits["slot"][0] == 12 and len(ring._sprite_cache) <= limits["menu"][0] + assert len(ring._static_layers) <= limits["static"][0] and ring._static_layers.bytes <= limits["static"][1] + assert len(ring._fan_line_cache) <= limits["lines"][0] and ring._fan_line_cache.bytes <= limits["lines"][1] + assert "stopped where the ring caches would hold no more" in lines[-1] + counts.take() + opened(app, app.slots[0]) # what it drew is still there + assert counts.take() == {"slot": 0, "menu": 0, "static": 0, "lines": 0} + + +def test_the_warm_up_uses_the_bounds_of_each_cache(): + limits = radiald.App._warm_limits() + assert limits["slot"] == (int(ring.SLOT_SPRITES * 0.75), float("inf")) + assert limits["static"] == (36, 24 * 2**20) and limits["lines"] == (24, 3 * 2**20) and limits["menu"] == (6, float("inf")) + + +def test_a_pick_that_fills_an_auto_slot_draws_only_that_slot(env): + app, timers, lines, counts, *_ = env + run_warm(app, timers) + counts.take() + app._record_pick(radiald.C.Item(label="Smooth", kind="brush", data={"brush": "Smooth"})) + assert timers[radiald.TIMER_RING_WARM] == radiald.RING_WARM_PICK_MS + run_warm(app, timers) + assert counts.take() == {"slot": 1, "menu": 2, "static": 0, "lines": 0} # the new slot, a's and t's sprites + open_everything(app) + assert counts.take() == {"slot": 0, "menu": 0, "static": 0, "lines": 0} + + +def test_a_pick_that_changes_no_auto_slot_does_not_warm_again(env): + app, timers, lines, counts, *_ = env + run_warm(app, timers) + app._record_pick(radiald.C.Item(label="Smooth", kind="brush", data={"brush": "Smooth"})) + run_warm(app, timers) + app._record_pick(radiald.C.Item(label="Smooth", kind="brush", data={"brush": "Smooth"})) # Smooth is in the slot already + assert radiald.TIMER_RING_WARM not in timers + + +def test_a_new_recent_file_warms_nothing_and_the_open_draws_only_what_is_new(env): + app, timers, lines, counts, listed, _ = env + run_warm(app, timers) + counts.take() + listed.insert(0, "D:/Models/Four.ztl") # ZBrush opened another file + assert radiald.TIMER_RING_WARM not in timers + r = opened(app, app.slots[0]) + open_fans(r) + drawn = counts.take() + assert drawn["slot"] == 1 and drawn["lines"] == 1 and drawn["static"] == 0 + + +def test_the_scale_is_zbrushs_monitor_when_it_is_in_front_else_the_primary(env, monkeypatch): + app, timers, lines, counts, *_ = env + assert app._warm_target() == (WORK, 1.0) + monkeypatch.setattr(W, "GetForegroundWindow", lambda: 7) + monkeypatch.setattr(radiald.App, "target_in_front", lambda self, on_hook=False: True) + assert app._warm_target() == (WORK, 1.5) + + +def test_zbrush_on_a_monitor_of_another_scale_warms_again_and_focus_elsewhere_does_not(env, monkeypatch): + app, timers, lines, counts, *_ = env + run_warm(app, timers) + assert app._warm_scale == 1.0 + app._check_warm_scale() + assert radiald.TIMER_RING_WARM not in timers # ZBrush is not in front: no change + monkeypatch.setattr(W, "GetForegroundWindow", lambda: 7) + monkeypatch.setattr(radiald.App, "target_in_front", lambda self, on_hook=False: True) + app._check_warm_scale() + assert timers[radiald.TIMER_RING_WARM] == radiald.RING_WARM_WAIT_MS and app._warm_scale == 1.5 + timers.clear() + app._check_warm_scale() + assert timers == {} # the same scale again: nothing + + +def test_the_warm_up_leaves_a_submenus_auto_slots_where_they_are(env): + app, timers, lines, counts, *_ = env + run_warm(app, timers) + now = time.time() + # Saved in an order that is not the ranking (Beta scores higher, Alpha holds the first slot), with one slot empty. + raw = {"brushes": {"alpha": {"name": "Alpha", "score": 1.0, "t": now - 3600}, + "beta": {"name": "Beta", "score": 3.0, "t": now}}, + "menus": {"t": {"slots": ["Alpha", "Beta", None], "pending": []}}} + with open(app.usage.path, "w", encoding="utf-8") as f: + json.dump(raw, f) + app.usage.load() + before = json.loads(json.dumps(app.usage.menus["t"])) + shown = [it.label for it in app.expander.resolve("t").items] + assert shown == ["Alpha", "Beta", "Auto", "Dam"] + app._warm_soon("menus loaded") + run_warm(app, timers) + assert app.usage.menus["t"] == before + assert [it.label for it in app.expander.resolve("t").items] == shown + +def warm_lines(lines): + return [ln for ln in lines if ln.startswith("ring warm-up")] + + +def test_a_job_that_raises_is_said_once_and_the_plan_goes(env, monkeypatch): + app, timers, lines, counts, *_ = env + + def boom(): + raise RuntimeError("boom") + monkeypatch.setattr(ring.Ring, "warm_jobs", lambda self, part: iter([("slot", ("k",), 0, boom)])) + lines.clear() + assert run_warm(app, timers) == 1 + said = warm_lines(lines) + assert len(said) == 1 and "error" in said[0] and "boom" in said[0] + assert radiald.TIMER_RING_WARM not in timers and app._warm is None + + +def test_a_plan_that_raises_is_said_once_and_the_timer_goes(env, monkeypatch): + app, timers, lines, counts, *_ = env + + def boom(self): + raise OSError("no monitor") + monkeypatch.setattr(radiald.App, "_warm_target", boom) + lines.clear() + assert run_warm(app, timers) == 1 + said = warm_lines(lines) + assert len(said) == 1 and "no monitor" in said[0] + assert radiald.TIMER_RING_WARM not in timers and app._warm is None and sum(counts.take().values()) == 0 + + +def test_a_menus_file_that_cannot_be_read_at_start_leaves_nothing_to_warm(env, tmp_path): + app, timers, lines, counts, *_ = env + timers.clear() + bad = tmp_path / "bad.json" + bad.write_text("{ not json", encoding="utf-8") + other = radiald.App(str(bad)) + other.ctrl = 1 + other.load_config(force=True, balloon=False) # nothing loaded yet: the app runs with no menus + assert other.slots == [] and timers[radiald.TIMER_RING_WARM] == radiald.RING_WARM_WAIT_MS + assert run_warm(other, timers) == 1 and radiald.TIMER_RING_WARM not in timers + assert sum(counts.take().values()) == 0 and warm_lines(lines) == [] + + +def test_before_the_control_window_there_is_no_timer_and_start_makes_one(env): + app, timers, lines, counts, *_ = env + timers.clear() + app.ctrl = None + gen = app._warm_gen + app.load_config(force=True, balloon=False) # _serve loads the menus before the window exists + assert timers == {} and app._warm_gen == gen + 1 + app.ctrl = 1 + app._warm_soon("start") # ... and asks again once it does + assert timers[radiald.TIMER_RING_WARM] == radiald.RING_WARM_WAIT_MS + run_warm(app, timers) + counts.take() + open_everything(app) + assert counts.take() == {"slot": 0, "menu": 0, "static": 0, "lines": 0} + + +def test_pictures_already_there_are_found_not_drawn_and_kept(env): + app, timers, lines, counts, *_ = env + r = opened(app, app.slots[0]) # the user opened ring a before warm-up ran + open_fans(r) + by_hand = counts.take() + lines.clear() + run_warm(app, timers) + said = warm_lines(lines)[0] + found = int(said.split(" already there")[0].rsplit(", ", 1)[1]) + assert found >= by_hand["slot"] + by_hand["static"] + by_hand["lines"] + counts.take() + open_everything(app) # what was there before is still there + assert counts.take() == {"slot": 0, "menu": 0, "static": 0, "lines": 0} + + +def test_zbrushs_monitor_is_kept_while_another_program_is_in_front(env, monkeypatch): + app, timers, lines, counts, *_ = env + timers.clear() + front = {"zbrush": True} + monkeypatch.setattr(W, "GetForegroundWindow", lambda: 7) + monkeypatch.setattr(radiald.App, "target_in_front", lambda self, on_hook=False: front["zbrush"]) + app._check_warm_scale() # the config tick sees ZBrush in front, on a 150% monitor + front["zbrush"] = False # Studio comes to the front and saves + app._warm_soon("menus loaded") + run_warm(app, timers) + assert app._warm_scale == 1.5 and "at scale 1.50" in warm_lines(lines)[-1] + front["zbrush"] = True # back in ZBrush: nothing to plan again + app._check_warm_scale() + assert radiald.TIMER_RING_WARM not in timers + + +def test_before_zbrush_is_seen_the_primary_monitor_is_used(env): + app, timers, lines, counts, *_ = env + assert app._zb_monitor is None and app._warm_target() == (WORK, 1.0) + + +def test_a_plan_with_recent_slots_waits_for_the_first_answer_until_its_deadline(env, monkeypatch): + app, timers, lines, counts, *_ = env + answered = {"yet": False} + monkeypatch.setattr(recent, "known", lambda kind, limit, install=None: answered["yet"]) + app._warm_tick() # planned, and waiting + for _ in range(3): + app._warm_tick() + assert sum(counts.take().values()) == 0 and timers[radiald.TIMER_RING_WARM] == radiald.RING_WARM_HOLD_MS + answered["yet"] = True # the worker answered + app._warm_tick() + assert sum(counts.take().values()) == 1 + app._warm_soon("menus loaded") + answered["yet"] = False + app._warm_tick() + app._warm["recent_until"] = 0.0 # a read that never answers holds it only so long + run_warm(app, timers) + assert radiald.TIMER_RING_WARM not in timers and app._warm is None + + +def test_a_plan_without_recent_slots_does_not_wait_for_them(env, monkeypatch): + app, timers, lines, counts, *_ = env + monkeypatch.setattr(recent, "known", lambda kind, limit, install=None: False) + for m in app.cfg.menus.values(): + m.items = [it for it in m.items if it.kind != "recent"] + app._warm_soon("menus loaded") + app._warm_tick() + assert app._warm["recent"] == [] and sum(counts.take().values()) == 1 \ No newline at end of file diff --git a/tests/test_tag_ring_colours.py b/tests/test_tag_ring_colours.py new file mode 100644 index 0000000..06b1761 --- /dev/null +++ b/tests/test_tag_ring_colours.py @@ -0,0 +1,82 @@ +"""A tag fan that opens as a ring of its own (no room by a screen edge, or deeper than a fan can go) shows its items in +the tag's colour, as the fan does: radiald.App._on_ring_done -> Expander.resolve -> App.open_menu, with no window. +The Recent fan opened as a ring keeps its items as they are too.""" +import json +from pathlib import Path + +import pytest + +import config as C +import expand +import radiald +import recent +import ring +import tags +from tags import TagStore + +ROCK, WOOD = "#a8df23", "#3355ff" +RECENT = ["D:/Projects/One.zpr", "D:/Models/Two.ztl"] + + +class FakeAssets: + """Apple and Pear are installed brushes.""" + generation = 1 + + def __init__(self): + self.idx = {k: {} for k in C.ASSET_KINDS} + self.idx["brush"] = {tags._norm(n): Path(f"C:/x/{n}.zbp") for n in ("Apple", "Pear")} + + def index(self, kind): + return self.idx[kind] + + def names(self, kind): + return {} + + def rescan(self, kind): + pass + + +@pytest.fixture +def app(tmp_path, monkeypatch): + for name in ("_prefetch", "_schedule_prewarm", "_prune_icons"): + monkeypatch.setattr(radiald.App, name, lambda self: None) + monkeypatch.setattr(radiald.App, "icon_for", lambda self, item: None) + monkeypatch.setattr(ring.Ring, "show", lambda self, x, y: None) # no window + monkeypatch.setattr(radiald, "log", lambda m: None) + monkeypatch.setattr(expand, "assets", FakeAssets()) + monkeypatch.setattr(recent, "entries", lambda kind, limit, install=None, log=None: RECENT[:limit]) + monkeypatch.setattr(recent, "prefetch", lambda *a, **k: None) + store = TagStore(str(tmp_path / "tags.json")) + # Apple carries Wood first and Rock second, so its own colour is Wood's; Pear carries Rock only. + store.import_tags({"version": 1, "tags": {"rock": {"label": "Rock", "color": ROCK}, + "wood": {"label": "Wood", "color": WOOD}}, + "items": {"brush:apple": ["wood", "rock"], "brush:pear": ["rock"]}}, "replace") + radial = tmp_path / "radial.json" + radial.write_text(json.dumps({"menus": {"h": {"title": "H", "hotkey": "Q", "items": [ + {"brush": "Apple"}, {"tag": "rock"}, {"recent": "any"}]}}}), encoding="utf-8") + a = radiald.App(str(radial)) + a.tags = store + a.load_config(force=True, balloon=False) + return a + + +def open_as_ring(app, k): + """Clicks slot k of the hotkey ring when its submenu opens as a ring of its own; the items that ring is given.""" + item = app.expander.menu(app.cfg.menus["h"]).items[k] + assert item.kind == "menu" + app._on_ring_done(item, "click") + items, app.ring = app.ring.items, None + return items + + +def test_a_tag_fan_opened_as_a_ring_shows_its_items_in_the_tags_colour(app): + fan = app.expander.resolve("tag:rock") # the fan form + ringed = open_as_ring(app, 1) # the same menu, opened as a ring of its own + assert sorted((it.label, it.color.lower()) for it in fan.items) == [("Apple", ROCK), ("Pear", ROCK)] + assert [(it.label, it.color.lower()) for it in ringed] == [(it.label, it.color.lower()) for it in fan.items] + assert app.expander.menu(app.cfg.menus["h"]).items[0].color.lower() == WOOD # in a ring of its own: its own tag + + +def test_a_recent_fan_opened_as_a_ring_keeps_its_items(app): + fan = app.expander.resolve("recent:any") + assert open_as_ring(app, 2) == fan.items diff --git a/zorbit/config.py b/zorbit/config.py index 34468ff..2f6b511 100644 --- a/zorbit/config.py +++ b/zorbit/config.py @@ -174,6 +174,9 @@ class Menu: look: dict | None = None # the complete look this menu is drawn with (roles resolved); None: use Config.ring look_spec: dict = field(default_factory=dict) # {"preset": name or "", **the menu's own look keys} icon: str = "" # the icon of a hub_style "icon" hub + # Set by expand.Expander on the menu it hands the ring (auto slots filled, tag colours stamped), so that handing it + # back in leaves it as it is: filling it twice would read the filled auto slots as brushes the menu pins. + filled: bool = field(default=False, compare=False, repr=False) @dataclass diff --git a/zorbit/expand.py b/zorbit/expand.py index 4b3ea85..5745e13 100644 --- a/zorbit/expand.py +++ b/zorbit/expand.py @@ -83,8 +83,10 @@ def __init__(self, cfg, usage, tags, front=None, log=None, wait_recent=False): def menu(self, menu): """The menu as the ring draws it now. Nothing is saved. A paged menu (when_full "more") comes back with its - More slot; a "more:" menu comes back as it is, already filled and cut.""" - if menu.id.startswith(MORE_PREFIX): + More slot; a "more:" menu comes back as it is, already filled and cut. So does any menu this Expander has + made (its filled mark is set), whoever hands it back: App.open_menu is given what resolve made, and a second + fill would read the filled auto slots as pinned brushes and cut the saved slots down to the empty ones.""" + if menu.id.startswith(MORE_PREFIX) or getattr(menu, "filled", False): return menu return self._page(self._fill(menu)) @@ -98,7 +100,7 @@ def _fill(self, menu): if name: it = dataclasses.replace(it, label=name, kind="brush", data={"brush": name}, auto=True) items.append(self._stamp(it)) - return dataclasses.replace(menu, items=items) + return dataclasses.replace(menu, items=items, filled=True) def _page_size(self, menu) -> int: """How many slots one page of the menu holds, the More slot included; 0 when the menu is not paged.""" @@ -187,7 +189,7 @@ def tag_menu(self, key): if more: items.append(C.Item(label="More…", kind="browser", data={"browser": key, "kind": found[0][0]}, color=color)) - menu = C.Menu(id="tag:" + key, title=self.tags.label(key), items=items, fan=True) + menu = C.Menu(id="tag:" + key, title=self.tags.label(key), items=items, fan=True, filled=True) if missing: self._cache.pop(key, None) # built again next time: a rescan may find the missing items else: @@ -211,7 +213,7 @@ def recent_menu(self, kind): return None items = [C.Item(label=recent.label(p), kind=recent.item_kind(p), data={recent.item_kind(p): p}) for p in paths] - return C.Menu(id=RECENT_PREFIX + kind, title=C.RECENT_LABELS[kind], items=items, fan=True) + return C.Menu(id=RECENT_PREFIX + kind, title=C.RECENT_LABELS[kind], items=items, fan=True, filled=True) except Exception as e: # never stops a ring from opening: the slot stays empty if self.log is not None and not self._recent_said: self._recent_said = True diff --git a/zorbit/radiald.py b/zorbit/radiald.py index 8ce4414..e7a143d 100644 --- a/zorbit/radiald.py +++ b/zorbit/radiald.py @@ -101,7 +101,15 @@ ACK_HOLD_MAX_S = 30 # ... but not while the left button is down, for this long at most LEGACY_SCAN_S = 60 # how long the scan for ZBrush 2022-2025 installations is kept TIMER_WATCH, TIMER_ACK, TIMER_REHOOK, TIMER_CONFIG, TIMER_BROWSER_PICK, TIMER_PREWARM, TIMER_POPOUT_SHOW, \ - TIMER_ZS_RESULT, TIMER_COMMAND, TIMER_APPLY = range(1, 11) + TIMER_ZS_RESULT, TIMER_COMMAND, TIMER_APPLY, TIMER_RING_WARM = range(1, 12) +RING_WARM_WAIT_MS = 1000 # ring warm-up starts this long after the last change (a slider drag saves often) +RING_WARM_PICK_MS = 2000 # ... and this long after a pick that changed an auto slot +RING_WARM_STEP_MS = 10 # one picture per tick while it runs +RING_WARM_HOLD_MS = 100 # how often it looks again while something more urgent is going on +RING_WARM_TICK_S = 0.004 # pictures already there are walked past in one tick for at most this long +RING_WARM_RECENT_S = 4.0 # a plan with recent slots waits at most this long for the first recent-files answer +RING_WARM_SHARE = 0.75 # warm-up fills at most this share of each ring cache, so it never pushes out + # what it drew a moment ago, and leaves room for what is opened by hand COMMAND_WAIT_S = 2 # how long a command's pick waits for the one before it to be taken ZS_RESULT_S = 15 # how long to look for the ZScript plugin's line about a pick (a stroke delays it) RECENT_PREFETCH_S = 30 # while ZBrush is in front, the recent slots' lists are read again this often @@ -447,6 +455,12 @@ def __init__(self, cfg_path=None, target_exe="zbrush.exe", exit_with_target=Fals self._popout_zb = None # (ZBrush hwnd, its install) from when the browser was last opened self._browser_pick = None # brush Item picked in the popout, waiting for ZBrush to have focus self._browser_pick_deadline = 0.0 + self._prefetch_threads = [] # the threads still reading the menus' thumbnails (see _prefetch) + self._warm = None # the ring warm-up under way (see _warm_tick), or None + self._warm_gen = 0 # moved by every change that makes a warm-up plan out of date + self._warm_why = "start" # what asked for the next warm-up + self._warm_scale = None # the display scale the last warm-up was planned for + self._zb_monitor = None # (work area, scale) of ZBrush's monitor when it was last seen in front # --- library folders ------------------------------------------------------------------------- def _check_library_folders(self) -> None: @@ -546,6 +560,7 @@ def load_config(self, force=False, balloon=True) -> None: self.cfg = C.Config(path=self.cfg_path, mtime=0.0) self.expander = expand.Expander(self.cfg, self.usage, self.tags, front=self.target_install, log=log) self._set_slots([], None) + self._warm_soon("no menus") # drops a plan for the menus that went self._menus_problem(announce=fresh and balloon) return self._config_error = None @@ -567,6 +582,7 @@ def load_config(self, force=False, balloon=True) -> None: self._prefetch() self._prune_icons() self._schedule_prewarm() + self._warm_soon("menus loaded") # the first open finds its pictures drawn def _check_profile(self, notify=False): """Follows the profile pointer: when Studio or the tray has pointed it at another profile, switches to it.""" @@ -708,7 +724,12 @@ def _prefetch(self): if tm is not None: pairs.update(g for g in (C.asset_of(i.data) for i in tm.items) if g) size = THUMB_PX - threading.Thread(target=assets.prefetch, args=(sorted(pairs), size), daemon=True).start() + # Kept, so the ring warm-up waits for it: icon_for would otherwise decode on the main thread the same pictures + # this thread is decoding and writing to the thumbnail cache. Every one still running is kept, not only the + # newest: a second load can come while the first load's thread is still reading. + t = threading.Thread(target=assets.prefetch, args=(sorted(pairs), size), daemon=True) + self._prefetch_threads = [p for p in self._prefetch_threads if p.is_alive()] + [t] + t.start() @staticmethod def _icon_kind(src): @@ -993,6 +1014,9 @@ def _ctrl_proc(self, hwnd, msg, wparam, lparam): self._check_profile() # before load_config, so a renamed-away path is never read first self._check_library_folders() self.load_config() # a new or changed hotkey must work before any ring opens + self._check_warm_scale() + elif wparam == TIMER_RING_WARM: + self._warm_tick() elif wparam == TIMER_BROWSER_PICK: self._browser_pick_tick() elif wparam == TIMER_PREWARM: @@ -1116,8 +1140,11 @@ def _record_pick(self, item): self.usage.load() self.usage.record_pick(item.kind, item.data[item.kind]) if item.kind == "brush": # the auto slots follow brushes only - self.usage.record(item.data["brush"]) autos = [m for m in self.cfg.menus.values() if usage.auto_count(m)] + # What the rings last showed (and warm-up drew): a copy of the stored slots. Not slots(), which would fill + # an empty slot from the ranking before this pick is counted, and so change where the pick lands. + shown = [list(self.usage.menus.get(m.id, {}).get("slots", [])) for m in autos] + self.usage.record(item.data["brush"]) for m in autos: before = self.usage.slots(m) self.usage.update(m) @@ -1125,6 +1152,9 @@ def _record_pick(self, item): if after != before: log(f"auto slots of '{m.id}': {before} -> {after}") self.usage.menus = {m.id: self.usage.menus[m.id] for m in autos} + if shown != [self.usage.menus[m.id]["slots"] for m in autos]: + # an empty slot took the brush, or one gave way: warm-up walks past what it has and draws the new slots + self._warm_soon("auto slots changed", RING_WARM_PICK_MS) if self._frozen: # files of a newer ZOrbit: never written return try: @@ -1179,6 +1209,169 @@ def _on_ring_done(self, item, reason, at=None): except Exception: log("usage error:\n" + traceback.format_exc()) + # --- ring warm-up -------------------------------------------------------------------------- + # After the menus load, the pictures the next open will look for are drawn on this thread, one per timer tick, + # so the first press of a hotkey finds them: each hotkey menu (its slots, its sprites and the static layer with + # the hold hint), then the slots and lines of the fans they open, then the rings their slots open one level + # down, then the static layers with the click hint a tap leaves. It stops where a ring cache would be fuller than + # RING_WARM_SHARE. Nothing runs while a ring is open or a pick is on its way, and a hotkey waits for at most the + # one picture being drawn. Any change throws the plan away and starts a new one RING_WARM_WAIT_MS later. + def _warm_soon(self, why, wait_ms=RING_WARM_WAIT_MS): + """Drops the warm-up plan and starts a new one wait_ms after the last call, so a run of changes (Studio saves + on every step of a slider drag) starts one warm-up. A picture already there is walked past, not drawn again.""" + self._warm_gen += 1 + self._warm = None + self._warm_why = why + if self.ctrl is not None: + W.SetTimer(self.ctrl, TIMER_RING_WARM, wait_ms, None) + + def _zbrush_monitor(self): + """(work area, scale) of the monitor holding ZBrush's window when ZBrush is in front, else None. Each answer is + kept in _zb_monitor, so warm-up planned while another program is in front (Studio saving) still uses it.""" + fg = W.GetForegroundWindow() + if not fg or not self.target_in_front(): + return None + self._zb_monitor = W.monitor_for_window(fg) + return self._zb_monitor + + def _warm_target(self): + """(work area, scale) of the monitor the next ring most likely opens on: ZBrush's, in front now or where it + was last seen in front, else (ZBrush not seen yet) the primary one, which holds the point 0, 0.""" + return self._zbrush_monitor() or self._zb_monitor or W.monitor_for_point(0, 0) + + def _check_warm_scale(self): + """On the config tick: notes ZBrush's monitor while it is in front, and warms again when that monitor's scale + is not the one the last warm-up was planned for (ZBrush moved to another monitor, or the scale changed). Focus + moving to another program is no change.""" + mon = self._zbrush_monitor() + if mon is None or self._warm_scale is None or self.ctrl is None: + return + if mon[1] != self._warm_scale: + self._warm_scale = mon[1] + self._warm_soon("display scale changed") + + def _warm_busy(self, w=None) -> bool: + """Something comes first: a ring is open or its hotkey is down, a pick is on its way to ZBrush, the menus' + thumbnails are still being read, or (for a plan w with recent slots, until its deadline) the first answer about + ZBrush's recent files has not come yet, so the Recent fan is not planned empty.""" + if (self.ring is not None or self.held_vk is not None or self._ack is not None + or self._browser_pick is not None or bool(self._commands) or self._apply_watch is not None + or any(t.is_alive() for t in self._prefetch_threads)): + return True + if w is None or not w["recent"] or time.monotonic() >= w["recent_until"]: + return False + limit = self.expander._tag_limit() + return not all(recent.known(kind, limit) for kind in w["recent"]) + + def _warm_plan(self): + """A new plan, as _warm_tick keeps it.""" + work, scale = self._warm_target() + self._warm_scale = scale + kinds = {it.data.get("recent") for m in self.cfg.menus.values() for it in m.items if it.kind == "recent"} + now = time.monotonic() + return {"gen": self._warm_gen, "jobs": self._warm_jobs(work, scale), "limits": self._warm_limits(), + "want": {}, "drawn": 0, "found": 0, "ms": 0.0, "longest": 0.0, "steps": 0, "at": now, "scale": scale, + "full": False, "why": self._warm_why, + "recent": sorted(k for k in kinds if isinstance(k, str)), "recent_until": now + RING_WARM_RECENT_S} + + @staticmethod + def _warm_limits(): + """{cache: (entries, bytes)} warm-up may fill: RING_WARM_SHARE of each ring cache's bounds.""" + import ring as R + k = RING_WARM_SHARE + return {"slot": (int(R.SLOT_SPRITES * k), float("inf")), "menu": (int(R.SPRITE_MENUS * k), float("inf")), + "static": (int(R.STATIC_LAYERS * k), int(R.STATIC_BYTES * k)), + "lines": (int(R.FAN_LINE_IMAGES * k), int(R.FAN_LINE_BYTES * k))} + + def _warm_jobs(self, work, scale): + """The warm-up plan as Ring.warm_jobs jobs, most wanted first, each ring built the way open_menu builds it.""" + from ring import Ring + resolve = self.expander.resolve + + def ring_of(menu, nested=False): + return Ring.for_warmup(self.expander.menu(menu), self.cfg.ring, self.icon_for, resolve, work, scale, nested) + hot, seen = [], set() + for m in self.slots: # the hotkey menus, in full + if m.id in seen: + continue + seen.add(m.id) + r = ring_of(m) + hot.append(r) + yield from r.warm_jobs("menu") + for r in hot: # their fans + yield from r.warm_jobs("fans") + subs = [] + for r in hot: # the rings their slots open, one level down + for mid in r.ring_submenus(): + if mid in seen: + continue + seen.add(mid) + sub = resolve(mid) + if sub is None or not sub.items: + continue + s = ring_of(sub, nested=True) + subs.append(s) + yield from s.warm_jobs("menu") + for r in hot + subs: # the click hint, as a tap leaves it + yield from r.warm_jobs("click") + + def _warm_tick(self): + """One warm-up step: plans when the wait is over, then draws one picture (walking past those already there), + and kills its timer when the plan is done or the caches would hold no more.""" + w = self._warm + if w is not None and w["gen"] != self._warm_gen: # made for menus that have changed since + w = self._warm = None + if w is None and (self.cfg is None or not self.slots or self.expander is None): + W.KillTimer(self.ctrl, TIMER_RING_WARM) + return + t0 = time.perf_counter() + done = False + try: + if w is None: + w = self._warm = self._warm_plan() + if self._warm_busy(w): + W.SetTimer(self.ctrl, TIMER_RING_WARM, RING_WARM_HOLD_MS, None) + return + W.SetTimer(self.ctrl, TIMER_RING_WARM, RING_WARM_STEP_MS, None) + t0 = time.perf_counter() + while True: + job = next(w["jobs"], None) + if job is None: + done = True + break + cache, key, size, run = job + want = w["want"].setdefault(cache, {}) + if key not in want: + entries, budget = w["limits"][cache] + if len(want) + 1 > entries or sum(want.values()) + size > budget: + w["full"] = done = True + break + want[key] = size + if run(): + w["drawn"] += 1 + break + w["found"] += 1 + if time.perf_counter() - t0 > RING_WARM_TICK_S: + break + except Exception: # planning or a picture failed: said once, and the plan goes + log("ring warm-up error; the rest of this warm-up is dropped:\n" + traceback.format_exc()) + W.KillTimer(self.ctrl, TIMER_RING_WARM) + self._warm = None + return + ms = (time.perf_counter() - t0) * 1000 + w["ms"] += ms + w["longest"] = max(w["longest"], ms) + w["steps"] += 1 + if not done: + return + W.KillTimer(self.ctrl, TIMER_RING_WARM) + self._warm = None + if w["drawn"] or w["full"]: + log(f"ring warm-up ({w['why']}): {w['drawn']} pictures drawn, {w['found']} already there, " + f"{w['ms']:.0f} ms in {w['steps']} steps (longest {w['longest']:.0f} ms) over " + f"{time.monotonic() - w['at']:.1f} s at scale {w['scale']:.2f}" + + ("; stopped where the ring caches would hold no more" if w["full"] else "")) + # --- browser popout ------------------------------------------------------------------------ @property def browser_key(self): @@ -1946,6 +2139,7 @@ def _serve(self, mutex, hold) -> int: return 1 self._create_ctrl() self._schedule_prewarm() + self._warm_soon("start") # the load above came before the window its timer needs self.start_hook() W.SetTimer(self.ctrl, TIMER_WATCH, 3000, None) W.SetTimer(self.ctrl, TIMER_REHOOK, 120000, None) diff --git a/zorbit/ring.py b/zorbit/ring.py index 9942395..03b1445 100644 --- a/zorbit/ring.py +++ b/zorbit/ring.py @@ -15,7 +15,8 @@ All of it is drawn into the sprites when they are built, so a frame costs the same as before. Every disc is drawn before any label, and a label sits on a soft pill in the theme's colour, so a neighbouring disc never hides a name. The radius is a minimum: a ring with many items grows until neighbouring slots are SLOT_GAP slot widths apart. -Sprites are kept between opens (_sprite_cache), so a menu opened again starts at once. +Sprites are kept between opens (_sprite_cache), and so are the static layer of guide rings, spokes, title and hint +(_static_layers) and the dashed lines of fans (_fan_line_cache), so a menu opened again starts at once. Selection is by angle outside a dead zone; a slot covers at most a quarter of the circle, so a ring of one to three slots has gaps between them. @@ -49,6 +50,7 @@ import threading import time import traceback +import weakref from collections import OrderedDict from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageFont @@ -79,6 +81,15 @@ SLOT_SPRITES = 160 # single slots kept, so a menu that changed by one slot redraws only that slot _slot_cache = OrderedDict() # key (see Ring._slot_key) -> (slot sprites, label sprites), least recently used first _sprite_lock = threading.Lock() +# The static layers kept between opens (Ring._static_key), by their pixels: room for the hold and click layers of three +# 16-slot hotkey rings at 150% (about 27 MB with what warm-up leaves free) or of the starter menus at 200% ... +STATIC_BYTES = 32 * 2**20 +# ... and by count, which is only a backstop: the smallest layer, a ring at its minimum radius at 100%, is about 0.8 MB, +# so the bytes run out at about 42 layers, before this. +STATIC_LAYERS = 48 +FAN_LINE_BYTES = 4 * 2**20 # the same for the dashed lines of fans (Ring._fan_lines) +FAN_LINE_IMAGES = 32 +ICON_SIGS = 1024 # icon signatures remembered (_icon_sig); each entry lives only as long as its image _class_registered = False _wndproc_ref = None try: @@ -237,11 +248,74 @@ def _sprite_put(key, val): _sprite_cache.popitem(last=False) +class _ImageLRU: + """Pictures kept between opens, least recently used first, within a pixel budget and a count. The newest entry + always stays, however big. Values are read only: a caller that draws on one must draw on a copy. The key is the + whole of what the picture is drawn from, so a change of any input is a new entry, never a stale one.""" + + def __init__(self, max_bytes, max_entries): + self.max_bytes, self.max_entries = max_bytes, max_entries + self._d = OrderedDict() # key -> (value, bytes) + self.bytes = 0 + + def __len__(self): + return len(self._d) + + def __contains__(self, key): # does not count as a use + return key in self._d + + def get(self, key): + with _sprite_lock: + got = self._d.get(key) + if got is None: + return None + self._d.move_to_end(key) + return got[0] + + def put(self, key, val, nbytes): + with _sprite_lock: + old = self._d.pop(key, None) + if old is not None: + self.bytes -= old[1] + self._d[key] = (val, nbytes) + self.bytes += nbytes + while len(self._d) > 1 and (len(self._d) > self.max_entries or self.bytes > self.max_bytes): + self.bytes -= self._d.popitem(last=False)[1][1] + + def clear(self): + with _sprite_lock: + self._d.clear() + self.bytes = 0 + + +_static_layers = _ImageLRU(STATIC_BYTES, STATIC_LAYERS) # Ring._static_key -> the static layer +_fan_line_cache = _ImageLRU(FAN_LINE_BYTES, FAN_LINE_IMAGES) # Ring._fan_lines' key -> (image, top-left) +_icon_sigs = {} # id(image) -> (weak reference to the image, its signature) + + def _icon_sig(im): - """A short fingerprint of an icon image: same picture, same signature, however many objects hold it.""" + """A short fingerprint of an icon image: same picture, same signature, however many objects hold it. Worked out + once per image object (icons are never drawn on: the ring draws copies), so a ring that opens again does not hash + every icon again. A replaced icon is another object and gets its own.""" if im is None: return None - return (im.size, im.mode, hashlib.blake2b(im.tobytes(), digest_size=8).hexdigest()) + got = _icon_sigs.get(id(im)) + if got is not None and got[0]() is im: + return got[1] + sig = (im.size, im.mode, hashlib.blake2b(im.tobytes(), digest_size=8).hexdigest()) + if len(_icon_sigs) >= ICON_SIGS: + _icon_sigs.clear() + key = id(im) + + def gone(ref, key=key): # the image was freed: its id may be given to another one + got = _icon_sigs.get(key) + if got is not None and got[0] is ref: + _icon_sigs.pop(key, None) + try: + _icon_sigs[key] = (weakref.ref(im, gone), sig) + except TypeError: # not weakly referable: just not remembered + pass + return sig def _shorten(draw, text, font, maxw): @@ -431,6 +505,18 @@ def _blit(canvas, im, x, y): canvas.alpha_composite(im, (x + l, y + t), (l, t, r, b)) +def _premultiplied_bgra(im): + """An RGBA frame as the premultiplied BGRA bytes UpdateLayeredWindow wants. Pillow's BGRa packer premultiplies + while it packs (it rounds a channel times alpha slightly differently from ImageChops.multiply, by 1 at most); + a Pillow without it gets the split, multiply and merge.""" + try: + return im.tobytes("raw", "BGRa") + except ValueError: + r, g, b, a = im.split() + r, g, b = (ImageChops.multiply(ch, a) for ch in (r, g, b)) + return Image.merge("RGBA", (r, g, b, a)).tobytes("raw", "BGRA") + + def _glyph_tag(draw, cx, cy, size, fill): """A luggage tag (outline, a ring hole near the blunt end) pointing up and right, size across.""" a = math.radians(-45) @@ -611,6 +697,8 @@ def __init__(self, menu, ring_cfg: dict, icon_for, on_done, log, held: bool, nes self._dib = None self._last_key = None self._base_cache = None + self._static_cache = None # the static layer this ring draws (see _static) + self._blend_cache, self._resized = {}, {} # made again by _prepare self._icons = {} # id(item) -> its icon image, for _sprite_key and the sprites # --- public ---------------------------------------------------------------------------------- @@ -720,11 +808,7 @@ def r2(v): def show(self, x: int, y: int) -> None: _register_class() work, scale = W.monitor_for_point(x, y) - self._geometry(scale) - self.cs, self.work = (x, y), work - self._fit_centre() - self._armed = self.cs == (x, y) # moved in from an edge: see _fit_centre - self._relayout() + self._place(x, y, work, scale) self._prepare() self.t0 = time.time() ex = W.WS_EX_NOACTIVATE | W.WS_EX_TOPMOST | W.WS_EX_TOOLWINDOW | W.WS_EX_LAYERED @@ -784,6 +868,15 @@ def cancel(self, reason="cancelled") -> None: self._finish(None, reason) # --- geometry -------------------------------------------------------------------------------- + def _place(self, x, y, work, scale): + """Sizes, layout and window for a ring centred on screen point (x, y) of a monitor with this work area + and scale: what show() does before it draws, and what warm-up does to find the same pictures.""" + self._geometry(scale) + self.cs, self.work = (x, y), work + self._fit_centre() + self._armed = self.cs == (x, y) # moved in from an edge: see _fit_centre + self._relayout() + def _geometry(self, scale): c = self.look self.scale = scale @@ -1130,6 +1223,89 @@ def _warm_step(self): return True return False + # --- warm-up: the app draws what the next open will look for before the hotkey is pressed ------------------ + @classmethod + def for_warmup(cls, menu, ring_cfg, icon_for, submenus, work, scale, nested=False): + """A ring set up as show() sets it up in the middle of a monitor's work area, with no window and no log, for + warm_jobs. It is held, as a hotkey ring opens, so its first hint is the hold one.""" + r = cls(menu, ring_cfg, icon_for, lambda *a: None, lambda m: None, held=True, nested=nested, submenus=submenus) + r._place((work.left + work.right) // 2, (work.top + work.bottom) // 2, work, scale) + r._ready() + return r + + def ring_submenus(self): + """Ids of the submenus this ring's slots open as rings of their own (not as fans), in slot order.""" + return [it.data["menu"] for i, it in enumerate(self.items) + if it.kind == "menu" and isinstance(it.data.get("menu"), str) and not self._fannable((i,))] + + def warm_jobs(self, part): + """The pictures an open of this ring looks for, one job at a time: (cache, key, bytes, run). cache is where + the picture goes ("slot", "menu", "static" or "lines"), key its key there, bytes what it takes of a budget + in bytes (0 where only a count is bounded), and run() finds or draws it through the same code an open uses + and says whether it had to be drawn. A job is handed out only after the one before it has run, since a fan + is laid out from the fans it hangs from. part: "menu" (each slot, the menu's sprites, then the static layer + with the hint it opens with), "fans" (the slots and lines of each fan reachable from it), or "click" (the + static layer with the click hint, as a tap leaves it).""" + if part == "menu": + for i, it in enumerate(self.items): + key = self._slot_key(it, self.sizes[i]) + yield "slot", key, 0, (lambda key=key, it=it, i=i: self._warm_slot( + key, lambda: self._slot_parts(it, self.sizes[i]))) + key = self._sprite_key() + yield "menu", key, 0, (lambda key=key: self._warm_sprites(key)) + yield self._warm_static() + elif part == "click": + self.mode = "click" + self._static_cache = None + yield self._warm_static() + elif part == "fans": + todo = [(i,) for i in range(self.n)] + while todo: + path = todo.pop(0) + if not self._fannable(path): + continue + menu = self._submenu(self._item(path).data["menu"]) + sizes, look = self._fan_sizes(menu), self._look_for(menu) + for k, it in enumerate(menu.items): + key = self._slot_key(it, sizes[k], look) + yield "slot", key, 0, (lambda key=key, menu=menu, k=k: self._warm_fan_slot(key, menu, k)) + anchor, pos = self._node_pos(path), self._layout(path)[3] + key = self._fan_lines_key(anchor, self._size_at(path) / 2, pos, sizes, + _chrome(self._item(path), self._look_at(path)), look) + w, h = self._fan_lines_box(anchor, pos)[2:] + yield "lines", key, w * h * 4, (lambda key=key, path=path: self._warm_fan(key, path)) + todo += [path + (k,) for k in range(len(menu.items))] # _fannable stops at FAN_DEPTH + + def _warm_slot(self, key, make): + hit = key in _slot_cache + _slot_get(key, make) + return not hit + + def _warm_fan_slot(self, key, menu, k): + hit = key in _slot_cache + self._fan_art(menu, upto=k + 1) # slot k of the fan, the way an open of the fan asks for it + return not hit + + def _warm_sprites(self, key): + hit = key in _sprite_cache + self._prepare() # finds the slots warm_jobs drew, and keeps the menu's sprites + return not hit + + def _warm_static(self): + key = self._static_key() + + def run(): + hit = key in _static_layers + self._static_cache = None + self._static() + return not hit + return "static", key, self.rsize * self.rsize * 4, run + + def _warm_fan(self, key, path): + hit = key in _fan_line_cache + self._fan(path) # its slots are drawn by then, so only the lines can be new + return not hit + def _set_open(self, path, now): path, cur = tuple(path), tuple(self.open) if path == cur: @@ -1196,20 +1372,26 @@ def _flicked(self, path, flick, hover_sub): return True # --- sprites --------------------------------------------------------------------------------- - def _prepare(self): + def _ready(self): + """The colours and the glyph font the sprites and the static layer are drawn with.""" lk = self.look self.c_bg, self.c_accent, self.c_hi = lk.bg, lk.accent, lk.hi self.c_text, self.c_lines, self.c_hub = lk.text, lk.lines, lk.hub_color self.pre = lk.pre # the theme's pill, hint, badge, placeholder self.f_glyph = _font(int(15 * self.scale * SS)) + + def _slot_parts(self, it, size, look=None): + """One slot's (sprites, label sprites), for _slot_get.""" + return self._slot_sprites(it, size, look), self._label_sprites(it, size, look) + + def _prepare(self): + self._ready() t0 = time.perf_counter() key = self._sprite_key() cached = _sprite_get(key) hit = cached is not None if not hit: - per = [_slot_get(self._slot_key(it, self.sizes[i]), - lambda it=it, i=i: (self._slot_sprites(it, self.sizes[i]), - self._label_sprites(it, self.sizes[i]))) + per = [_slot_get(self._slot_key(it, self.sizes[i]), lambda it=it, i=i: self._slot_parts(it, self.sizes[i])) for i, it in enumerate(self.items)] cached = ([p[0] for p in per], [p[1] for p in per], self._dot_sprite()) _sprite_put(key, cached) @@ -1220,6 +1402,7 @@ def _prepare(self): self._base_cache = None self.hv = [[0.0, 0.0, 0.0] for _ in self.items] # per slot: from, to, t0 self._blend_cache = {} + self._resized = {} # per slot: (picture, pixel size, it resized) def _icon(self, it): """The icon image of an item (or None), asked of icon_for once per ring.""" @@ -1489,15 +1672,33 @@ def _hub_face(self, im, L, c, ins): def _fan_lines(self, anchor, anchor_r, pos, sizes, color="", look=None): """Dashed lines from a fan's slot out to each of its slots (sizes: their widths), in color if it has one, - else the lines colour of the fan's look: (image, top-left relative to the ring centre).""" - s = SS + else the lines colour of the fan's look: (image, top-left relative to the ring centre). Kept between opens + (_fan_line_cache), keyed on everything they are drawn from; the image is shared, so it is only ever read.""" + key = self._fan_lines_key(anchor, anchor_r, pos, sizes, color, look) + got = _fan_line_cache.get(key) + if got is None: + got = self._draw_fan_lines(anchor, anchor_r, pos, sizes, key[-1]) + _fan_line_cache.put(key, got, got[0].size[0] * got[0].size[1] * 4) + return got + + def _fan_lines_key(self, anchor, anchor_r, pos, sizes, color="", look=None): + """What _fan_lines draws from; its last part is the RGBA colour.""" + col = _rgba(color or (look or self.look).lines, 0.75) + return SS, self.scale, tuple(anchor), anchor_r, tuple(tuple(p) for p in pos), tuple(sizes), col + + def _fan_lines_box(self, anchor, pos): + """(left, top, width, height) of a fan's lines image, relative to the ring centre.""" m = 4 * self.scale xs, ys = [anchor[0]] + [p[0] for p in pos], [anchor[1]] + [p[1] for p in pos] x0, y0 = math.floor(min(xs) - m), math.floor(min(ys) - m) - w, h = math.ceil(max(xs) + m) - x0, math.ceil(max(ys) + m) - y0 + return x0, y0, math.ceil(max(xs) + m) - x0, math.ceil(max(ys) + m) - y0 + + def _draw_fan_lines(self, anchor, anchor_r, pos, sizes, col): + """_fan_lines drawn anew, in the RGBA colour col.""" + s = SS + x0, y0, w, h = self._fan_lines_box(anchor, pos) im = Image.new("RGBA", (w * s, h * s), (0, 0, 0, 0)) d = ImageDraw.Draw(im) - col = _rgba(color or (look or self.look).lines, 0.75) lw = max(1, int(1.3 * self.scale * s)) dash, gap = 5 * self.scale, 4 * self.scale ax, ay = anchor @@ -1515,10 +1716,34 @@ def _fan_lines(self, anchor, anchor_r, pos, sizes, color="", look=None): return im.resize((w, h), Image.LANCZOS), (x0, y0) def _static(self): - """Guide rings, spokes, title and hint (no dot, no slots) on the ring's own square. Cached per hint - text.""" - if self._static_cache is not None: - return self._static_cache + """Guide rings, spokes, title and hint (no dot, no slots) on the ring's own square. Kept on the ring until + its hint or armed state changes, and between opens in _static_layers under _static_key, so a ring opened + again does not draw it again. The image is shared: it is only ever read.""" + if self._static_cache is None: + key = self._static_key() + im = _static_layers.get(key) + if im is None: + im = self._draw_static() + _static_layers.put(key, im, im.size[0] * im.size[1] * 4) + self._static_cache = im + return self._static_cache + + def _static_key(self): + """Everything _draw_static, _band and _caption read, as a hashable value: the sizes and geometry, each slot's + rim colour and whether it is empty, a submenu or a tag slot (the only kinds the layer tells apart, so an auto + slot that fills with a brush keeps its layer), the tag band settings, the hub, the colours, the title, the + hint as it reads now (the hint setting, flick, hold or click, armed) and the fonts folder.""" + lk = self.look + return (SS, _fonts_dir(), self.rsize, self.scale, self.slot, self.R, self.hit_r, + tuple(self.seats), tuple(self.rad), tuple(self.sizes), tuple(s.ring for s in self.plan.seats), + tuple((rp.radius, rp.step, rp.n, rp.first, rp.offset) for rp in self.plan.rings), + tuple((it.kind if it.kind in ("empty", "menu", "tag") else "", _chrome(it, lk)) for it in self.items), + lk.tag_band, lk.tag_band_opacity, lk.hub_style, self._hub(), + self.c_text, self.c_lines, self.c_hi, self.c_accent, self.pre["hint"], + self.menu.title, tuple(self._hint_lines())) + + def _draw_static(self): + """The static layer drawn anew (see _static).""" s = SS S = self.rsize * s cx = cy = self.rsize // 2 * s @@ -1550,8 +1775,7 @@ def _static(self): for text, font, dy, colr in self._caption(d): l, t, r_, b = d.textbbox((0, 0), text, font=font) d.text((cx - (r_ - l) / 2 - l, cy + dy * self.scale * s - (b - t) / 2 - t), text, font=font, fill=colr) - self._static_cache = im.resize((self.rsize, self.rsize), Image.LANCZOS) - return self._static_cache + return im.resize((self.rsize, self.rsize), Image.LANCZOS) def _hint_lines(self): """The hint for the ring's state as candidates, longest first: each a tuple of lines. None when the @@ -1805,7 +2029,15 @@ def _draw_disc(self, canvas, key, sprites, px, py, sc, alpha, h, rot, blur): w0 = spr.size[0] wk = max(1, int(w0 * k)) if wk != w0: - spr = spr.resize((wk, wk), Image.BILINEAR) + # A slot keeps its size from frame to frame unless it is growing, shrinking or easing its hover, and a + # settled one is still drawn at 1/1.12 of its sprite: the last resize is kept per slot and reused while + # the picture and the pixel size are the same. + last = self._resized.get(key) + if last is not None and last[0] is spr and last[1] == wk: + spr = last[2] + else: + src, spr = spr, spr.resize((wk, wk), Image.BILINEAR) + self._resized[key] = (src, wk, spr) if rot: spr = spr.rotate(rot, resample=Image.BILINEAR) if blur > 0.3: @@ -1830,10 +2062,8 @@ def _render(self, now): def _push(self, im): """Premultiply and hand the frame to Windows via UpdateLayeredWindow (which also moves and resizes - the window when the open fans change).""" - r, g, b, a = im.split() - r, g, b = (ImageChops.multiply(ch, a) for ch in (r, g, b)) - data = Image.merge("RGBA", (r, g, b, a)).tobytes("raw", "BGRA") + the window when the open fans change). Pillow premultiplies as it packs the bytes, in one pass.""" + data = _premultiplied_bgra(im) w, h = self.size screen = W.GetDC(None) try: diff --git a/zorbit/win32.py b/zorbit/win32.py index 06639c1..36398fb 100644 --- a/zorbit/win32.py +++ b/zorbit/win32.py @@ -159,6 +159,7 @@ class PROCESSENTRY32W(ctypes.Structure): GetRawInputData = _sig(user32, "GetRawInputData", w.UINT, w.HANDLE, w.UINT, ctypes.c_void_p, ctypes.POINTER(w.UINT), w.UINT) MonitorFromPoint = _sig(user32, "MonitorFromPoint", w.HMONITOR, w.POINT, w.DWORD) +MonitorFromWindow = _sig(user32, "MonitorFromWindow", w.HMONITOR, w.HWND, w.DWORD) GetMonitorInfoW = _sig(user32, "GetMonitorInfoW", w.BOOL, w.HMONITOR, ctypes.POINTER(MONITORINFO)) try: SetProcessDpiAwarenessContext = _sig(user32, "SetProcessDpiAwarenessContext", w.BOOL, ctypes.c_void_p) @@ -537,7 +538,15 @@ def launch_chain(depth: int = 2) -> str: def monitor_for_point(x: int, y: int) -> tuple[w.RECT, float]: """Work area of the monitor under (x, y) and its DPI scale (1.0 = 96 dpi).""" - hmon = MonitorFromPoint(w.POINT(x, y), MONITOR_DEFAULTTONEAREST) + return _monitor(MonitorFromPoint(w.POINT(x, y), MONITOR_DEFAULTTONEAREST)) + + +def monitor_for_window(hwnd) -> tuple[w.RECT, float]: + """The same for the monitor that holds most of the window hwnd.""" + return _monitor(MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST)) + + +def _monitor(hmon) -> tuple[w.RECT, float]: mi = MONITORINFO() mi.cbSize = ctypes.sizeof(MONITORINFO) GetMonitorInfoW(hmon, ctypes.byref(mi))