Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enforce min layout size as window size #2020

Merged
merged 30 commits into from
Aug 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
cecafa3
Remove DPI calculations from Pack, and track min size while computing…
freakboy3742 Jul 1, 2023
32cb8eb
Factor out common logic in RTL/LTR offset passes.
freakboy3742 Jul 1, 2023
357d06f
Factored out some repeated logic around padding.
freakboy3742 Jul 1, 2023
59fcedc
Make Cocoa honor min size constraints on every layout.
freakboy3742 Jul 1, 2023
38092bb
Finalize removal of Viewport.
freakboy3742 Jul 1, 2023
2654579
Clarified some details in Pack.
freakboy3742 Jul 2, 2023
12af5f0
Log when iOS layouts exceed minimum size.
freakboy3742 Jul 2, 2023
efb66b9
Optimize GTK layout passes.
freakboy3742 Jul 2, 2023
fc63c9e
Add changenote.
freakboy3742 Jul 2, 2023
9d25975
Remove a viewport API that is no longer needed.
freakboy3742 Jul 6, 2023
8a3de20
Removed DPI as a container property on iOS, Cocoa and macOS.
freakboy3742 Jul 6, 2023
56914cf
Merge branch 'audit-optioncontainer' into min-width-no-scale
freakboy3742 Jul 17, 2023
4a6af8e
Merge branch 'audit-optioncontainer' into min-width-no-scale
freakboy3742 Jul 26, 2023
a1e553a
Merge branch 'audit-optioncontainer' into min-width-no-scale
freakboy3742 Jul 26, 2023
4f408b8
Add test cases to get complete coverage.
freakboy3742 Jul 28, 2023
7e598b0
Merge branch 'audit-optioncontainer' into min-width-no-scale
freakboy3742 Aug 1, 2023
01207b8
Include a temporary branch of travertino.
freakboy3742 Aug 1, 2023
52f10b6
Updates to get tests passing.
freakboy3742 Aug 1, 2023
3ff8ea7
Merge branch 'audit-optioncontainer' into min-width-no-scale
freakboy3742 Aug 3, 2023
cf44d78
Merge branch 'main' into min-width-no-scale
mhsmith Aug 11, 2023
5c6fd6f
All tests passing on Winforms with a scale factor of 1.25
mhsmith Aug 12, 2023
688f596
Tighten up ScrollContainer test tolerances
mhsmith Aug 12, 2023
e6f9ec5
Fix Winforms minimum window size calculation
mhsmith Aug 14, 2023
2eea854
Fix rounding error causing disabled scroll bars to appear intermittently
mhsmith Aug 14, 2023
b94efa0
Fix assertion rewriting on Android (closes #1961)
mhsmith Aug 14, 2023
add6e25
All tests passing on Android with a scale factor of 2.625
mhsmith Aug 14, 2023
f7ac639
Ensure grandchild allocations contribute to min size.
freakboy3742 Aug 15, 2023
3be34d0
Fix set_content / clear_content ordering on Android
mhsmith Aug 15, 2023
0800f99
Android: show warning if content is larger than window
mhsmith Aug 15, 2023
5d7f137
Require travertino>=0.3.0, which will be released shortly
mhsmith Aug 15, 2023
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
36 changes: 22 additions & 14 deletions android/src/toga_android/container.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,40 @@
from .libs.android.widget import RelativeLayout, RelativeLayout__LayoutParams
from .widgets.base import Scalable


class Container:
class Container(Scalable):
def init_container(self, native_parent):
self.width = self.height = 0

context = native_parent.getContext()
self.native_parent = native_parent
self.init_scale(context)
self.native_width = self.native_height = 0
self.content = None

self.native_content = RelativeLayout(context)
native_parent.addView(self.native_content)

self.dpi = context.getResources().getDisplayMetrics().densityDpi
# Toga needs to know how the current DPI compares to the platform default,
# which is 160: https://developer.android.com/training/multiscreen/screendensities
self.baseline_dpi = 160
self.scale = self.dpi / self.baseline_dpi
@property
def width(self):
return self.scale_out(self.native_width)

@property
def height(self):
return self.scale_out(self.native_height)

def set_content(self, widget):
self.clear_content()
if widget:
widget.container = self
self.content = widget

def clear_content(self):
if self.interface.content:
self.interface.content._impl.container = None
if self.content:
self.content.container = None
self.content = None

def resize_content(self, width, height):
if (self.width, self.height) != (width, height):
self.width, self.height = (width, height)
if (self.native_width, self.native_height) != (width, height):
self.native_width, self.native_height = (width, height)
if self.interface.content:
self.interface.content.refresh()

Expand All @@ -37,8 +45,8 @@ def refreshed(self):
# meaning of the (int, int) constructor.
lp = self.native_content.getLayoutParams()
layout = self.interface.content.layout
lp.width = max(self.width, layout.width)
lp.height = max(self.height, layout.height)
lp.width = max(self.native_width, self.scale_in(layout.width))
lp.height = max(self.native_height, self.scale_in(layout.height))
self.native_content.setLayoutParams(lp)

def add_content(self, widget):
Expand Down
71 changes: 54 additions & 17 deletions android/src/toga_android/widgets/base.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from abc import abstractmethod
from abc import ABC, abstractmethod
from decimal import ROUND_HALF_EVEN, ROUND_UP, Decimal

from travertino.size import at_least

from toga.constants import CENTER, JUSTIFY, LEFT, RIGHT, TRANSPARENT

Expand All @@ -7,6 +10,7 @@
from ..libs.android.graphics import PorterDuff__Mode, PorterDuffColorFilter, Rect
from ..libs.android.graphics.drawable import ColorDrawable, InsetDrawable
from ..libs.android.view import Gravity, View
from ..libs.android.widget import RelativeLayout__LayoutParams


def _get_activity(_cache=[]):
Expand All @@ -30,7 +34,30 @@ def _get_activity(_cache=[]):
return _cache[0]


class Widget:
class Scalable:
SCALE_DEFAULT_ROUNDING = ROUND_HALF_EVEN

def init_scale(self, context):
# The baseline DPI is 160:
# https://developer.android.com/training/multiscreen/screendensities
self.scale = context.getResources().getDisplayMetrics().densityDpi / 160

# Convert CSS pixels to native pixels
def scale_in(self, value, rounding=SCALE_DEFAULT_ROUNDING):
return self.scale_round(value * self.scale, rounding)

# Convert native pixels to CSS pixels
def scale_out(self, value, rounding=SCALE_DEFAULT_ROUNDING):
if isinstance(value, at_least):
return at_least(self.scale_out(value.value, rounding))
else:
return self.scale_round(value / self.scale, rounding)

def scale_round(self, value, rounding):
return int(Decimal(value).to_integral(rounding))


class Widget(ABC, Scalable):
# Some widgets are not generally focusable, but become focusable if there has been a
# keyboard event since the last touch event. To avoid this complicating the tests,
# these widgets disable programmatic focus entirely by setting focusable = False.
Expand All @@ -43,7 +70,18 @@ def __init__(self, interface):
self._container = None
self.native = None
self._native_activity = _get_activity()
self.init_scale(self._native_activity)
self.create()

# Some widgets, e.g. TextView, may throw an exception if we call measure()
# before setting LayoutParams.
self.native.setLayoutParams(
RelativeLayout__LayoutParams(
RelativeLayout__LayoutParams.WRAP_CONTENT,
RelativeLayout__LayoutParams.WRAP_CONTENT,
)
)

# Immediately re-apply styles. Some widgets may defer style application until
# they have been added to a container.
self.interface.style.reapply()
Expand Down Expand Up @@ -74,19 +112,7 @@ def container(self, container):
for child in self.interface.children:
child._impl.container = container

self.rehint()

@property
def viewport(self):
return self._container

# Convert CSS pixels to native pixels
def scale_in(self, value):
return int(round(value * self.container.scale))

# Convert native pixels to CSS pixels
def scale_out(self, value):
return int(round(value / self.container.scale))
self.refresh()

def get_enabled(self):
return self.native.isEnabled()
Expand All @@ -107,7 +133,9 @@ def set_tab_index(self, tab_index):
# APPLICATOR

def set_bounds(self, x, y, width, height):
self.container.set_content_bounds(self, x, y, width, height)
self.container.set_content_bounds(
self, *map(self.scale_in, (x, y, width, height))
)

def set_hidden(self, hidden):
if hidden:
Expand Down Expand Up @@ -170,12 +198,21 @@ def insert_child(self, index, child):
def remove_child(self, child):
child.container = None

# TODO: consider calling requestLayout or forceLayout here
# (https://github.com/beeware/toga/issues/1289#issuecomment-1453096034)
def refresh(self):
intrinsic = self.interface.intrinsic
intrinsic.width = intrinsic.height = None
self.rehint()
assert intrinsic.width is not None, self
assert intrinsic.height is not None, self

intrinsic.width = self.scale_out(intrinsic.width, ROUND_UP)
intrinsic.height = self.scale_out(intrinsic.height, ROUND_UP)

@abstractmethod
def rehint(self):
pass
...


def align(value):
Expand Down
4 changes: 0 additions & 4 deletions android/src/toga_android/widgets/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,6 @@ def set_background_color(self, value):
self.set_background_filter(value)

def rehint(self):
# Like other text-viewing widgets, Android crashes when rendering
# `Button` unless it has its layout params set. Guard for that case.
if not self.native.getLayoutParams():
return
self.native.measure(
View__MeasureSpec.UNSPECIFIED,
View__MeasureSpec.UNSPECIFIED,
Expand Down
6 changes: 6 additions & 0 deletions android/src/toga_android/widgets/canvas.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import math

from travertino.size import at_least

from ..libs import activity
from ..libs.android.graphics import (
DashPathEffect,
Expand Down Expand Up @@ -214,3 +216,7 @@ def get_image_data(self):

def set_on_resize(self, handler):
self.interface.factory.not_implemented("Canvas.on_resize")

def rehint(self):
self.interface.intrinsic.width = at_least(0)
self.interface.intrinsic.height = at_least(0)
3 changes: 0 additions & 3 deletions android/src/toga_android/widgets/detailedlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,9 +204,6 @@ def scroll():
Handler().post(PythonRunnable(scroll))

def rehint(self):
# Android can crash when rendering some widgets until they have their layout params set. Guard for that case.
if not self.native.getLayoutParams():
return
self.native.measure(
View__MeasureSpec.UNSPECIFIED,
View__MeasureSpec.UNSPECIFIED,
Expand Down
5 changes: 0 additions & 5 deletions android/src/toga_android/widgets/internal/pickers.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,6 @@ def create(self):

def rehint(self):
self.interface.intrinsic.width = at_least(300)
# Refuse to call measure() if widget has no container, i.e., has no LayoutParams.
# On Android, EditText's measure() throws NullPointerException if the widget has no
# LayoutParams.
if not self.native.getLayoutParams():
return
self.native.measure(
View__MeasureSpec.UNSPECIFIED, View__MeasureSpec.UNSPECIFIED
)
Expand Down
4 changes: 0 additions & 4 deletions android/src/toga_android/widgets/label.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,6 @@ def set_text(self, value):
self.native.setText(value)

def rehint(self):
# Refuse to rehint an Android TextView if it has no LayoutParams yet.
# Calling measure() on an Android TextView w/o LayoutParams raises NullPointerException.
if not self.native.getLayoutParams():
return
# Ask the Android TextView first for its minimum possible height.
# This is the height with word-wrapping disabled.
self.native.measure(
Expand Down
4 changes: 0 additions & 4 deletions android/src/toga_android/widgets/progressbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,6 @@ def set_value(self, value):
self.native.setProgress(int(value * self.TOGA_SCALE))

def rehint(self):
# Android can crash when rendering some widgets until
# they have their layout params set. Guard for that case.
if not self.native.getLayoutParams():
return
self.native.measure(
View__MeasureSpec.UNSPECIFIED,
View__MeasureSpec.UNSPECIFIED,
Expand Down
7 changes: 6 additions & 1 deletion android/src/toga_android/widgets/scrollcontainer.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from decimal import ROUND_DOWN

from travertino.size import at_least

from ..container import Container
Expand Down Expand Up @@ -65,7 +67,10 @@ def create(self):

def set_bounds(self, x, y, width, height):
super().set_bounds(x, y, width, height)
self.resize_content(width, height)
self.resize_content(
self.scale_in(width, ROUND_DOWN),
self.scale_in(height, ROUND_DOWN),
)

def get_vertical(self):
return self.vScrollListener.is_scrolling_enabled
Expand Down
2 changes: 0 additions & 2 deletions android/src/toga_android/widgets/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,6 @@ def set_value(self, value):
self.native.setChecked(bool(value))

def rehint(self):
if not self.native.getLayoutParams():
return
self.native.measure(
View__MeasureSpec.UNSPECIFIED, View__MeasureSpec.UNSPECIFIED
)
Expand Down
5 changes: 0 additions & 5 deletions android/src/toga_android/widgets/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,11 +229,6 @@ def set_font(self, font):
self.change_source(self.interface.data)

def rehint(self):
# Android can crash when rendering some widgets until
# they have their layout params set. Guard for that case.
if not self.native.getLayoutParams():
return

self.native.measure(
View__MeasureSpec.UNSPECIFIED,
View__MeasureSpec.UNSPECIFIED,
Expand Down
5 changes: 0 additions & 5 deletions android/src/toga_android/widgets/textinput.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,6 @@ def _on_lose_focus(self):

def rehint(self):
self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH)
# Refuse to call measure() if widget has no container, i.e., has no LayoutParams.
# On Android, EditText's measure() throws NullPointerException if the widget has no
# LayoutParams.
if not self.native.getLayoutParams():
return
self.native.measure(
View__MeasureSpec.UNSPECIFIED, View__MeasureSpec.UNSPECIFIED
)
Expand Down
16 changes: 16 additions & 0 deletions android/src/toga_android/window.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from decimal import ROUND_UP

from .container import Container
from .libs.android import R__id
from .libs.android.view import ViewTreeObserver__OnGlobalLayoutListener
Expand Down Expand Up @@ -62,6 +64,20 @@ def hide(self):
# A no-op, as the window cannot be hidden.
pass

def refreshed(self):
if self.native_width and self.native_height:
layout = self.interface.content.layout
available_width = self.scale_out(self.native_width, ROUND_UP)
available_height = self.scale_out(self.native_height, ROUND_UP)
if (layout.width > available_width) or (layout.height > available_height):
# Show the sizes in terms of CSS pixels.
print(
f"Warning: Window content {(layout.width, layout.height)} "
f"exceeds available space {(available_width, available_height)}"
)

super().refreshed()

def get_visible(self):
# The window is always visible
return True
Expand Down
1 change: 1 addition & 0 deletions changes/2020.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The minimum window size is now correctly recomputed and enforced if window content changes.
Loading