Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
9b62fad
Minimal Viable OpenGLView
corranwebster Mar 28, 2026
f644cc0
Spelling; fix Gtk errors.
corranwebster Mar 28, 2026
05dfeec
Better names for android drawing arguments.
corranwebster Mar 28, 2026
aa3d480
Add shadertoy example.
corranwebster Mar 30, 2026
b1def72
Merge branch 'main' into opengl-minimal-widget
corranwebster Mar 30, 2026
c3ab445
Fix new pre-commit errors.
corranwebster Mar 30, 2026
5130aa2
Better layout and status reporting in Shadertoy demo.
corranwebster Mar 31, 2026
4e431c6
[WIP] Working out mouse/touch support.
corranwebster Mar 31, 2026
c7cb27f
More work on position and button support.
corranwebster Apr 1, 2026
e06c505
Get shadertoy mostly working on Android; refactor renderers.
corranwebster Apr 1, 2026
c006fa1
Refactoring and clean-up of Shadertoy example.
corranwebster Apr 2, 2026
7539f0d
Add Pyglet backend.
corranwebster Apr 2, 2026
09ee1ac
Further refactor of shadertoy example; get iOS working.
corranwebster Apr 3, 2026
f27b1a3
Fix for Cocoa OpenGLView to enable depth buffer.
corranwebster Apr 8, 2026
dab7a9c
Update shadertoy example for iOS support.
corranwebster Apr 8, 2026
582c5c5
Update opengl example to be an obj file viewer app.
corranwebster Apr 8, 2026
92ce539
Rename obj file viewer example.
corranwebster Apr 8, 2026
35328bf
Update utilities in shadertoy and make pyglet work.
corranwebster Apr 8, 2026
87107c3
Additional fixes for shadertoy renderer.
corranwebster Apr 8, 2026
1bc16de
Fixes for unit tests.
corranwebster Apr 9, 2026
1ad968a
More fixes for tests.
corranwebster Apr 9, 2026
619e35f
WIP commit: screenshot app; work on obj_viewer; other fixes.
corranwebster Apr 12, 2026
e1ea596
Merge branch 'main' into opengl-minimal-widget
corranwebster Apr 12, 2026
d75e9fb
Fix Android screenshots; start work on iOS screenshots.
corranwebster Apr 15, 2026
c1d0ff7
More work on iOS backend for screenshot.
corranwebster Apr 17, 2026
87204e2
Merge branch 'main' into opengl-minimal-widget
corranwebster Apr 30, 2026
923c9d3
Remove double defintions of EAGLContext in iOS.
corranwebster Apr 30, 2026
8266177
Add tests of position; insist on position and buttons arguments.
corranwebster Apr 30, 2026
3ed6491
Fix tests and cocoa NSPoint call.
corranwebster Apr 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions android/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ Label = "toga_android.widgets.label:Label"
MapView = "toga_android.widgets.mapview:MapView"
MultilineTextInput = "toga_android.widgets.multilinetextinput:MultilineTextInput"
NumberInput = "toga_android.widgets.numberinput:NumberInput"
OpenGLView = "toga_android.widgets.openglview:OpenGLView"
OptionContainer = "toga_android.widgets.optioncontainer:OptionContainer"
PasswordInput = "toga_android.widgets.passwordinput:PasswordInput"
ProgressBar = "toga_android.widgets.progressbar:ProgressBar"
Expand Down
81 changes: 81 additions & 0 deletions android/src/toga_android/widgets/openglview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import weakref

from android.opengl import GLSurfaceView
from android.view import MotionEvent
from java import dynamic_proxy

from toga.widgets.openglview import TOUCH

from .base import Widget, suppress_reference_error


class TogaGLRenderer(dynamic_proxy(GLSurfaceView.Renderer)):
def __init__(self, impl):
super().__init__()
self.impl = weakref.proxy(impl)
self.interface = weakref.proxy(impl.interface)

def onSurfaceCreated(self, gl_api, config):
self.interface.renderer.on_init(self.interface)

def onDrawFrame(self, gl_api):
self._redraw()

def onSurfaceChanged(self, gl_api, width, height):
self._redraw()

def _redraw(self):
native = self.impl.native
width = native.getWidth()
height = native.getHeight()
# Pointer coordinates are in device-independent, top-left origin coords
# We need drawing pixel, bottom-left origin coordinates
scale = native.getContext().getResources().getDisplayMetrics().densityDpi / 160
pointer = self.impl.pointer
if pointer:
x, y = pointer
pointer = (scale * x, height - (scale * y))
self.interface.renderer.on_render(
self.interface,
size=(width, height),
pointer=pointer,
buttons=self.impl.buttons,
)


