Skip to content

Instantly share code, notes, and snippets.

@cheery
Last active July 18, 2025 09:13
Show Gist options
  • Save cheery/8ec957d85546f0f0f1384896a755b225 to your computer and use it in GitHub Desktop.
Save cheery/8ec957d85546f0f0f1384896a755b225 to your computer and use it in GitHub Desktop.
jetpack compose style UI tree
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()
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[:]
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)
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