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

[widget Audit] toga.DetailedList #2025

Merged
merged 33 commits into from
Sep 9, 2023
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
8a3fbf1
Update docs and core API for DetailedList.
freakboy3742 Jul 4, 2023
796dbb2
Add changenotes.
freakboy3742 Jul 4, 2023
9a4e69f
Update docs index entry.
freakboy3742 Jul 4, 2023
e6e20c7
Update core tests for DetailedList.
freakboy3742 Jul 4, 2023
186aff3
Update examples to use new ListSource APIs.
freakboy3742 Jul 4, 2023
545a280
Deleting attributes is a notifiable change.
freakboy3742 Jul 4, 2023
1f260f5
Modify core API to use primary/secondary actions.
freakboy3742 Jul 5, 2023
6f49004
Cocoa DetailedList at 100%.
freakboy3742 Jul 6, 2023
6ec8eb8
iOS DetailedList to 100% coverage.
freakboy3742 Jul 6, 2023
ced26bb
Silence a test cleanup warning when running iOS tests non-slow.
freakboy3742 Jul 7, 2023
6484af2
More iOS test cleanups.
freakboy3742 Jul 7, 2023
b7fa33d
Clean up GTK DetailedList implementation
freakboy3742 Jul 7, 2023
dea9eaf
GTK DetailedList at 100% coverage.
freakboy3742 Jul 8, 2023
50b07dd
Add the ability to get a coverage report without running the full tes…
freakboy3742 Jul 8, 2023
6597a16
Ensure widgets have been made visible; Fixes #2026.
freakboy3742 Jul 8, 2023
cd02bfd
Merge branch 'audit-tree' into audit-detailedlist
freakboy3742 Jul 8, 2023
b3ac24a
Ensure GTK progressbar gets coverage.
freakboy3742 Jul 9, 2023
3dec76a
Use explicit calls to tableView methods to avoid ambiguous names, and…
freakboy3742 Jul 9, 2023
f92d9f8
Merge branch 'audit-tree' into audit-detailedlist
freakboy3742 Jul 17, 2023
230d3bb
Merge branch 'audit-tree' into audit-detailedlist
freakboy3742 Jul 26, 2023
0fe227b
Merge branch 'audit-tree' into audit-detailedlist
freakboy3742 Jul 26, 2023
12dc04e
Merge branch 'audit-tree' into audit-detailedlist
freakboy3742 Aug 4, 2023
812fe80
Merge branch 'main' into audit-detailedlist
freakboy3742 Aug 29, 2023
52be479
Documentation cleanups
mhsmith Aug 30, 2023
9d0a282
Winforms: support icons in Tables, but in first column only
mhsmith Sep 1, 2023
449a17a
WinForms DetailedList at 100%, but primary, secondary and refresh act…
mhsmith Sep 1, 2023
1dd7fea
In examples/table, make data structure clearer
mhsmith Sep 4, 2023
4d26f01
Remove workarounds for rubicon-java JNI reference issues
mhsmith Sep 6, 2023
b1b57d2
Android DetailedList at 100%
mhsmith Sep 7, 2023
ba31939
Make simulated Android swipes more realistic
mhsmith Sep 9, 2023
2b4ff7a
Rationalize usage of MainActivity.singletonThis
mhsmith Sep 9, 2023
09cb2a5
Move nested classes to module level
mhsmith Sep 9, 2023
4ff988c
Clarify DetailedList accessor docs
mhsmith Sep 9, 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
9 changes: 1 addition & 8 deletions android/src/toga_android/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def __init__(self, app):
super().__init__()
self._impl = app
MainActivity.setPythonApp(self)
self.native = MainActivity.singletonThis
print("Python app launched & stored in Android Activity class")

def onCreate(self):
Expand Down Expand Up @@ -162,14 +163,6 @@ def onPrepareOptionsMenu(self, menu):

return True

@property
def native(self):
# We access `MainActivity.singletonThis` freshly each time, rather than
# storing a reference in `__init__()`, because it's not safe to use the
# same reference over time because `rubicon-java` creates a JNI local
# reference.
return MainActivity.singletonThis


class App:
def __init__(self, interface):
Expand Down
7 changes: 2 additions & 5 deletions android/src/toga_android/widgets/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,7 @@