class TouchListener(dynamic_proxy(GLSurfaceView.OnTouchListener)):
def __init__(self, impl):
super().__init__()
self.impl = weakref.proxy(impl)
self.interface = weakref.proxy(impl.interface)

def onTouch(self, canvas, event):
with suppress_reference_error():
x, y = map(self.impl.scale_out, (event.getX(), event.getY()))
self.impl.pointer = (x, y)
if (action := event.getAction()) == MotionEvent.ACTION_DOWN:
self.impl.buttons = frozenset([TOUCH])
elif action == MotionEvent.ACTION_MOVE:
self.impl.buttons = frozenset([TOUCH])
elif action == MotionEvent.ACTION_UP:
self.impl.buttons = frozenset()
else: # pragma: no cover
self.impl.buttons = frozenset()
return True


class OpenGLView(Widget):
def create(self):
self.pointer = None
self.buttons = frozenset()
self.native = GLSurfaceView(self._native_activity)
self.native.setEGLContextClientVersion(3)

self.renderer = TogaGLRenderer(self)
self.native.setRenderer(self.renderer)

self.listener = TouchListener(self)
self.native.setOnTouchListener(self.listener)

def redraw(self):
self.native.invalidate()
35 changes: 35 additions & 0 deletions android/tests_backend/widgets/openglview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from android.opengl import GLSurfaceView
from android.os import SystemClock
from android.view import MotionEvent

from toga.widgets.openglview import TOUCH

from .base import SimpleProbe


class OpenGLViewProbe(SimpleProbe):
native_class = GLSurfaceView
buttons = frozenset({TOUCH})

async def button_state(self, buttons: frozenset, x=0, y=0):
if TOUCH in buttons:
await self.touch_down(x, y)

async def reset_buttons(self, x=0, y=0):
await self.touch_up(x, y)
await self.redraw("Touch cleared")

async def position_change(self, x=0, y=0):
self.motion_event(MotionEvent.ACTION_MOVE, x, y)

def motion_event(self, action, x, y):
time = SystemClock.uptimeMillis()
super().motion_event(
time, time, action, x * self.scale_factor, y * self.scale_factor
)

async def touch_down(self, x, y):
self.motion_event(MotionEvent.ACTION_DOWN, x, y)

async def touch_up(self, x, y):
self.motion_event(MotionEvent.ACTION_UP, x, y)
1 change: 1 addition & 0 deletions changes/4273.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The Android, Cocoa, GTK, iOS and Qt backends now have a basic `OpenGLView` widget that provides low-level access to OpenGL rendering.
1 change: 1 addition & 0 deletions cocoa/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ Label = "toga_cocoa.widgets.label:Label"
MapView = "toga_cocoa.widgets.mapview:MapView"
MultilineTextInput = "toga_cocoa.widgets.multilinetextinput:MultilineTextInput"
NumberInput = "toga_cocoa.widgets.numberinput:NumberInput"
OpenGLView = "toga_cocoa.widgets.openglview:OpenGLView"
OptionContainer = "toga_cocoa.widgets.optioncontainer:OptionContainer"
PasswordInput = "toga_cocoa.widgets.passwordinput:PasswordInput"
ProgressBar = "toga_cocoa.widgets.progressbar:ProgressBar"
Expand Down
17 changes: 17 additions & 0 deletions cocoa/src/toga_cocoa/libs/appkit.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,10 @@ class NSEventType(IntEnum):
KeyDown = 10
KeyUp = 11

OtherMouseDown = 25
OtherMouseUp = 26
OtherMouseDreagged = 27


######################################################################
# NSFont.h
Expand Down Expand Up @@ -525,10 +529,21 @@ class NSLayoutPriority(Enum):
NSOpenGLPFARemotePixelBuffer = 91 # can be used to render offline to a pbuffer
NSOpenGLPFAAllowOfflineRenderers = 96 # allow use of offline renderers
NSOpenGLPFAAcceleratedCompute = 97 # choose a hardware accelerated compute device
NSOpenGLPFAOpenGLProfile = 99 # specify an OpenGL Profile to use
NSOpenGLPFAVirtualScreenCount = 128 # number of virtual screens in this format

