Last active
July 18, 2025 09:13
-
-
Save cheery/8ec957d85546f0f0f1384896a755b225 to your computer and use it in GitHub Desktop.
jetpack compose style UI tree
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from dataclasses import dataclass, field | |
from typing import List, Dict, Optional, Callable, Tuple, Any, Set, Union | |
from gui.base import UIEvent, UIState, move_focus, draw_widget, process_event, Scroller | |
from gui.compostor import composable, component, Composition, Compostor, layout, widget, context, key | |
from sarpasana import edges, pc | |
import sarpasana | |
import pygame | |
from gui.components import * | |
# Add documentation for sarpasana | |
# try to make a scroll bar | |
# try to make a context free menu | |
# try to make a popup screen | |
greet = UIEvent("greet") | |
hold = UIEvent("hold") | |
hack = UIEvent("hack") | |
tab = UIEvent("tab") | |
SCREEN_WIDTH = 800 | |
SCREEN_HEIGHT = 600 | |
pygame.init() | |
font = pygame.font.SysFont('Arial', 16) | |
screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) | |
clock = pygame.time.Clock() | |
@dataclass(eq=False) | |
class UIContext: | |
font : pygame.font.Font | |
tab : UIEvent | |
uicontext = UIContext(font, tab) | |
@composable | |
def foobar(): | |
@widget().attach | |
def _draw_hallo_(this, frame): | |
text = font.render("HELLO", True, (200,200,200)) | |
frame.screen.blit(text, frame.rect.topleft) | |
layout().style_width = 150.0 | |
layout().style_height = 15.0 | |
layout().style_margin = edges(left=10.0, right=10.0) | |
@composable | |
def hello(i): | |
layout().style_flex_direction = "row" | |
layout().style_min_height = 15.0 | |
layout().style_margin = edges(left=10.0, right=10.0) | |
for i in range(5): | |
with button(greet, hold, hack): | |
foobar() | |
foobar() | |
@composable | |
def inner_container(n): | |
layout().style_flex_direction = "column" | |
layout().style_overflow = "visible" | |
hello(-1) | |
for i in range(n + 12): | |
hello(i) | |
hello(-2) | |
@composable | |
def outer_container(n): | |
layout().style_flex_direction = "row" | |
widget().mouse_hit_rect = False | |
inner_container(n) | |
widget().scroller = Scroller(0, 0, widget()[0]) | |
@composable | |
def scroll_view(n): | |
@widget().attach | |
def _clip_contents_(this, frame): | |
sc = this.scroller | |
frame.screen.set_clip(frame.rect) | |
outer_container(n) | |
#layout().style_height = 25*pc | |
layout().style_flex_shrink = 1 | |
@widget().attach | |
def _clip_contents_(this, frame): | |
frame.screen.set_clip(None) | |
def intro(n, textfields): | |
layout().style_flex_direction = "column" | |
foobar() | |
foobar() | |
foobar() | |
scroll_view(n) | |
for textfield in textfields: | |
textbox(textfield) | |
def _key_down_(this, frame): | |
mods = pygame.key.get_mods() | |
shift_held = mods & pygame.KMOD_SHIFT | |
if frame.ev.key == pygame.K_TAB: | |
frame.emit(tab(shift_held)) | |
widget().at_keydown = _key_down_ | |
edit_textfield = UIEvent("edit_textfield") | |
textfield = TextField("hello", 0, 0, edit_textfield) | |
edit_textfield2 = UIEvent("edit_textfield2") | |
textfield2 = TextField("hello", 0, 0, edit_textfield2) | |
compostor = Compostor(intro, uicontext) | |
root = compostor(5, (textfield, textfield2)) | |
root.calculate_layout(SCREEN_WIDTH, SCREEN_HEIGHT, "ltr") | |
#root = compostor(8, textfield) | |
#root.calculate_layout(SCREEN_WIDTH, SCREEN_HEIGHT, "ltr") | |
# | |
#print(root) | |
# widget = compostor(5) | |
# widget.calculate_layout(500, 500, "ltr") | |
# print(widget) | |
# widget = compostor(2) | |
# widget.calculate_layout(750, 500, "ltr") | |
#print(widget) | |
# https://github.com/emilk/egui | |
#Imgui taasen liittyy siihen miten käyttöliittymän toiminnallista tietoa kannattaisi säilyttää, siinä ajatus on että se tieto keskitetään ja siirretään ulos tuosta puurakenteesta. (samalla saadaan käyttöliittymä esitettyä sarjana komentoja). | |
# https://stackoverflow.com/questions/51662877/are-there-any-basic-immediate-mode-gui-tutorials | |
ui = UIState(root) | |
running = True | |
while running: | |
dt = clock.tick(30) / 1000.0 | |
for ev in pygame.event.get(): | |
if ev.type == pygame.QUIT: | |
running = False | |
else: | |
for t in process_event(ui, root, ev, screen.get_rect()): | |
if t.match(greet): | |
print("hello world") | |
if t.match(hold): | |
print("hello wooorld") | |
if t.match(hack): | |
print("hello ******") | |
if t.match(edit_textfield): | |
textfield = TextField(*(t.args + (textfield.edit,))) | |
root = compostor(5, (textfield, textfield2)) | |
root.calculate_layout(SCREEN_WIDTH, SCREEN_HEIGHT, "ltr") | |
ui.widget = root | |
if t.match(edit_textfield2): | |
textfield2 = TextField(*(t.args + (textfield2.edit,))) | |
root = compostor(5, (textfield, textfield2)) | |
root.calculate_layout(SCREEN_WIDTH, SCREEN_HEIGHT, "ltr") | |
ui.widget = root | |
if t.match(tab): | |
move_focus(ui, root, screen.get_rect(), t.args[0]) | |
screen.fill((30, 30, 30)) | |
draw_widget(ui, root, screen, screen.get_rect()) | |
pygame.display.flip() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from collections import defaultdict | |
from dataclasses import dataclass, field | |
from typing import List, Dict, Optional, Callable, Tuple, Any, Set, Union | |
import pygame | |
import sarpasana | |
import itertools | |
@dataclass(eq=False, frozen=True) | |
class UIEvent: | |
name : str | |
def __repr__(self): | |
return f"UIEvent:{self.name}" | |
def __call__(self, *args): | |
return UIEventTuple(self, args) | |
def match(self, this): | |
return self is this | |
@dataclass(eq=True, frozen=True) | |
class UIEventTuple: | |
event : UIEvent | |
args : Tuple[Any] | |
def __call__(self, *args): | |
return UIEventTuple(self, self.args + args) | |
def match(self, this): | |
return self.event is this | |
class UIState: | |
def __init__(self, root): | |
self.focus = (root.site, None) | |
self.pressed = (root.site, None) | |
self.pointer = (0, 0) | |
self.events = [] | |
self.mouse_tool = None | |
self.keyboard_tool = None | |
class UIFrame: | |
def emit(self, event): | |
self.ui.events.append(event) | |
def same(self, site_vector): | |
i = -1 | |
for i, key in enumerate(self.site_vector): | |
if i < len(site_vector) and site_vector[i] == key: | |
continue | |
return False | |
return i+1 == len(site_vector) | |
def focus(self, keyboard_tool = None): | |
self.ui.focus = self.site_vector | |
self.ui.keyboard_tool = keyboard_tool | |
def press(self, mouse_tool = None): | |
self.ui.pressed = self.site_vector | |
self.ui.mouse_tool = mouse_tool | |
@property | |
def inside(self): | |
return self.rect.collidepoint(self.ui.pointer) | |
@dataclass(eq=False, frozen=True) | |
class EventFrame(UIFrame): | |
ui : UIState | |
ev : Optional[pygame.event.Event] | |
pointer : Tuple[float, float] | |
rect : pygame.Rect | |
site_vector : Tuple[Any] = None | |
def move(self, rect, scroller, site): | |
pointer = self.pointer[0] - rect.left, self.pointer[1] - rect.top | |
rect = rect.move(self.rect.topleft) | |
if scroller: | |
sx = scroller.x(self.rect.width) | |
sy = scroller.y(self.rect.height) | |
pointer = pointer[0] + sx, pointer[1] + sy | |
rect = rect.move((-sx, -sy)) | |
site_vector = (site, self.site_vector) | |
return EventFrame(self.ui, self.ev, pointer, rect, site_vector) | |
@dataclass(eq=False, frozen=True) | |
class DrawFrame(UIFrame): | |
ui : UIState | |
screen : pygame.surface.Surface | |
rect : pygame.Rect | |
site_vector : Tuple[Any] = None | |
def move(self, rect, scroller, site): | |
rect = rect.move(self.rect.topleft) | |
site_vector = (site, self.site_vector) | |
if scroller: | |
sx = scroller.x(self.rect.width) | |
sy = scroller.y(self.rect.height) | |
rect = rect.move((-sx, -sy)) | |
return DrawFrame(self.ui, self.screen, rect, site_vector) | |
class NoCapture(Exception): | |
pass | |
def no_capture(this, frame): | |
raise NoCapture | |
def no_action(this, frame): | |
pass | |
class Widget(sarpasana.Node): | |
def __init__(self, site): | |
super().__init__() | |
self.site = site | |
self.drawables = defaultdict(list) | |
self.pre_mousebuttondown = no_capture | |
self.post_mousebuttondown = no_capture | |
self.at_mousemotion = no_action | |
self.at_mousebuttonup = no_action | |
self.focusable = 0 | |
self.at_focus = no_action | |
self.at_keydown = no_action | |
self.at_keyup = no_action | |
self.at_textinput = no_action | |
self.scroller = None | |
self.mouse_hit_rect = True | |
def debug_str(self, indent): | |
header = " "*indent + f"{type(self).__name__} {self.left, self.top, self.width, self.height} {self.site}" | |
return "\n".join([header] + [x.debug_str(indent+2) if x is not None else " "*indent + " None" for x in self]) | |
@property | |
def rect(self): | |
return pygame.Rect(self.left, self.top, self.width, self.height) | |
def attach(self, drawable): | |
self.drawables[len(self)].append(drawable) | |
def draw(self, frame): | |
frame = frame.move(self.rect, self.scroller, self.site) | |
for i, widget in enumerate(self): | |
for drawable in self.drawables[i]: | |
drawable(self, frame) | |
widget.draw(frame) | |
for drawable in self.drawables[len(self)]: | |
drawable(self, frame) | |
def mousebuttondown(self, frame): | |
if self.mouse_hit_rect and not self.rect.collidepoint(frame.pointer): | |
return False | |
frame = frame.move(self.rect, self.scroller, self.site) | |
try: | |
self.pre_mousebuttondown(self, frame) | |
return True | |
except NoCapture: | |
for child in reversed(self): | |
if child.mousebuttondown(frame): | |
return True | |
try: | |
self.post_mousebuttondown(self, frame) | |
return True | |
except NoCapture: | |
return False | |
def fetch(self, site_vector, frame): | |
frame = frame.move(self.rect, self.scroller, self.site) | |
if frame.same(site_vector): | |
return self, frame | |
else: | |
for child in self: | |
if (res := child.fetch(site_vector, frame)) is not None: | |
return res | |
def focusables(self, frame): | |
frame = frame.move(self.rect, self.scroller, self.site) | |
if self.focusable: | |
yield self, frame | |
for child in self: | |
yield from child.focusables(frame) | |
import time, math | |
@dataclass | |
class Scroller: | |
pc_x : float | |
pc_y : float | |
inner_container : Widget | |
def x(self, width): | |
t = math.sin(time.monotonic()) * 0.5 + 0.5 | |
return t * max(0, self.inner_container.width - width) | |
def y(self, height): | |
t = math.cos(time.monotonic()) * 0.5 + 0.5 | |
return t * max(0, self.inner_container.height - height) | |
def move_focus(ui, root, rect, reverse=False): | |
it = root.focusables(EventFrame(ui, None, ui.pointer, rect)) | |
if reverse: | |
it = reversed(list(it)) | |
top = (root.site, None) | |
if ui.focus == top: | |
if wf := next(it, None): | |
wf[1].focus(wf[0].at_focus(*wf)) | |
else: | |
it = itertools.dropwhile(lambda x: not x[1].same(ui.focus), it) | |
next(it, None) | |
if wf := next(it, None): | |
wf[1].focus(wf[0].at_focus(*wf)) | |
else: | |
wf = root, EventFrame(ui, None, ui.pointer, rect).move(root.rect, root.scroller, root.site) | |
wf[1].focus(wf[0].at_focus(*wf)) | |
def draw_widget(ui, root, screen, rect): | |
root.draw(DrawFrame(ui, screen, rect)) | |
def process_event(ui, root, ev, rect): | |
ui.events.clear() | |
if ev.type == pygame.KEYDOWN: | |
frame = EventFrame(ui, ev, ui.pointer, rect) | |
if (wf := root.fetch(ui.focus, frame)) is not None: | |
if wf[0].focusable & 1 or wf[0] is root: | |
wf[0].at_keydown(*wf) | |
elif ev.type == pygame.KEYUP: | |
frame = EventFrame(ui, ev, ui.pointer, rect) | |
if (wf := root.fetch(ui.focus, frame)) is not None: | |
if wf[0].focusable & 1 or wf[0] is root: | |
wf[0].at_keyup(*wf) | |
elif ev.type == pygame.TEXTINPUT: | |
frame = EventFrame(ui, ev, ui.pointer, rect) | |
if (wf := root.fetch(ui.focus, frame)) is not None: | |
if wf[0].focusable & 2 or wf[0] is root: | |
wf[0].at_textinput(*wf) | |
elif ev.type == pygame.MOUSEBUTTONDOWN: | |
ui.pointer = ev.pos | |
frame = EventFrame(ui, ev, ui.pointer, rect) | |
root.mousebuttondown(frame) | |
elif ev.type == pygame.MOUSEBUTTONUP: | |
ui.pointer = ev.pos | |
frame = EventFrame(ui, ev, ui.pointer, rect) | |
if (wf := root.fetch(ui.pressed, frame)) is not None: | |
wf[0].at_mousebuttonup(*wf) | |
ui.pressed = (root.site,) | |
elif ev.type == pygame.MOUSEMOTION: | |
ui.pointer = ev.pos | |
frame = EventFrame(ui, ev, ui.pointer, rect) | |
if (wf := root.fetch(ui.pressed, frame)) is not None: | |
wf[0].at_mousemotion(*wf) | |
return ui.events[:] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from dataclasses import dataclass, field | |
from typing import List, Dict, Optional, Callable, Tuple, Any, Set, Union | |
from .base import UIEvent, UIState, move_focus, draw_widget, process_event | |
from .compostor import composable, component, Composition, Compostor, layout, widget, context, key | |
from sarpasana import edges, pc | |
import sarpasana | |
import pygame | |
@dataclass(eq=True) | |
class TextField: | |
text : str | |
head : int | |
tail : int | |
edit : UIEvent | |
@component | |
def textbox(widget, field): | |
font = context().font | |
tab = context().tab | |
width, height = font.size(field.text) | |
@widget.attach | |
def _draw_(this, frame): | |
pygame.draw.rect(frame.screen, (200,200,200), frame.rect, 1) | |
rect = frame.rect.inflate((-20,-20)) | |
caret = font.size(field.text[:field.head])[0] + rect.left | |
tail = font.size(field.text[:field.tail])[0] + rect.left | |
x0 = min(caret, tail) | |
x1 = max(caret, tail) | |
if x0 < x1: | |
pygame.draw.rect(frame.screen, (0,100,255), (x0, rect.top, x1-x0, rect.height)) | |
text = font.render(field.text, True, (200, 200, 200)) | |
frame.screen.blit(text, rect.topleft) | |
if frame.same(frame.ui.focus): | |
pygame.draw.line(frame.screen, (200,200,200), (caret, rect.top), (caret, rect.bottom), 2) | |
widget.style_padding = edges(10.0) | |
def _measure_(this, avail_width, wm, avail_height, hm): | |
return (width, height) | |
widget.measure_func = _measure_ | |
widget.node_type = "text" | |
def _mousebuttondown_(this, frame): | |
x = frame.pointer[0] - 10 | |
meas = lambda i: font.size(field.text[:i])[0] | |
i = min((abs(x - meas(i)), i) for i in range(len(field.text)+1))[1] | |
frame.emit(field.edit(field.text, i, i)) | |
frame.focus(None) | |
frame.press(None) | |
widget.post_mousebuttondown = _mousebuttondown_ | |
def _mousemotion_(this, frame): | |
x = frame.pointer[0] - 10 | |
meas = lambda i: font.size(field.text[:i])[0] | |
i = min((abs(x - meas(i)), i) for i in range(len(field.text)+1))[1] | |
frame.emit(field.edit(field.text, i, field.tail)) | |
widget.at_mousemotion = _mousemotion_ | |
widget.focusable = 3 | |
def _key_down_(this, frame): | |
mods = pygame.key.get_mods() | |
shift_held = mods & pygame.KMOD_SHIFT | |
if frame.ev.key == pygame.K_TAB: | |
frame.emit(tab(shift_held)) | |
widget.at_keydown = _key_down_ | |
def _textinput_(this, frame): | |
i = min(field.head, field.tail) | |
j = max(field.head, field.tail) | |
prefix = field.text[:i] + frame.ev.text | |
suffix = field.text[j:] | |
head = len(prefix) | |
frame.emit(field.edit(prefix + suffix, head, head)) | |
widget.at_textinput = _textinput_ | |
@component | |
def button(widget, left=None, middle=None, right=None): | |
@widget.attach | |
def _draw_(this, frame): | |
if frame.same(frame.ui.pressed): | |
pygame.draw.rect(frame.screen, (50, 150, 50), frame.rect, 1) | |
elif frame.same(frame.ui.focus): | |
pygame.draw.rect(frame.screen, (50, 50, 150), frame.rect, 2) | |
else: | |
pygame.draw.rect(frame.screen, (150, 50, 50), frame.rect, 1) | |
def _down_(this, frame): | |
if frame.ev.button == 1 and left is not None: | |
frame.press(frame.ev.button) | |
if frame.ev.button == 2 and middle is not None: | |
frame.press(frame.ev.button) | |
if frame.ev.button == 3 and right is not None: | |
frame.press(frame.ev.button) | |
widget.pre_mousebuttondown = _down_ | |
def _up_(this, frame): | |
if frame.inside: | |
if frame.ui.mouse_tool == 1 and left is not None: | |
frame.emit(left) | |
if frame.ui.mouse_tool == 2 and middle is not None: | |
frame.emit(middle) | |
if frame.ui.mouse_tool == 3 and middle is not None: | |
frame.emit(right) | |
widget.at_mousebuttonup = _up_ | |
widget.style_padding = edges(5.0) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from collections import Counter | |
from .base import Widget | |
import inspect | |
import contextvars | |
import functools | |
builder = contextvars.ContextVar("builder") | |
kwd_mark = (object(),) | |
class Composition(Widget): | |
def __init__(self, site, key, memo): | |
super().__init__(site) | |
self.key = key | |
self.memo = memo | |
def __str__(self): | |
return self.debug_str(0) | |
class Builder: | |
def __init__(self, context, composition, counts = None, widget = None, site_prefix=()): | |
self.context = context | |
self.composition = composition | |
self.counts = Counter() if counts is None else counts | |
self.widget = composition if widget is None else widget | |
self.site_prefix = site_prefix | |
self._token = None | |
def make_site(self, frame): | |
site = self.site_prefix + (frame.f_code, frame.f_lineno) | |
self.counts[site] += 1 | |
return site + (self.counts[site],) | |
def __enter__(self): | |
self._token = builder.set(self) | |
return self | |
def __exit__(self, exc_type, exc_val, exc_traceback): | |
builder.reset(self._token) | |
class Compostor: | |
def __init__(self, fn, context=None): | |
self.composition = Composition((), (), {}) | |
self.fn = fn | |
self.context = context | |
def __call__(self, *args, **kwargs): | |
self.composition.clear() | |
self.composition.reset() | |
with Builder(self.context, self.composition): | |
self.fn(*args, **kwargs) | |
return self.composition | |
def composable(fn): | |
@functools.wraps(fn) | |
def _composable_(*args, **kwargs): | |
bd = builder.get() | |
frame = inspect.currentframe().f_back | |
site = bd.make_site(frame) | |
key = make_key(args, kwargs) | |
try: | |
widget = bd.composition.memo[site] | |
if widget.key != key: | |
widget.key = key | |
widget.clear() | |
widget.reset() | |
with Builder(bd.context, widget): | |
fn(*args, **kwargs) | |
except KeyError: | |
widget = Composition(site, key, {}) | |
with Builder(bd.context, widget): | |
fn(*args, **kwargs) | |
bd.composition.memo[site] = widget | |
bd.widget.append(widget) | |
return _composable_ | |
def make_key(args, kwargs): | |
key = args | |
if kwargs: | |
key += kwd_mark | |
for item in kwargs.items(): | |
key += item | |
return key | |
# TODO: this probably doesn't work yet, try it out. | |
def key(*keys): | |
bd = builder.get() | |
return Builder(bd.context, bd.composition, bd.counts, bd.widget, bd.site_prefix + keys) | |
def widget(): | |
return builder.get().widget | |
def layout(): | |
return builder.get().widget | |
def context(): | |
return builder.get().context | |
def component(fn): | |
@functools.wraps(fn) | |
def _fn_(*args, **kwargs): | |
bd = builder.get() | |
frame = inspect.currentframe().f_back | |
site = bd.make_site(frame) | |
widget = Widget(site) | |
bd.widget.append(widget) | |
fn(widget, *args, **kwargs) | |
return Builder(bd.context, bd.composition, bd.counts, widget, bd.site_prefix) | |
return _fn_ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment