Skip to content

Commit

Permalink
Automatically recompute layout on style changes.
Browse files Browse the repository at this point in the history
  • Loading branch information
freakboy3742 committed Feb 16, 2023
1 parent e86a136 commit f381494
Show file tree
Hide file tree
Showing 13 changed files with 137 additions and 26 deletions.
5 changes: 5 additions & 0 deletions android/src/toga_android/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ def width(self):
def height(self):
return self.native.getHeight()

def make_dirty(self, widget=None):
# TODO: This won't be required once we complete the refactor
# making container a separate impl concept.
pass


class Window:
def __init__(self, interface, title, position, size):
Expand Down
5 changes: 5 additions & 0 deletions cocoa/src/toga_cocoa/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ def width(self):
def height(self):
return 0 if self.view is None else self.view.frame.size.height

def make_dirty(self, widget=None):
# TODO: This won't be required once we complete the refactor
# making container a separate impl concept.
pass


class WindowDelegate(NSObject):

Expand Down
6 changes: 5 additions & 1 deletion core/src/toga/style/applicator.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ class TogaApplicator:
def __init__(self, widget):
self.widget = widget

def refresh(self):
# print("RE-EVALUATE LAYOUT", self.widget)
self.widget.refresh()

def set_bounds(self):
# print("LAYOUT", self.widget, self.widget.layout)
# print("APPLY LAYOUT", self.widget, self.widget.layout)
self.widget._impl.set_bounds(
self.widget.layout.absolute_content_left,
self.widget.layout.absolute_content_top,
Expand Down
11 changes: 6 additions & 5 deletions core/src/toga/style/pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,18 +93,15 @@ def apply(self, prop, value):
else:
value = LEFT
self._applicator.set_text_alignment(value)
if prop == "text_direction":
elif prop == "text_direction":
if self.text_align is None:
self._applicator.set_text_alignment(RIGHT if value == RTL else LEFT)
elif prop == "color":
self._applicator.set_color(value)
elif prop == "background_color":
self._applicator.set_background_color(value)
elif prop == "visibility":
hidden = False
if value == HIDDEN:
hidden = True
self._applicator.set_hidden(hidden)
self._applicator.set_hidden(value == HIDDEN)
elif prop in (
"font_family",
"font_size",
Expand All @@ -121,6 +118,10 @@ def apply(self, prop, value):
weight=self.font_weight,
)
)
else:
# Any other style change will cause a change in layout geometry,
# so perform a refresh.
self._applicator.refresh()

def layout(self, node, viewport):
# Precompute `scale_factor` by providing it as a default param.
Expand Down
3 changes: 3 additions & 0 deletions core/src/toga/widgets/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,8 +281,11 @@ def refresh(self):
self._root.refresh()
else:
self.refresh_sublayouts()
# We can't compute a layout until we have a viewport
if self._impl.viewport:
super().refresh(self._impl.viewport)
# Refreshing the layout means the viewport needs a redraw.
self._impl.viewport.make_dirty()

def refresh_sublayouts(self):
for child in self.children:
Expand Down
6 changes: 6 additions & 0 deletions core/tests/style/test_pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ def __init__(self, name, style, size=None, children=None):
def __repr__(self):
return f"<{self.name} at {id(self)}>"

def refresh(self):
# We're directly modifying sytles and computing layouts for specific
# viewports, so we don't need to trigger layout changes when a style is
# changed.
pass


class TestViewport:
def __init__(self, width, height, dpi=96, baseline_dpi=96):
Expand Down
5 changes: 5 additions & 0 deletions dummy/src/toga_dummy/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ def width(self):
def height(self):
return self.window.get_size()[1]

def make_dirty(self, widget=None):
# TODO: This won't be required once we complete the refactor
# making container a separate impl concept.
pass


class Window(LoggedObject):
def __init__(self, interface, title, position, size):
Expand Down
89 changes: 81 additions & 8 deletions gtk/src/toga_gtk/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,63 @@ def __init__(self):
self.dpi = 96
self.baseline_dpi = self.dpi

self.dirty = set()
# The dirty widgets are the set of widgets that are known to need
# re-hinting before any redraw occurs.
self._dirty_widgets = set()

# A flag that can be used to explicitly flag that a redraw is required.
self._needs_redraw = True

@property
def needs_redraw(self):
"""Does the container need a redraw?"""
return self._needs_redraw or bool(self._dirty_widgets)

def make_dirty(self, widget=None):
"""Mark the container (or a specific widget in the container) as dirty.
:param widget: Optional; if provided, the widget that is now dirty. If
not provided, the entire container is considered dirty.
"""
if widget is None:
self._needs_redraw = True
self.queue_resize()
else:
self._dirty_widgets.add(widget)
widget.native.queue_resize()

@property
def width(self):
# Treat `native=None` as a 0x0 viewport.
"""The display width of the container.
If the container doesn't have any content yet, the width is 0.
"""
if self._content is None:
return 0
return self.get_allocated_width()

@property
def height(self):
# Treat `native=None` as a 0x0 viewport.
"""The display height of the container.
If the container doesn't have any content yet, the height is 0.
"""
if self._content is None:
return 0
return self.get_allocated_height()