NSOpenGLProfileVersionLegacy = 0x1000 # choose a Legacy/Pre-OpenGL 3.0 Implementation
NSOpenGLProfileVersion3_2Core = 0x3200 # choose an OpenGL 3.2 Core Implementation
NSOpenGLProfileVersion4_1Core = 0x4100 # choose an OpenGL 4.1 Core Implementation

NSOpenGLCPSwapInterval = 222

######################################################################
# NSOpenGLView.h
NSOpenGLView = ObjCClass("NSOpenGLView")
NSOpenGLContext = ObjCClass("NSOpenGLContext")
NSOpenGLPixelFormat = ObjCClass("NSOpenGLPixelFormat")

######################################################################
# NSOpenPanel.h
NSOpenPanel = ObjCClass("NSOpenPanel")
Expand Down Expand Up @@ -723,6 +738,8 @@ def NSTextAlignment(alignment):
NSToolbarItem.declare_property("itemIdentifier")
######################################################################
# NSTrackingArea.h
NSTrackingArea = ObjCClass("NSTrackingArea")

NSTrackingMouseEnteredAndExited = 0x01
NSTrackingMouseMoved = 0x02
NSTrackingCursorUpdate = 0x04
Expand Down
122 changes: 122 additions & 0 deletions cocoa/src/toga_cocoa/widgets/openglview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import ctypes

from rubicon.objc import objc_method, objc_property
from travertino.size import at_least

from toga.widgets.openglview import LEFT, MIDDLE, RIGHT
from toga_cocoa.libs import (
NSOpenGLPFADepthSize,
NSOpenGLPFADoubleBuffer,
NSOpenGLPFAOpenGLProfile,
NSOpenGLPixelFormat,
NSOpenGLProfileVersion4_1Core,
NSOpenGLView,
NSRect,
)

from .base import Widget


class TogaOpenGLView(NSOpenGLView):
interface = objc_property(object, weak=True)
impl = objc_property(object, weak=True)
buttons: set = objc_property(object)

@objc_method
def prepareOpenGL(self) -> None:
self.openGLContext.makeCurrentContext()
self.interface.renderer.on_init(self.interface)

@objc_method
def drawRect_(self, rect: NSRect) -> None:
# Get size in GL pixels
backingBounds = self.convertRectToBacking(self.bounds)
size = (backingBounds.size.width, backingBounds.size.height)

# Get current mouse position in GL pixels
position = self.convertPoint(
self.window.mouseLocationOutsideOfEventStream(),
fromView=None,
)
scale = self.backingScaleFactor
pointer = (position.x * scale, position.y * scale)

self.openGLContext.makeCurrentContext()
try:
self.interface.renderer.on_render(
self.interface,
size=size,
pointer=pointer,
buttons=frozenset(self.buttons),
)
finally:
# show what was drawn up to any error
self.openGLContext.flushBuffer()

@objc_method
def initWithFrame_(self, frame: NSRect):
a = (
NSOpenGLPFADepthSize,
24,
NSOpenGLPFADoubleBuffer,
NSOpenGLPFAOpenGLProfile,
NSOpenGLProfileVersion4_1Core,
)
attributes = (ctypes.c_uint32 * len(a))(*a)
pixel_format = NSOpenGLPixelFormat.alloc().initWithAttributes_(attributes)
# try help pinpoint the failure if we can't set things up correctly
# can't test, as this would only occur on an older machine/macOS version
if pixel_format is None: # pragma: no cover
# warnings cause segfault here, so just print
print("Can't create NSOpenGLPixelFormat with required properties.")
return None

return self.initWithFrame_pixelFormat_(frame, pixel_format)

@objc_method
def mouseDown_(self, event) -> None:
self.buttons.add(LEFT)

@objc_method
def mouseUp_(self, event) -> None:
self.buttons.discard(LEFT)

@objc_method
def otherMouseDown_(self, event) -> None:
self.buttons.add(MIDDLE)

@objc_method
def otherMouseUp_(self, event) -> None:
self.buttons.discard(MIDDLE)

@objc_method
def rightMouseDown_(self, event) -> None:
self.buttons.add(RIGHT)

@objc_method
def rightMouseUp_(self, event) -> None:
self.buttons.discard(RIGHT)


class OpenGLView(Widget):
def create(self):
self.native = TogaOpenGLView.alloc().init()
# try to fail gracefully if we can't set things up correctly
# can't test, as this would only occur on an older machine/macOS version
if self.native is None: # pragma: no cover
raise RuntimeError("Can't create native OpenGLView widget.")
self.native.interface = self.interface
self.native.impl = self
self.native.buttons = set()