def _get_activity(_cache=[]):
freakboy3742 marked this conversation as resolved.
Show resolved Hide resolved
"""Android Toga widgets need a reference to the current activity to pass it as
`context` when creating Android native widgets. This may be useful at any time, so
we retain a global JNI ref.

:param _cache: List that is either empty or contains 1 item, the cached global JNI ref
`context` when creating Android native widgets.
"""
if _cache:
return _cache[0]
Expand All @@ -30,7 +27,7 @@ def _get_activity(_cache=[]):
"Unable to find MainActivity.singletonThis from Python. This is typically set by "
"org.beeware.android.MainActivity.onCreate()."
)
_cache.append(MainActivity.singletonThis.__global__())
_cache.append(MainActivity.singletonThis)
return _cache[0]


Expand Down
255 changes: 151 additions & 104 deletions android/src/toga_android/widgets/detailedlist.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
from rubicon.java.android_events import Handler, PythonRunnable
from dataclasses import dataclass

from travertino.size import at_least

from ..libs.android import R__color
from ..libs.android.graphics import BitmapFactory, Rect
from ..libs.android.view import Gravity, OnClickListener, View__MeasureSpec
from ..libs.android import R__attr, R__color
from ..libs.android.app import AlertDialog__Builder
from ..libs.android.content import DialogInterface__OnClickListener
from ..libs.android.graphics import Rect
from ..libs.android.view import Gravity, OnClickListener, OnLongClickListener
from ..libs.android.widget import (
ImageView,
ImageView__ScaleType,
Expand All @@ -24,16 +27,66 @@
class DetailedListOnClickListener(OnClickListener):
def __init__(self, impl, row_number):
super().__init__()
self._impl = impl
self._row_number = row_number
self.impl = impl
self.row_number = row_number

def onClick(self, _view):
row = self._impl.interface.data[self._row_number]
self._impl._selection = row
if self._impl.interface.on_select:
self._impl.interface.on_select(
self._impl.interface, row=self._impl.interface.data[self._row_number]
)
self.impl._set_selection(self.row_number)
self.impl.interface.on_select(None)


class DetailedListOnLongClickListener(OnLongClickListener):
def __init__(self, impl, row_number):
super().__init__()
self.impl = impl
self.interface = impl.interface
self.row_number = row_number

def onLongClick(self, _view):
self.impl._set_selection(self.row_number)
self.impl.interface.on_select(None)

@dataclass
class Action:
name: str
handler: callable
enabled: bool

freakboy3742 marked this conversation as resolved.
Show resolved Hide resolved
actions = [
action
for action in [
Action(
self.interface._primary_action,
self.interface.on_primary_action,
self.impl._primary_action_enabled,
),
Action(
self.interface._secondary_action,
self.interface.on_secondary_action,
self.impl._secondary_action_enabled,
),
]
if action.enabled
]

if actions:
row = self.interface.data[self.row_number]
AlertDialog__Builder(self.impl._native_activity).setItems(
[action.name for action in actions],
DetailedListActionListener(actions, row),
).show()

return True


class DetailedListActionListener(DialogInterface__OnClickListener):
def __init__(self, actions, row):
super().__init__()
self.actions = actions
self.row = row

def onClick(self, dialog, which):
self.actions[which].handler(None, row=self.row)


class OnRefreshListener(SwipeRefreshLayout__OnRefreshListener):
Expand All @@ -42,86 +95,86 @@ def __init__(self, interface):
self._interface = interface

def onRefresh(self):
if self._interface.on_refresh:
self._interface.on_refresh(self._interface)
self._interface.on_refresh(None)


class DetailedList(Widget):
ROW_HEIGHT = 250
_swipe_refresh_layout = None
_scroll_view = None
_dismissable_container = None
_selection = None

def create(self):
# DetailedList is not a specific widget on Android, so we build it out
# of a few pieces.
if self.native is None:
self.native = LinearLayout(self._native_activity)
self.native.setOrientation(LinearLayout.VERTICAL)
else:
# If create() is called a second time, clear the widget and regenerate it.
self.native.removeAllViews()

scroll_view = ScrollView(self._native_activity)
self._scroll_view = scroll_view.__global__()
scroll_view_layout_params = LinearLayout__LayoutParams(
LinearLayout__LayoutParams.MATCH_PARENT,
LinearLayout__LayoutParams.MATCH_PARENT,
)
scroll_view_layout_params.gravity = Gravity.TOP
swipe_refresh_wrapper = SwipeRefreshLayout(self._native_activity)
swipe_refresh_wrapper.setOnRefreshListener(OnRefreshListener(self.interface))
self._swipe_refresh_layout = swipe_refresh_wrapper.__global__()
swipe_refresh_wrapper.addView(scroll_view)
self.native.addView(swipe_refresh_wrapper, scroll_view_layout_params)
dismissable_container = LinearLayout(self._native_activity)
self._dismissable_container = dismissable_container.__global__()
dismissable_container.setOrientation(LinearLayout.VERTICAL)
dismissable_container_params = LinearLayout__LayoutParams(
# get the selection color from the current theme
attrs = [R__attr.colorBackground, R__attr.colorControlHighlight]
typed_array = self._native_activity.obtainStyledAttributes(attrs)
self.color_unselected = typed_array.getColor(0, 0)
self.color_selected = typed_array.getColor(1, 0)
typed_array.recycle()

self.native = self._refresh_layout = SwipeRefreshLayout(self._native_activity)
self._refresh_layout.setOnRefreshListener(OnRefreshListener(self.interface))

self._scroll_view = ScrollView(self._native_activity)
match_parent = LinearLayout__LayoutParams(
LinearLayout__LayoutParams.MATCH_PARENT,
LinearLayout__LayoutParams.MATCH_PARENT,
)
scroll_view.addView(dismissable_container, dismissable_container_params)
for i in range(len(self.interface.data or [])):
self._make_row(dismissable_container, i)
self._refresh_layout.addView(self._scroll_view, match_parent)

def _make_row(self, container, i):
self._linear_layout = LinearLayout(self._native_activity)
self._linear_layout.setOrientation(LinearLayout.VERTICAL)
self._scroll_view.addView(self._linear_layout, match_parent)

def _load_data(self):
self._selection = None
self._linear_layout.removeAllViews()
for i, row in enumerate(self.interface.data):
self._make_row(self._linear_layout, i, row)

def _make_row(self, container, i, row):
# Create the foreground.
row_foreground = RelativeLayout(self._native_activity)
container.addView(row_foreground)
row_view = RelativeLayout(self._native_activity)
container.addView(row_view)
row_view.setOnClickListener(DetailedListOnClickListener(self, i))
row_view.setOnLongClickListener(DetailedListOnLongClickListener(self, i))
row_height = self.scale_in(80)

title, subtitle, icon = (
getattr(row, attr, None) for attr in self.interface.accessors
)

# Add user-provided icon to layout.
icon_image_view = ImageView(self._native_activity)
icon = self.interface.data[i].icon
if icon is not None:
bitmap = BitmapFactory.decodeFile(str(icon._impl.path))
icon_image_view.setImageBitmap(bitmap)
icon_image_view.setImageBitmap(icon._impl.native)
icon_layout_params = RelativeLayout__LayoutParams(
RelativeLayout__LayoutParams.WRAP_CONTENT,
RelativeLayout__LayoutParams.WRAP_CONTENT,
)
icon_layout_params.width = 150
icon_layout_params.setMargins(25, 0, 25, 0)
icon_layout_params.height = self.ROW_HEIGHT
icon_width = self.scale_in(50)
icon_margin = self.scale_in(10)
icon_layout_params.width = icon_width
icon_layout_params.setMargins(icon_margin, 0, icon_margin, 0)
icon_layout_params.height = row_height
icon_image_view.setScaleType(ImageView__ScaleType.FIT_CENTER)
row_foreground.addView(icon_image_view, icon_layout_params)
row_view.addView(icon_image_view, icon_layout_params)

# Create layout to show top_text and bottom_text.
text_container = LinearLayout(self._native_activity)
text_container_params = RelativeLayout__LayoutParams(
RelativeLayout__LayoutParams.WRAP_CONTENT,
RelativeLayout__LayoutParams.WRAP_CONTENT,
)
text_container_params.height = self.ROW_HEIGHT
text_container_params.setMargins(25 + 25 + 150, 0, 0, 0)
row_foreground.addView(text_container, text_container_params)
text_container_params.height = row_height
text_container_params.setMargins(icon_width + (2 * icon_margin), 0, 0, 0)
row_view.addView(text_container, text_container_params)
text_container.setOrientation(LinearLayout.VERTICAL)
text_container.setWeightSum(2.0)

# Create top & bottom text; add them to layout.
def get_string(value):
if value is None:
value = self.interface.missing_value
return str(value)

top_text = TextView(self._native_activity)
top_text.setText(str(getattr(self.interface.data[i], "title", "")))
top_text.setText(get_string(title))
top_text.setTextSize(20.0)
top_text.setTextColor(
self._native_activity.getResources().getColor(R__color.black)
Expand All @@ -130,7 +183,7 @@ def _make_row(self, container, i):
bottom_text.setTextColor(
self._native_activity.getResources().getColor(R__color.black)
)
bottom_text.setText(str(getattr(self.interface.data[i], "subtitle", "")))
bottom_text.setText(get_string(subtitle))
bottom_text.setTextSize(16.0)
top_text_params = LinearLayout__LayoutParams(
RelativeLayout__LayoutParams.WRAP_CONTENT,
Expand All @@ -148,65 +201,59 @@ def _make_row(self, container, i):
bottom_text_params.gravity = Gravity.TOP
text_container.addView(bottom_text, bottom_text_params)

# Apply an onclick listener so that clicking anywhere on the row triggers Toga's on_select(row).
row_foreground.setOnClickListener(DetailedListOnClickListener(self, i))
def _get_row(self, index):
return self._linear_layout.getChildAt(index)

def change_source(self, source):
# If the source changes, re-build the widget.
self.create()

def set_on_refresh(self, handler):
# No special handling needed.
pass
self._load_data()

def after_on_refresh(self, widget, result):
if self._swipe_refresh_layout:
self._swipe_refresh_layout.setRefreshing(False)
self._refresh_layout.setRefreshing(False)

def insert(self, index, item):
# If the data changes, re-build the widget. Brutally effective.
self.create()
self._load_data()

def change(self, item):
# If the data changes, re-build the widget. Brutally effective.
self.create()
self._load_data()

def remove(self, index, item):
# If the data changes, re-build the widget. Brutally effective.
self.create()
self._load_data()

def clear(self):
# If the data changes, re-build the widget. Brutally effective.
self.create()
self._load_data()

def _clear_selection(self):
if self._selection is not None:
self._get_row(self._selection).setBackgroundColor(self.color_unselected)
self._selection = None

def _set_selection(self, index):
self._clear_selection()
self._get_row(index).setBackgroundColor(self.color_selected)
self._selection = index

def get_selection(self):
return self._selection

def set_on_select(self, handler):
# No special handling required.
pass
def set_primary_action_enabled(self, enabled):
self._primary_action_enabled = enabled

def set_on_delete(self, handler):
# This widget currently does not implement event handlers for data change.
self.interface.factory.not_implemented("DetailedList.set_on_delete()")
def set_secondary_action_enabled(self, enabled):
self._secondary_action_enabled = enabled

def set_refresh_enabled(self, enabled):
self._refresh_layout.setEnabled(enabled)

def scroll_to_row(self, row):
def scroll():
row_obj = self._dismissable_container.getChildAt(row)
hit_rect = Rect()
row_obj.getHitRect(hit_rect)
self._scroll_view.requestChildRectangleOnScreen(
self._dismissable_container,
hit_rect,
False,
)

Handler().post(PythonRunnable(scroll))
row_obj = self._linear_layout.getChildAt(row)
hit_rect = Rect()
row_obj.getHitRect(hit_rect)
self._scroll_view.requestChildRectangleOnScreen(
self._linear_layout,
hit_rect,
True, # Immediate, not animated
)

def rehint(self):
self.native.measure(
View__MeasureSpec.UNSPECIFIED,
View__MeasureSpec.UNSPECIFIED,
)
self.interface.intrinsic.width = at_least(self.native.getMeasuredWidth())
self.interface.intrinsic.height = self.native.getMeasuredHeight()
self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH)
self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT)
Loading
Loading