@property
def content(self):
"""The Toga implementation widget that is the root content of this
container.
All children of the root content will also be added to the container as
a result of assigning content.
If the container already has content, the old content will be replaced.
The old root content and all it's children will be removed from the
container.
"""
return self._content

@content.setter
Expand All @@ -49,12 +88,17 @@ def content(self, widget):
widget.container = self

def recompute(self):
if self._content and self.dirty:
"""Rehint and re-layout the container's content, if necessary.
Any widgets known to be dirty will be rehinted. The minimum
possible layout size for the container will also be recomputed.
"""
if self._content and self.needs_redraw:
# If any of the widgets have been marked as dirty,
# recompute their bounds, and re-evaluate the minimum
# allowed size fo the layout.
while self.dirty:
widget = self.dirty.pop()
while self._dirty_widgets:
widget = self._dirty_widgets.pop()
widget.gtk_rehint()

# Compute the layout using a 0-size container
Expand All @@ -67,7 +111,16 @@ def recompute(self):
self.min_height = self._content.interface.layout.height

def do_get_preferred_width(self):
# Calculate the minimum and natural width of the container.
"""Return (recomputing if necessary) the preferred width for the
container.
The preferred size of the container is it's minimum size. This
preference will be overridden with the layout size when the layout is
applied.
If the container does not yet have content, the minimum width is set to
0.
"""
# print("GET PREFERRED WIDTH", self._content)
if self._content is None:
return 0, 0
Expand All @@ -80,7 +133,16 @@ def do_get_preferred_width(self):
return self.min_width, self.min_width

def do_get_preferred_height(self):
# Calculate the minimum and natural height of the container.
"""Return (recomputing if necessary) the preferred height for the
container.
The preferred size of the container is it's minimum size. This
preference will be overridden with the layout size when the
layout is applied.
If the container does not yet have content, the minimum height
is set to 0.
"""
# print("GET PREFERRED HEIGHT", self._content)
if self._content is None:
return 0, 0
Expand All @@ -93,6 +155,14 @@ def do_get_preferred_height(self):
return self.min_height, self.min_height

def do_size_allocate(self, allocation):
"""Perform the actual layout for the widget, and all it's children.
The container will assume whatever size it has been given by GTK -
usually the full space of the window that holds the container.
The layout will then be re-computed based on this new available size,
and that new geometry will be applied to all child widgets of the
container.
"""
# print(self._content, f"Container layout {allocation.width}x{allocation.height} @ {allocation.x}x{allocation.y}")

# The container will occupy the full space it has been allocated.
Expand Down Expand Up @@ -124,3 +194,6 @@ def do_size_allocate(self, allocation):
widget_allocation.height = widget.interface.layout.content_height

widget.size_allocate(widget_allocation)

# The layout has been redrawn
self._needs_redraw = False
8 changes: 4 additions & 4 deletions gtk/src/toga_gtk/widgets/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,9 @@ def set_tab_index(self, tab_index):
######################################################################

def set_bounds(self, x, y, width, height):
# No implementation required here; the new sizing will be picked up
# by the box's allocation handler.
pass
# If the bounds have changed, we need to queue a resize on the container
if self.container:
self.container.make_dirty()

def set_alignment(self, alignment):
# By default, alignment can't be changed
Expand Down Expand Up @@ -119,7 +119,7 @@ def rehint(self):
# Instead, put the widget onto a dirty list to be rehinted before the
# next layout.
if self.container:
self.container.dirty.add(self)
self.container.make_dirty(self)

def gtk_rehint(self):
# Perform the actual GTK rehint.
Expand Down
10 changes: 2 additions & 8 deletions gtk/tests_backend/widgets/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,9 @@ def assert_container(self, container):

async def redraw(self):
"""Request a redraw of the app, waiting until that redraw has completed."""
# Refresh the layout
self.widget.window.content.refresh()

self.impl.container.queue_resize()

# Force a repaint
while self.impl.container.dirty:
while Gtk.events_pending():
Gtk.main_iteration_do(blocking=False)
while self.impl.container.needs_redraw or Gtk.events_pending():
Gtk.main_iteration_do(blocking=False)

@property
def enabled(self):
Expand Down
5 changes: 5 additions & 0 deletions iOS/src/toga_iOS/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ def height(self):
self.widget.native.bounds.size.height - self.bottom_offset - self.top_offset
)

def make_dirty(self, widget=None):
# TODO: This won't be required once we complete the refactor
# making container a separate impl concept.
pass


class Window:
def __init__(self, interface, title, position, size):
Expand Down
5 changes: 5 additions & 0 deletions web/src/toga_web/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ def width(self):
def height(self):
return 768

def make_dirty(self, widget=None):
# TODO: This won't be required once we complete the refactor
# making container a separate impl concept.
pass


class Window:
def __init__(self, interface, title, position, size):
Expand Down
5 changes: 5 additions & 0 deletions winforms/src/toga_winforms/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ def dpi(self):
return self.baseline_dpi
return self.native.CreateGraphics().DpiX

def make_dirty(self, widget=None):
# TODO: This won't be required once we complete the refactor
# making container a separate impl concept.
pass


class Window:
def __init__(self, interface, title, position, size):
Expand Down

0 comments on commit f381494

Please sign in to comment.