# Add the layout constraints
self.add_constraints()

def redraw(self):
self.native.needsDisplay = True

# Rehint
def rehint(self):
fitting_size = self.native.fittingSize()
self.interface.intrinsic.height = at_least(fitting_size.height)
self.interface.intrinsic.width = at_least(fitting_size.width)
Comment on lines +120 to +122
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here this value is 0 on cocoa. Same may be occuring on other backends -- it'd be wise to take the maximum of the native fitting size and self.interface._MIN_WIDTH and self.interface._MIN_HEIGHT (defined in core base.py).

I understand that you did not ask for a review, but since this is not in your TODO list, I'd like to bring this to attn.

Thank you.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... I stole borrowed that from the Canvas widget. I figured that I want the same logic for the size of the widget.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In which case it may be appropriate to not enforce a minimum size. I apologize for not researching those precedents in advance.

You may resolve this comment now. I brought this up because I got a "singular matrix" eror when I resized toga-chart to 0 size...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There shouldn't be a minimum size, per-se, but having it at least be positive might be a good idea. In any case it's a wider issue than just this.

79 changes: 79 additions & 0 deletions cocoa/tests_backend/widgets/openglview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
from toga.widgets.openglview import LEFT, MIDDLE, RIGHT
from toga_cocoa.libs import NSEvent, NSEventType, NSOpenGLView, NSPoint

from .base import SimpleProbe


class OpenGLViewProbe(SimpleProbe):
native_class = NSOpenGLView
buttons = frozenset({LEFT, MIDDLE, RIGHT})

async def button_state(self, buttons: frozenset, x=0, y=0):
methods = [
self.left_mouse_down,
self.middle_mouse_down,
self.right_mouse_down,
]
for button in buttons:
method = methods[button]
await method(x, y)

async def reset_buttons(self, x=0, y=0):
methods = [
self.left_mouse_up,
self.middle_mouse_up,
self.right_mouse_up,
]
for button in frozenset(self.native.buttons):
method = methods[button]
await method(x, y)
await self.redraw("Buttons cleared")

async def position_change(self, x=0, y=0):
await self.mouse_event(
NSEventType.LeftMouseDragged,
self.native.convertPoint(NSPoint(x, y), toView=None),
)

async def left_mouse_down(self, x=0, y=0):
event = self._button_event(NSEventType.LeftMouseDown)
self.native.mouseDown_(event)
await self.redraw("Left mouse is down")

async def left_mouse_up(self, x=0, y=0):
event = self._button_event(NSEventType.LeftMouseUp)
self.native.mouseUp_(event)
await self.redraw("Left mouse is up")

async def middle_mouse_down(self, x=0, y=0):
event = self._button_event(NSEventType.OtherMouseDown)
self.native.otherMouseDown_(event)
await self.redraw("Left mouse is down")

async def middle_mouse_up(self, x=0, y=0):
event = self._button_event(NSEventType.OtherMouseUp)
self.native.otherMouseUp_(event)
await self.redraw("Left mouse is up")

async def right_mouse_down(self, x=0, y=0):
event = self._button_event(NSEventType.RightMouseDown)
self.native.rightMouseDown_(event)
await self.redraw("Left mouse is down")

async def right_mouse_up(self, x=0, y=0):
event = self._button_event(NSEventType.RightMouseUp)
self.native.rightMouseUp_(event)
await self.redraw("Left mouse is up")

def _button_event(self, event_type, x=0, y=0):
return NSEvent.mouseEventWithType(
event_type,
location=self.native.convertPoint(NSPoint(x, y), toView=None),
modifierFlags=0,
timestamp=0,
windowNumber=self.native.window.windowNumber,
context=None,
eventNumber=0,
clickCount=1,
pressure=1.0 if event_type == NSEventType.LeftMouseDown else 0.0,
)
1 change: 1 addition & 0 deletions core/src/toga/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ from toga.widgets.mapview import MapPin as MapPin
from toga.widgets.mapview import MapView as MapView
from toga.widgets.multilinetextinput import MultilineTextInput as MultilineTextInput
from toga.widgets.numberinput import NumberInput as NumberInput
from toga.widgets.openglview import OpenGLView as OpenGLView
from toga.widgets.optioncontainer import OptionContainer as OptionContainer
from toga.widgets.optioncontainer import OptionItem as OptionItem
from toga.widgets.passwordinput import PasswordInput as PasswordInput
Expand Down
Loading
Loading