Skip to content

Commit

Permalink
Merge pull request #2020 from freakboy3742/min-width-no-scale
Browse files Browse the repository at this point in the history
Enforce min layout size as window size
  • Loading branch information
freakboy3742 authored Aug 15, 2023
2 parents 6327c11 + 5d7f137 commit 6096823
Show file tree
Hide file tree
Showing 54 changed files with 828 additions and 1,869 deletions.
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

0 comments on commit 6096823

Please sign in to comment.