diff --git a/android/src/toga_android/app.py b/android/src/toga_android/app.py index cdea6612d1..9ac4f00b72 100644 --- a/android/src/toga_android/app.py +++ b/android/src/toga_android/app.py @@ -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): @@ -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): diff --git a/android/src/toga_android/widgets/base.py b/android/src/toga_android/widgets/base.py index 8f2ed6e8fe..f10cd75f3c 100644 --- a/android/src/toga_android/widgets/base.py +++ b/android/src/toga_android/widgets/base.py @@ -13,27 +13,6 @@ from ..libs.android.widget import RelativeLayout__LayoutParams -def _get_activity(_cache=[]): - """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 - """ - if _cache: - return _cache[0] - # See MainActivity.onCreate() for initialization of .singletonThis: - # https://github.com/beeware/briefcase-android-gradle-template/blob/3.7/%7B%7B%20cookiecutter.formal_name%20%7D%7D/app/src/main/java/org/beeware/android/MainActivity.java - # This can't be tested because if it isn't set, nothing else will work. - if not MainActivity.singletonThis: # pragma: no cover - raise ValueError( - "Unable to find MainActivity.singletonThis from Python. This is typically set by " - "org.beeware.android.MainActivity.onCreate()." - ) - _cache.append(MainActivity.singletonThis.__global__()) - return _cache[0] - - class Scalable: SCALE_DEFAULT_ROUNDING = ROUND_HALF_EVEN @@ -69,7 +48,7 @@ def __init__(self, interface): self.interface._impl = self self._container = None self.native = None - self._native_activity = _get_activity() + self._native_activity = MainActivity.singletonThis self.init_scale(self._native_activity) self.create() diff --git a/android/src/toga_android/widgets/box.py b/android/src/toga_android/widgets/box.py index 0b7a4f7f79..c1d5b62912 100644 --- a/android/src/toga_android/widgets/box.py +++ b/android/src/toga_android/widgets/box.py @@ -1,13 +1,12 @@ from travertino.size import at_least -from ..libs.activity import MainActivity from ..libs.android.widget import RelativeLayout from .base import Widget class Box(Widget): def create(self): - self.native = RelativeLayout(MainActivity.singletonThis) + self.native = RelativeLayout(self._native_activity) def set_background_color(self, value): self.set_background_simple(value) diff --git a/android/src/toga_android/widgets/detailedlist.py b/android/src/toga_android/widgets/detailedlist.py index 00759984d1..3357fa1541 100644 --- a/android/src/toga_android/widgets/detailedlist.py +++ b/android/src/toga_android/widgets/detailedlist.py @@ -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, @@ -24,16 +27,67 @@ 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) + + +@dataclass +class Action: + name: str + handler: callable + enabled: bool + + +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) + + 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): @@ -42,70 +96,65 @@ 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) + + self._linear_layout = LinearLayout(self._native_activity) + self._linear_layout.setOrientation(LinearLayout.VERTICAL) + self._scroll_view.addView(self._linear_layout, match_parent) - def _make_row(self, container, i): + 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) @@ -113,15 +162,20 @@ def _make_row(self, container, i): 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) @@ -130,7 +184,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, @@ -148,65 +202,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_secondary_action_enabled(self, enabled): + self._secondary_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_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) diff --git a/android/src/toga_android/widgets/table.py b/android/src/toga_android/widgets/table.py index 2a9004c58a..f8fa4c77c4 100644 --- a/android/src/toga_android/widgets/table.py +++ b/android/src/toga_android/widgets/table.py @@ -4,7 +4,6 @@ import toga -from ..libs.activity import MainActivity from ..libs.android import R__attr from ..libs.android.graphics import Rect, Typeface from ..libs.android.view import Gravity, OnClickListener, OnLongClickListener @@ -75,7 +74,7 @@ def create(self): vscroll_view_layout_params.gravity = Gravity.TOP vscroll_view.setLayoutParams(vscroll_view_layout_params) - self.table_layout = TableLayout(MainActivity.singletonThis) + self.table_layout = TableLayout(self._native_activity) table_layout_params = TableLayout__Layoutparams( TableLayout__Layoutparams.MATCH_PARENT, TableLayout__Layoutparams.WRAP_CONTENT, @@ -113,13 +112,13 @@ def clear_selection(self): self.remove_selection(index) def create_table_header(self): - table_row = TableRow(MainActivity.singletonThis) + table_row = TableRow(self._native_activity) table_row_params = TableRow__Layoutparams( TableRow__Layoutparams.MATCH_PARENT, TableRow__Layoutparams.WRAP_CONTENT ) table_row.setLayoutParams(table_row_params) for col_index in range(len(self.interface._accessors)): - text_view = TextView(MainActivity.singletonThis) + text_view = TextView(self._native_activity) text_view.setText(self.interface.headings[col_index]) self._font_impl.apply( text_view, text_view.getTextSize(), text_view.getTypeface() @@ -140,7 +139,7 @@ def create_table_header(self): return table_row def create_table_row(self, row_index): - table_row = TableRow(MainActivity.singletonThis) + table_row = TableRow(self._native_activity) table_row_params = TableRow__Layoutparams( TableRow__Layoutparams.MATCH_PARENT, TableRow__Layoutparams.WRAP_CONTENT ) @@ -151,7 +150,7 @@ def create_table_row(self, row_index): table_row.setOnLongClickListener(TogaOnLongClickListener(impl=self)) table_row.setId(row_index) for col_index in range(len(self.interface._accessors)): - text_view = TextView(MainActivity.singletonThis) + text_view = TextView(self._native_activity) text_view.setText(self.get_data_value(row_index, col_index)) self._font_impl.apply( text_view, text_view.getTextSize(), text_view.getTypeface() diff --git a/android/tests_backend/widgets/base.py b/android/tests_backend/widgets/base.py index f6316c3056..eab0dd2a13 100644 --- a/android/tests_backend/widgets/base.py +++ b/android/tests_backend/widgets/base.py @@ -10,7 +10,13 @@ LayerDrawable, ) from android.os import Build, SystemClock -from android.view import MotionEvent, View, ViewTreeObserver +from android.view import ( + MotionEvent, + View, + ViewGroup, + ViewTreeObserver, + WindowManagerGlobal, +) from toga.colors import TRANSPARENT from toga.style.pack import JUSTIFY, LEFT @@ -37,6 +43,8 @@ def __init__(self, widget): self.native.getViewTreeObserver().addOnGlobalLayoutListener( self.layout_listener ) + self.window_manager = WindowManagerGlobal.getInstance() + self.original_window_names = self.window_manager.getViewRootNames() # Store the device DPI, as it will be needed to scale some values self.dpi = ( @@ -158,28 +166,57 @@ def background_color(self): else: return TRANSPARENT + def find_dialog(self): + new_windows = [ + name + for name in self.window_manager.getViewRootNames() + if name not in self.original_window_names + ] + if len(new_windows) == 0: + return None + elif len(new_windows) == 1: + return self.window_manager.getRootView(new_windows[0]) + else: + raise RuntimeError(f"More than one new window: {new_windows}") + async def press(self): self.native.performClick() - def motion_event(self, down_time, action, x, y): - event = MotionEvent.obtain( - down_time, - SystemClock.uptimeMillis(), # eventTime - action, - x, - y, - 0, # metaState - ) + def motion_event(self, down_time, event_time, action, x, y): + event = MotionEvent.obtain(down_time, event_time, action, x, y, 0) self.native.dispatchTouchEvent(event) event.recycle() - async def swipe(self, dx, dy): + async def swipe(self, start_x, start_y, end_x, end_y, *, duration=0.3, hold=0.2): down_time = SystemClock.uptimeMillis() - start_x, start_y = (self.width / 2, self.height / 2) - end_x, end_y = (start_x + dx, start_y + dy) - self.motion_event(down_time, MotionEvent.ACTION_DOWN, start_x, start_y) - self.motion_event(down_time, MotionEvent.ACTION_MOVE, end_x, end_y) - self.motion_event(down_time, MotionEvent.ACTION_UP, end_x, end_y) + self.motion_event( + down_time, down_time, MotionEvent.ACTION_DOWN, start_x, start_y + ) + + # Convert to milliseconds + duration *= 1000 + hold *= 1000 + end_time = down_time + duration + + dx, dy = end_x - start_x, end_y - start_y + while (time := SystemClock.uptimeMillis()) < end_time: + fraction = (time - down_time) / duration + self.motion_event( + down_time, + time, + MotionEvent.ACTION_MOVE, + start_x + (dx * fraction), + start_y + (dy * fraction), + ) + await asyncio.sleep(0.02) + + # Hold at the end of the swipe to prevent it becoming a "fling" + end_time += hold + while (time := SystemClock.uptimeMillis()) < end_time: + self.motion_event(down_time, time, MotionEvent.ACTION_MOVE, end_x, end_y) + await asyncio.sleep(0.02) + + self.motion_event(down_time, time, MotionEvent.ACTION_UP, end_x, end_y) @property def is_hidden(self): @@ -188,3 +225,15 @@ def is_hidden(self): @property def has_focus(self): return self.widget.app._impl.native.getCurrentFocus() == self.native + + +def find_view_by_type(root, cls): + assert isinstance(root, View) + if isinstance(root, cls): + return root + if isinstance(root, ViewGroup): + for i in range(root.getChildCount()): + result = find_view_by_type(root.getChildAt(i), cls) + if result is not None: + return result + return None diff --git a/android/tests_backend/widgets/detailedlist.py b/android/tests_backend/widgets/detailedlist.py new file mode 100644 index 0000000000..c028734724 --- /dev/null +++ b/android/tests_backend/widgets/detailedlist.py @@ -0,0 +1,149 @@ +import asyncio + +from androidx.swiperefreshlayout.widget import SwipeRefreshLayout + +from android.os import SystemClock +from android.view import KeyEvent +from android.widget import ( + ImageView, + LinearLayout, + ListView, + RelativeLayout, + ScrollView, + TextView, +) + +from .base import SimpleProbe, find_view_by_type + + +class DetailedListProbe(SimpleProbe): + native_class = SwipeRefreshLayout + supports_actions = True + supports_refresh = True + + def __init__(self, widget): + super().__init__(widget) + self.refresh_layout = self.native + assert self.refresh_layout.getChildCount() == 2 + + # Child index 0 is the refresh icon. + self.scroll_view = self.native.getChildAt(1) + assert isinstance(self.scroll_view, ScrollView) + assert self.scroll_view.getChildCount() == 1 + + self.linear_layout = self.scroll_view.getChildAt(0) + assert isinstance(self.linear_layout, LinearLayout) + + @property + def row_count(self): + return self.linear_layout.getChildCount() + + def assert_cell_content(self, row, title, subtitle, icon): + row_layout = self._row_layout(row) + assert isinstance(row_layout, RelativeLayout) + assert row_layout.getChildCount() == 2 + + icon_view = row_layout.getChildAt(0) + assert isinstance(icon_view, ImageView) + if icon is None: + assert icon_view.getDrawable() is None + else: + assert icon_view.getDrawable().getBitmap() is icon._impl.native + + text_layout = row_layout.getChildAt(1) + assert isinstance(text_layout, LinearLayout) + assert text_layout.getChildCount() == 2 + + title_view = text_layout.getChildAt(0) + assert isinstance(title_view, TextView) + assert str(title_view.getText()) == title + + subtitle_view = text_layout.getChildAt(1) + assert isinstance(subtitle_view, TextView) + assert str(subtitle_view.getText()) == subtitle + + def _row_layout(self, row): + return self.linear_layout.getChildAt(row) + + @property + def max_scroll_position(self): + return ( + self.linear_layout.getHeight() - self.scroll_view.getHeight() + ) / self.scale_factor + + @property + def scroll_position(self): + return self.scroll_view.getScrollY() / self.scale_factor + + async def wait_for_scroll_completion(self): + pass + + async def select_row(self, row, add=False): + self._row_layout(row).performClick() + + def refresh_available(self): + return self.scroll_position <= 0 + + async def non_refresh_action(self): + await self._swipe_down(80) + + async def refresh_action(self, active=True): + await self._swipe_down(180) + + # The minimum distance to trigger a refresh is 128 dp. + async def _swipe_down(self, distance): + x = self.native.getWidth() * 0.5 + start_y = self.native.getHeight() * 0.1 + end_y = start_y + (distance * self.scale_factor) + + await self.swipe(x, start_y, x, end_y) + await asyncio.sleep(0.5) # Handler isn't called until animation completes. + + async def perform_primary_action(self, row, active=True): + await self._perform_action(row, self.widget._primary_action, active) + + async def perform_secondary_action(self, row, active=True): + await self._perform_action(row, self.widget._secondary_action, active) + + async def _perform_action(self, row, action, active): + expected_actions = [] + if self.widget._impl._primary_action_enabled: + expected_actions.append(self.widget._primary_action) + if self.widget._impl._secondary_action_enabled: + expected_actions.append(self.widget._secondary_action) + assert (action in expected_actions) == active + + self._row_layout(row).performLongClick() + await self.redraw("Long-pressed row") + + decor = self.find_dialog() + if not expected_actions: + assert decor is None + return + + menu = find_view_by_type(decor, ListView) + assert [ + str(find_view_by_type(menu.getChildAt(i), TextView).getText()) + for i in range(menu.getChildCount()) + ] == expected_actions + + if active: + menu.performItemClick(None, expected_actions.index(action), 0) + await self.redraw("Clicked menu item") + else: + timestamp = SystemClock.uptimeMillis() + decor.dispatchKeyEvent( + KeyEvent( + timestamp, # downTime + timestamp, # eventTime + KeyEvent.ACTION_UP, + KeyEvent.KEYCODE_BACK, + 0, # repeat + 0, # metaState + 0, # deviceId + 0, # scancode + KeyEvent.FLAG_TRACKING, + ), + ) + await self.redraw("Closed menu") + assert self.find_dialog() is None diff --git a/android/tests_backend/widgets/progressbar.py b/android/tests_backend/widgets/progressbar.py index 278c27c36c..d5ba788b15 100644 --- a/android/tests_backend/widgets/progressbar.py +++ b/android/tests_backend/widgets/progressbar.py @@ -18,3 +18,7 @@ def is_animating_indeterminate(self): @property def position(self): return self.native.getProgress() / self.native.getMax() + + async def wait_for_animation(self): + # Android ProgressBar has internal animation handling; no special handling required. + pass diff --git a/android/tests_backend/widgets/scrollcontainer.py b/android/tests_backend/widgets/scrollcontainer.py index 21ac804432..1eab93809b 100644 --- a/android/tests_backend/widgets/scrollcontainer.py +++ b/android/tests_backend/widgets/scrollcontainer.py @@ -1,5 +1,3 @@ -import asyncio - from android.widget import HorizontalScrollView, RelativeLayout, ScrollView from .base import SimpleProbe @@ -33,14 +31,9 @@ def document_width(self): return round(self.native_content.getWidth() / self.scale_factor) async def scroll(self): - await self.swipe(0, -30) # Swipe up + x = self.native.getWidth() * 0.5 + height = self.native.getHeight() + await self.swipe(x, height * 0.9, x, height * 0.1) async def wait_for_scroll_completion(self): - position = self.widget.position - current = None - # Iterate until 2 successive reads of the scroll position, - # 0.05s apart, return the same value - while position != current: - position = current - await asyncio.sleep(0.05) - current = self.widget.position + pass diff --git a/android/tests_backend/widgets/timeinput.py b/android/tests_backend/widgets/timeinput.py index c2cd85c0c7..4c784e480f 100644 --- a/android/tests_backend/widgets/timeinput.py +++ b/android/tests_backend/widgets/timeinput.py @@ -2,23 +2,12 @@ from datetime import time from android import R as android_R -from android.view import ViewGroup from android.widget import TimePicker +from .base import find_view_by_type from .dateinput import DateTimeInputProbe -def findViewByType(root, cls): - if isinstance(root, cls): - return root - if isinstance(root, ViewGroup): - for i in range(root.getChildCount()): - result = findViewByType(root.getChildAt(i), cls) - if result is not None: - return result - return None - - class TimeInputProbe(DateTimeInputProbe): supports_limits = False supports_seconds = False @@ -45,7 +34,7 @@ async def _change_dialog_value(self, delta): @property def _picker(self): - picker = findViewByType( + picker = find_view_by_type( self._dialog.findViewById(android_R.id.content), TimePicker ) assert picker is not None diff --git a/changes/2025.feature.1.rst b/changes/2025.feature.1.rst new file mode 100644 index 0000000000..1f27d4f773 --- /dev/null +++ b/changes/2025.feature.1.rst @@ -0,0 +1 @@ +The DetailedList widget now has 100% test coverage and complete API documentation. diff --git a/changes/2025.feature.2.rst b/changes/2025.feature.2.rst new file mode 100644 index 0000000000..acfbf94e1a --- /dev/null +++ b/changes/2025.feature.2.rst @@ -0,0 +1 @@ +The accessors used to populate a DetailedList can now be customised. diff --git a/changes/2025.feature.3.rst b/changes/2025.feature.3.rst new file mode 100644 index 0000000000..c08ba6a011 --- /dev/null +++ b/changes/2025.feature.3.rst @@ -0,0 +1 @@ +A DetailedList can now provide a value to use when a row doesn't provide the required data. diff --git a/changes/2025.feature.4.rst b/changes/2025.feature.4.rst new file mode 100644 index 0000000000..c20370c7b3 --- /dev/null +++ b/changes/2025.feature.4.rst @@ -0,0 +1 @@ +DetailedList can now respond to "primary" and "secondary" user actions. These may be implemented as left and right swipe respectively, or using any other platform-appropriate mechanism. diff --git a/changes/2025.removal.1.rst b/changes/2025.removal.1.rst new file mode 100644 index 0000000000..14d19c204e --- /dev/null +++ b/changes/2025.removal.1.rst @@ -0,0 +1 @@ +When constructing a DetailedList from a list of tuples, or a list of lists, the required order of values has changed from (icon, title, subtitle) to (title, subtitle, icon). diff --git a/changes/2025.removal.2.rst b/changes/2025.removal.2.rst new file mode 100644 index 0000000000..7b3dc14033 --- /dev/null +++ b/changes/2025.removal.2.rst @@ -0,0 +1 @@ +The ``on_select`` handler for DetailedList no longer receives the selected row as an argument. diff --git a/changes/2025.removal.3.rst b/changes/2025.removal.3.rst new file mode 100644 index 0000000000..14e44ed1af --- /dev/null +++ b/changes/2025.removal.3.rst @@ -0,0 +1 @@ +The handling of row deletion in DetailedList widgets has been significantly altered. The ``on_delete`` event handler has been renamed ``on_primary_action``, and is now *only* a notification that a "swipe left" event (or platform equivalent) has been confirmed. This was previously inconsistent across platforms. Some platforms would update the data source to remove the row; some treated ``on_delete`` as a notification event and expected the application to handle the deletion. It is now the application's responsibility to perform the data deletion. diff --git a/cocoa/src/toga_cocoa/widgets/detailedlist.py b/cocoa/src/toga_cocoa/widgets/detailedlist.py index 0f3105f8e1..b5566d19a2 100644 --- a/cocoa/src/toga_cocoa/widgets/detailedlist.py +++ b/cocoa/src/toga_cocoa/widgets/detailedlist.py @@ -1,14 +1,12 @@ +from rubicon.objc import SEL, objc_method, objc_property from travertino.size import at_least from toga_cocoa.libs import ( - SEL, - NSBezelBorder, + NSIndexSet, NSMenu, NSTableColumn, NSTableView, NSTableViewColumnAutoresizingStyle, - objc_method, - objc_property, ) from toga_cocoa.widgets.base import Widget from toga_cocoa.widgets.internal.cells import TogaDetailedCell @@ -16,40 +14,54 @@ from toga_cocoa.widgets.internal.refresh import RefreshableScrollView -def attr_impl(value, attr): - # If the data value has an _impl attribute, invoke it. - # This will manifest any impl-specific attributes. - impl = getattr(value, attr, None) - try: - return impl._impl - except AttributeError: - return impl - - class TogaList(NSTableView): interface = objc_property(object, weak=True) impl = objc_property(object, weak=True) @objc_method def menuForEvent_(self, event): - if self.interface.on_delete: + if self.impl.primary_action_enabled or self.impl.secondary_action_enabled: + # Find the row under the mouse click mousePoint = self.convertPoint(event.locationInWindow, fromView=None) row = self.rowAtPoint(mousePoint) - popup = NSMenu.alloc().initWithTitle("popup") - delete_item = popup.addItemWithTitle( - "Delete", action=SEL("actionDeleteRow:"), keyEquivalent="" + # Ensure the row is selected. + self.selectRowIndexes( + NSIndexSet.indexSetWithIndex(row), + byExtendingSelection=False, ) - delete_item.tag = row - # action_item = popup.addItemWithTitle("???", action=SEL('actionRow:'), keyEquivalent="") - # action_item.tag = row + + # Create a popup menu to display the possible actions. + popup = NSMenu.alloc().initWithTitle("popup").autorelease() + if self.impl.primary_action_enabled: + primary_action_item = popup.addItemWithTitle( + self.interface._primary_action, + action=SEL("primaryActionOnRow:"), + keyEquivalent="", + ) + primary_action_item.tag = row + + if self.impl.secondary_action_enabled: + secondary_action_item = popup.addItemWithTitle( + self.interface._secondary_action, + action=SEL("secondaryActionOnRow:"), + keyEquivalent="", + ) + secondary_action_item.tag = row return popup + else: + return None @objc_method - def actionDeleteRow_(self, menuitem): + def primaryActionOnRow_(self, menuitem): row = self.interface.data[menuitem.tag] - self.interface.on_delete(self.interface, row=row) + self.interface.on_primary_action(self.interface, row=row) + + @objc_method + def secondaryActionOnRow_(self, menuitem): + row = self.interface.data[menuitem.tag] + self.interface.on_secondary_action(self.interface, row=row) # TableDataSource methods @objc_method @@ -66,7 +78,34 @@ def tableView_objectValueForTableColumn_row_(self, table, column, row: int): data.retain() value._impl = data - data.attrs = {attr: attr_impl(value, attr) for attr in value._attrs} + try: + title = getattr(value, self.interface.accessors[0]) + if title is not None: + title = str(title) + else: + title = self.interface.missing_value + except AttributeError: + title = self.interface.missing_value + + try: + subtitle = getattr(value, self.interface.accessors[1]) + if subtitle is not None: + subtitle = str(subtitle) + else: + subtitle = self.interface.missing_value + except AttributeError: + subtitle = self.interface.missing_value + + try: + icon = getattr(value, self.interface.accessors[2])._impl.native + except AttributeError: + icon = None + + data.attrs = { + "title": title, + "subtitle": subtitle, + "icon": icon, + } return data @@ -83,103 +122,90 @@ def selectionShouldChangeInTableView_(self, table) -> bool: @objc_method def tableViewSelectionDidChange_(self, notification) -> None: - index = notification.object.selectedRow - if index == -1: - selection = None - else: - selection = self.interface.data[index] - - if self.interface.on_select: - self.interface.on_select(self.interface, row=selection) + self.interface.on_select(self.interface) class DetailedList(Widget): def create(self): # Create a List, and put it in a scroll view. # The scroll view is the _impl, because it's the outer container. - self.native = RefreshableScrollView.alloc().init() - self.native.interface = self.interface - self.native.impl = self - self.native.hasVerticalScroller = True - self.native.hasHorizontalScroller = False - self.native.autohidesScrollers = False - self.native.borderType = NSBezelBorder # Create the List widget - self.detailedlist = TogaList.alloc().init() - self.detailedlist.interface = self.interface - self.detailedlist.impl = self - self.detailedlist.columnAutoresizingStyle = ( + self.native_detailedlist = TogaList.alloc().init() + self.native_detailedlist.interface = self.interface + self.native_detailedlist.impl = self + self.native_detailedlist.columnAutoresizingStyle = ( NSTableViewColumnAutoresizingStyle.Uniform ) + self.native_detailedlist.allowsMultipleSelection = False - # TODO: Optionally enable multiple selection - self.detailedlist.allowsMultipleSelection = False + # Disable all actions by default. + self.primary_action_enabled = False + self.secondary_action_enabled = False - self.native.detailedlist = self.detailedlist + self.native = RefreshableScrollView.alloc().initWithDocument( + self.native_detailedlist + ) + self.native.interface = self.interface + self.native.impl = self # Create the column for the detailed list column = NSTableColumn.alloc().initWithIdentifier("data") - self.detailedlist.addTableColumn(column) + self.native_detailedlist.addTableColumn(column) self.columns = [column] cell = TogaDetailedCell.alloc().init() column.dataCell = cell # Hide the column header. - self.detailedlist.headerView = None + self.native_detailedlist.headerView = None - self.detailedlist.delegate = self.detailedlist - self.detailedlist.dataSource = self.detailedlist - - # Embed the tree view in the scroll view - self.native.documentView = self.detailedlist + self.native_detailedlist.delegate = self.native_detailedlist + self.native_detailedlist.dataSource = self.native_detailedlist # Add the layout constraints self.add_constraints() def change_source(self, source): - self.detailedlist.reloadData() + self.native_detailedlist.reloadData() def insert(self, index, item): - self.detailedlist.reloadData() + self.native_detailedlist.reloadData() def change(self, item): - self.detailedlist.reloadData() + self.native_detailedlist.reloadData() def remove(self, index, item): - self.detailedlist.reloadData() + self.native_detailedlist.reloadData() # After deletion, the selection changes, but Cocoa doesn't send # a tableViewSelectionDidChange: message. - selection = self.get_selection() - if selection and self.interface.on_select: - self.interface.on_select(self.interface, row=selection) + self.interface.on_select(self.interface) def clear(self): - self.detailedlist.reloadData() + self.native_detailedlist.reloadData() + + def set_refresh_enabled(self, enabled): + self.native.setRefreshEnabled(enabled) - def set_on_refresh(self, handler): - pass + def set_primary_action_enabled(self, enabled): + self.primary_action_enabled = enabled + + def set_secondary_action_enabled(self, enabled): + self.secondary_action_enabled = enabled def after_on_refresh(self, widget, result): self.native.finishedLoading() def get_selection(self): - index = self.detailedlist.selectedRow - if index != -1: - return self.interface.data[index] - else: + index = self.native_detailedlist.selectedRow + if index == -1: return None - - def set_on_select(self, handler): - pass - - def set_on_delete(self, handler): - pass + else: + return index def scroll_to_row(self, row): - self.detailedlist.scrollRowToVisible(row) + self.native_detailedlist.scrollRowToVisible(row) def rehint(self): self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) diff --git a/cocoa/src/toga_cocoa/widgets/internal/cells.py b/cocoa/src/toga_cocoa/widgets/internal/cells.py index c1276d0f89..f639254219 100644 --- a/cocoa/src/toga_cocoa/widgets/internal/cells.py +++ b/cocoa/src/toga_cocoa/widgets/internal/cells.py @@ -1,6 +1,7 @@ +from rubicon.objc import at, objc_method + from toga_cocoa.libs import ( NSAffineTransform, - NSBezierPath, NSColor, NSCompositingOperationSourceOver, NSFont, @@ -24,8 +25,6 @@ NSTableCellView, NSTextField, NSTextFieldCell, - at, - objc_method, ) @@ -148,8 +147,8 @@ def setText_(self, text): # A TogaDetailedCell contains: # * an icon -# * a main label -# * a secondary label +# * a main "title" label +# * a secondary "subtitle" label class TogaDetailedCell(NSTextFieldCell): @objc_method def drawInteriorWithFrame_inView_(self, cellFrame: NSRect, view) -> None: @@ -158,26 +157,28 @@ def drawInteriorWithFrame_inView_(self, cellFrame: NSRect, view) -> None: title = self.objectValue.attrs["title"] subtitle = self.objectValue.attrs["subtitle"] - if icon and icon.native: + # If there's an icon, draw it. + if icon: NSGraphicsContext.currentContext.saveGraphicsState() yOffset = cellFrame.origin.y - if view.isFlipped: - xform = NSAffineTransform.transform() - xform.translateXBy(4, yBy=cellFrame.size.height) - xform.scaleXBy(1.0, yBy=-1.0) - xform.concat() - yOffset = 0.5 - cellFrame.origin.y + + # Coordinate system is always flipped + xform = NSAffineTransform.transform() + xform.translateXBy(4, yBy=cellFrame.size.height) + xform.scaleXBy(1.0, yBy=-1.0) + xform.concat() + yOffset = 0.5 - cellFrame.origin.y interpolation = NSGraphicsContext.currentContext.imageInterpolation NSGraphicsContext.currentContext.imageInterpolation = ( NSImageInterpolationHigh ) - icon.native.drawInRect( + icon.drawInRect( NSRect(NSPoint(cellFrame.origin.x, yOffset + 4), NSSize(40.0, 40.0)), fromRect=NSRect( NSPoint(0, 0), - NSSize(icon.native.size.width, icon.native.size.height), + NSSize(icon.size.width, icon.size.height), ), operation=NSCompositingOperationSourceOver, fraction=1.0, @@ -185,50 +186,29 @@ def drawInteriorWithFrame_inView_(self, cellFrame: NSRect, view) -> None: NSGraphicsContext.currentContext.imageInterpolation = interpolation NSGraphicsContext.currentContext.restoreGraphicsState() + + # Find the right color for the text + if self.isHighlighted(): + primaryColor = NSColor.alternateSelectedControlTextColor else: - path = NSBezierPath.bezierPathWithRect( - NSRect( - NSPoint(cellFrame.origin.x, cellFrame.origin.y + 4), - NSSize(40.0, 40.0), - ) - ) - NSColor.grayColor.set() - path.fill() - - if title: - # Find the right color for the text - if self.isHighlighted(): - primaryColor = NSColor.alternateSelectedControlTextColor - else: - if False: - primaryColor = NSColor.disabledControlTextColor - else: - primaryColor = NSColor.textColor - - textAttributes = NSMutableDictionary.alloc().init() - textAttributes[NSForegroundColorAttributeName] = primaryColor - textAttributes[NSFontAttributeName] = NSFont.systemFontOfSize(15) - - at(title).drawAtPoint( - NSPoint(cellFrame.origin.x + 48, cellFrame.origin.y + 4), - withAttributes=textAttributes, - ) + primaryColor = NSColor.textColor - if subtitle: - # Find the right color for the text - if self.isHighlighted(): - primaryColor = NSColor.alternateSelectedControlTextColor - else: - if False: - primaryColor = NSColor.disabledControlTextColor - else: - primaryColor = NSColor.textColor - - textAttributes = NSMutableDictionary.alloc().init() - textAttributes[NSForegroundColorAttributeName] = primaryColor - textAttributes[NSFontAttributeName] = NSFont.systemFontOfSize(13) - - at(subtitle).drawAtPoint( - NSPoint(cellFrame.origin.x + 48, cellFrame.origin.y + 24), - withAttributes=textAttributes, - ) + # Draw the title + textAttributes = NSMutableDictionary.alloc().init() + textAttributes[NSForegroundColorAttributeName] = primaryColor + textAttributes[NSFontAttributeName] = NSFont.systemFontOfSize(15) + + at(title).drawAtPoint( + NSPoint(cellFrame.origin.x + 48, cellFrame.origin.y + 4), + withAttributes=textAttributes, + ) + + # Draw the subtitle + textAttributes = NSMutableDictionary.alloc().init() + textAttributes[NSForegroundColorAttributeName] = primaryColor + textAttributes[NSFontAttributeName] = NSFont.systemFontOfSize(13) + + at(subtitle).drawAtPoint( + NSPoint(cellFrame.origin.x + 48, cellFrame.origin.y + 24), + withAttributes=textAttributes, + ) diff --git a/cocoa/src/toga_cocoa/widgets/internal/refresh.py b/cocoa/src/toga_cocoa/widgets/internal/refresh.py index f5a13f3513..2c6ee33717 100644 --- a/cocoa/src/toga_cocoa/widgets/internal/refresh.py +++ b/cocoa/src/toga_cocoa/widgets/internal/refresh.py @@ -1,8 +1,16 @@ +from ctypes import c_void_p + +from rubicon.objc import ( + ObjCInstance, + objc_method, + objc_property, + send_super, +) + from toga_cocoa.libs import ( - SEL, + NSBezelBorder, NSClipView, - NSEvent, - NSEventPhaseEnded, + NSEventPhaseBegan, NSLayoutAttributeCenterX, NSLayoutAttributeCenterY, NSLayoutAttributeHeight, @@ -13,7 +21,6 @@ NSLayoutRelationEqual, NSMakePoint, NSMakeRect, - NSNotificationCenter, NSPoint, NSProgressIndicator, NSProgressIndicatorSpinningStyle, @@ -21,16 +28,59 @@ NSScrollElasticityAllowed, NSScrollView, NSView, - NSViewBoundsDidChangeNotification, - ObjCInstance, - c_void_p, - core_graphics, - kCGScrollEventUnitLine, - objc_method, - objc_property, - send_super, ) +######################################################################################### +# This is broadly derived from Alex Zielenski's ScrollToRefresh implementation: +# https://github.com/alexzielenski/ScrollToRefresh/blob/master/ScrollToRefresh/src/EQSTRScrollView.m +# ======================================================================================= +# ScrollToRefresh +# +# Copyright (C) 2011 by Alex Zielenski. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of this +# software and associated documentation files (the "Software"), to deal in the Software +# without restriction, including without limitation the rights to use, copy, modify, +# merge, publish, distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be included in all copies +# or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +# INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +# PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF +# CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE +# OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# ======================================================================================= +# +# HOW THIS WORKS +# +# RefreshableScrollView is a subclass of NSScrollView. When it is created, it is +# provided a document (usually a List View); the RefreshableScrollView has a custom +# clipView that accommodates an extra widget which can display the "currently loading" +# spinner. When the scroll view is reloading, the clipView alters its bounds to include +# the extra space for the refresh_view header; when the reload finishes, an artificial +# scroll event is generated that forces the clipView to re-evaluate its bounds, removing +# the refresh_view. +# +# During a scroll action, a ``scrollWheel:`` event is generated. During normal in-bounds +# scrolling, the y origin of the content area will always be non-negative. If you're +# near the end of the scroll area and bounce against the end, you won't generate an +# event scroll event with a negative y origin. However, if you're already at the end of +# the scroll area, and you pull past the limit, you'll generate a scroll event with a +# negative origin. The scrollingDeltaY associated with that event indicates how hard you +# pulled past the limit; as long the size of the pull exceeds a threshold (this allows +# us to ignoring small/accidental scrolls), a reload event is generated. The bounds of +# the content area are forcibly extended to include the refresh view. +# +# All of this is also gated by the refreshEnabled flag; when refresh is disabled, it +# also makes the refresh widget invisible so that it can't be seen in a bounce scroll. +######################################################################################### + +# The height of the refresh header; also the minimum pull height to trigger a refresh. HEADER_HEIGHT = 45.0 @@ -49,10 +99,12 @@ def constrainScrollPoint_(self, proposedNewOrigin: NSPoint) -> NSPoint: argtypes=[NSPoint], ) - if self.superview and self.superview.refreshTriggered: + if self.superview and self.superview.is_refreshing: return NSMakePoint( constrained.x, - max(proposedNewOrigin.y, -self.superview.refreshView.frame.size.height), + max( + proposedNewOrigin.y, -self.superview.refresh_view.frame.size.height + ), ) return constrained @@ -61,12 +113,12 @@ def constrainScrollPoint_(self, proposedNewOrigin: NSPoint) -> NSPoint: def documentRect(self) -> NSRect: rect = send_super(__class__, self, "documentRect", restype=NSRect, argtypes=[]) - if self.superview and self.superview.refreshTriggered: + if self.superview and self.superview.is_refreshing: return NSMakeRect( rect.origin.x, - rect.origin.y - self.superview.refreshView.frame.size.height, + rect.origin.y - self.superview.refresh_view.frame.size.height, rect.size.width, - rect.size.height + self.superview.refreshView.frame.size.height, + rect.size.height + self.superview.refresh_view.frame.size.height, ) return rect @@ -75,112 +127,91 @@ class RefreshableScrollView(NSScrollView): interface = objc_property(object, weak=True) impl = objc_property(object, weak=True) - # Create Header View - @objc_method - def viewDidMoveToWindow(self) -> None: - self.refreshTriggered = False - self.isRefreshing = False - self.refreshView = None - self.refreshIndicator = None - self.createRefreshView() - @objc_method - def createContentView(self): - superClipView = ObjCInstance(send_super(__class__, self, "contentView")) - if not isinstance(superClipView, RefreshableClipView): - # create new clipview - documentView = superClipView.documentView - clipView = RefreshableClipView.alloc().initWithFrame(superClipView.frame) - - clipView.documentView = documentView - clipView.copiesOnScroll = False - clipView.drawsBackground = False - - self.setContentView(clipView) - superClipView = ObjCInstance(send_super(__class__, self, "contentView")) + def initWithDocument_(self, documentView): + self = ObjCInstance(send_super(__class__, self, "init")) + self.hasVerticalScroller = True + self.verticalScrollElasticity = NSScrollElasticityAllowed + self.hasHorizontalScroller = False + self.autohidesScrollers = False + self.borderType = NSBezelBorder - return superClipView + # Set local refresh-controlling properties + self.event_ended = False + self.is_refreshing = False - @objc_method - def createRefreshView(self) -> None: - # delete old stuff if any - if self.refreshView: - self.refreshView.removeFromSuperview() - self.refreshView.release() - self.refreshView = None + # Create the clipview that contains the document and refresh view. + superClipView = ObjCInstance(send_super(__class__, self, "contentView")) + clipView = RefreshableClipView.alloc().initWithFrame(superClipView.frame) - self.verticalScrollElasticity = NSScrollElasticityAllowed + clipView.documentView = documentView + clipView.copiesOnScroll = False + clipView.drawsBackground = False - # create new content view - self.createContentView() + self.setContentView(clipView) self.contentView.postsFrameChangedNotifications = True self.contentView.postsBoundsChangedNotifications = True - NSNotificationCenter.defaultCenter.addObserver( - self, - selector=SEL("viewBoundsChanged:"), - name=NSViewBoundsDidChangeNotification, - object=self.contentView, - ) - - # Create view to hold the refresh widgets refreshview - contentRect = self.contentView.documentView.frame - self.refreshView = NSView.alloc().init() - self.refreshView.translatesAutoresizingMaskIntoConstraints = False + # Create view to hold the refresh widgets + self.refresh_view = NSView.alloc().init() + self.refresh_view.translatesAutoresizingMaskIntoConstraints = False # Create spinner - self.refreshIndicator = NSProgressIndicator.alloc().init() - self.refreshIndicator.style = NSProgressIndicatorSpinningStyle - self.refreshIndicator.translatesAutoresizingMaskIntoConstraints = False - self.refreshIndicator.displayedWhenStopped = True - self.refreshIndicator.usesThreadedAnimation = True - self.refreshIndicator.indeterminate = True - self.refreshIndicator.bezeled = False - self.refreshIndicator.sizeToFit() + self.refresh_indicator = NSProgressIndicator.alloc().init() + self.refresh_indicator.style = NSProgressIndicatorSpinningStyle + self.refresh_indicator.translatesAutoresizingMaskIntoConstraints = False + self.refresh_indicator.displayedWhenStopped = True + self.refresh_indicator.usesThreadedAnimation = True + self.refresh_indicator.indeterminate = True + self.refresh_indicator.bezeled = False + self.refresh_indicator.sizeToFit() + + # Hide the refresh indicator by default; this will be made visible when refresh + # is explicitly enabled. + self.refresh_indicator.setHidden(True) # Center the spinner in the header - self.refreshIndicator.setFrame( + self.refresh_indicator.setFrame( NSMakeRect( - self.refreshView.bounds.size.width / 2 - - self.refreshIndicator.frame.size.width / 2, - self.refreshView.bounds.size.height / 2 - - self.refreshIndicator.frame.size.height / 2, - self.refreshIndicator.frame.size.width, - self.refreshIndicator.frame.size.height, + self.refresh_view.bounds.size.width / 2 + - self.refresh_indicator.frame.size.width / 2, + self.refresh_view.bounds.size.height / 2 + - self.refresh_indicator.frame.size.height / 2, + self.refresh_indicator.frame.size.width, + self.refresh_indicator.frame.size.height, ) ) # Put everything in place - self.refreshView.addSubview(self.refreshIndicator) - # self.refreshView.addSubview(self.refreshArrow) - self.contentView.addSubview(self.refreshView) + self.refresh_view.addSubview(self.refresh_indicator) + self.contentView.addSubview(self.refresh_view) # set layout constraints indicatorHCenter = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 - self.refreshIndicator, + self.refresh_indicator, NSLayoutAttributeCenterX, NSLayoutRelationEqual, - self.refreshView, + self.refresh_view, NSLayoutAttributeCenterX, 1.0, 0, ) - self.refreshView.addConstraint(indicatorHCenter) + self.refresh_view.addConstraint(indicatorHCenter) indicatorVCenter = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 - self.refreshIndicator, + self.refresh_indicator, NSLayoutAttributeCenterY, NSLayoutRelationEqual, - self.refreshView, + self.refresh_view, NSLayoutAttributeCenterY, 1.0, 0, ) - self.refreshView.addConstraint(indicatorVCenter) + self.refresh_view.addConstraint(indicatorVCenter) refreshWidth = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 - self.refreshView, + self.refresh_view, NSLayoutAttributeWidth, NSLayoutRelationEqual, self.contentView, @@ -191,7 +222,7 @@ def createRefreshView(self) -> None: self.contentView.addConstraint(refreshWidth) refreshHeight = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 - self.refreshView, + self.refresh_view, NSLayoutAttributeHeight, NSLayoutRelationEqual, None, @@ -202,7 +233,7 @@ def createRefreshView(self) -> None: self.contentView.addConstraint(refreshHeight) refreshHeight = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 - self.refreshView, + self.refresh_view, NSLayoutAttributeTop, NSLayoutRelationEqual, self.contentView, @@ -213,46 +244,48 @@ def createRefreshView(self) -> None: self.contentView.addConstraint(refreshHeight) # Scroll to top + contentRect = self.contentView.documentView.frame self.contentView.scrollToPoint(NSMakePoint(contentRect.origin.x, 0)) self.reflectScrolledClipView(self.contentView) + return self + + @objc_method + def setRefreshEnabled(self, enabled: bool): + self.refresh_enabled = enabled + self.refresh_indicator.setHidden(not enabled) + + ###################################################################### # Detecting scroll + ###################################################################### @objc_method def scrollWheel_(self, event) -> None: - if event.phase == NSEventPhaseEnded: - if self.refreshTriggered and not self.isRefreshing: - self.reload() + if ( + self.refresh_enabled + and event.momentumPhase == NSEventPhaseBegan + and event.scrollingDeltaY > HEADER_HEIGHT + and self.contentView.bounds.origin.y < 0 + ): + self.is_refreshing = True + # Extend the content view area to ensure the refresh view is visible, + # start the loading animation, and trigger the user's refresh handler. + self.contentView.scrollToPoint( + NSMakePoint(self.contentView.bounds.origin.x, -HEADER_HEIGHT) + ) + self.refresh_indicator.startAnimation(self) + self.interface.on_refresh(self.interface) send_super(__class__, self, "scrollWheel:", event, argtypes=[c_void_p]) - @objc_method - def viewBoundsChanged_(self, note) -> None: - if self.isRefreshing: - return - - if self.contentView.bounds.origin.y <= -self.refreshView.frame.size.height: - self.refreshTriggered = True - - # Reload - @objc_method - def reload(self) -> None: - """Start a reload, starting the reload spinner.""" - self.isRefreshing = True - self.refreshIndicator.startAnimation(self) - self.interface.on_refresh(self.interface) - @objc_method def finishedLoading(self): """Invoke to mark the end of a reload, stopping and hiding the reload spinner.""" - self.isRefreshing = False - self.refreshTriggered = False - self.refreshIndicator.stopAnimation(self) - self.detailedlist.reloadData() - - # Force a scroll event to make the scroll hide the reload - cgEvent = core_graphics.CGEventCreateScrollWheelEvent( - None, kCGScrollEventUnitLine, 2, 1, 0 - ) - scrollEvent = NSEvent.eventWithCGEvent(cgEvent) - self.scrollWheel(scrollEvent) + self.is_refreshing = False + self.refresh_indicator.stopAnimation(self) + self.documentView.reloadData() + + # Scroll back to top, hiding the refresh window from view. + contentRect = self.contentView.documentView.frame + self.contentView.scrollToPoint(NSMakePoint(contentRect.origin.x, 0)) + self.reflectScrolledClipView(self.contentView) diff --git a/cocoa/tests_backend/widgets/detailedlist.py b/cocoa/tests_backend/widgets/detailedlist.py new file mode 100644 index 0000000000..3036cdb95d --- /dev/null +++ b/cocoa/tests_backend/widgets/detailedlist.py @@ -0,0 +1,217 @@ +import asyncio +from ctypes import c_int64, c_uint32, c_uint64 + +from rubicon.objc import CGPoint, NSPoint + +from toga_cocoa.libs import ( + CGEventRef, + NSEvent, + NSEventType, + NSScrollView, + NSTableView, + core_graphics, + kCGScrollEventUnitPixel, +) + +from .base import SimpleProbe + +NSEventModifierFlagCommand = 1 << 20 + +kCGScrollWheelEventScrollPhase = 99 +kCGScrollWheelEventMomentumPhase = 123 + +kCGScrollPhaseBegan = 1 +kCGMomentumScrollPhaseBegin = 1 + +CGEventField = c_uint32 +CGEventFlags = c_uint64 + +core_graphics.CGEventSetLocation.argtypes = [CGEventRef, CGPoint] +core_graphics.CGEventSetLocation.restype = None + +core_graphics.CGEventSetFlags.argtypes = [CGEventRef, CGEventFlags] +core_graphics.CGEventSetFlags.restype = None + +core_graphics.CGEventSetIntegerValueField.argtypes = [CGEventRef, CGEventField, c_int64] +core_graphics.CGEventSetIntegerValueField.restype = None + + +class DetailedListProbe(SimpleProbe): + native_class = NSScrollView + supports_actions = True + supports_refresh = True + + def __init__(self, widget): + super().__init__(widget) + self.native_detailedlist = widget._impl.native_detailedlist + assert isinstance(self.native_detailedlist, NSTableView) + + @property + def row_count(self): + return int( + self.native_detailedlist.numberOfRowsInTableView(self.native_detailedlist) + ) + + def assert_cell_content(self, row, title, subtitle, icon=None): + data = self.native_detailedlist.tableView( + self.native_detailedlist, + objectValueForTableColumn=self.native_detailedlist.tableColumns[0], + row=row, + ) + assert str(data.attrs["title"]) == title + assert str(data.attrs["subtitle"]) == subtitle + + if icon: + assert data.attrs["icon"] == icon._impl.native + else: + assert data.attrs["icon"] is None + + @property + def max_scroll_position(self): + return int(self.native.documentView.bounds.size.height) - int( + self.native.contentView.bounds.size.height + ) + + @property + def scroll_position(self): + return int(self.native.contentView.bounds.origin.y) + + def scroll_to_top(self): + self.native.contentView.scrollToPoint( + NSPoint(self.native.contentView.documentView.frame.origin.x, 0) + ) + self.native.reflectScrolledClipView(self.native.contentView) + + async def wait_for_scroll_completion(self): + # No animation associated with scroll, so this is a no-op + pass + + def row_position(self, row): + # Pick a point half way across horizontally, and half way down the row, + # taking into account the size of the rows and the header + row_height = self.native_detailedlist.tableView( + self.native_detailedlist, heightOfRow=row + ) + return self.native_detailedlist.convertPoint( + NSPoint( + self.width / 2, + (row * row_height) + (row_height / 2), + ), + toView=None, + ) + + async def select_row(self, row, add=False): + point = self.row_position(row) + # Table maintains an inner mouse event loop, so we can't + # use the "wait for another event" approach for the mouse events. + # Use a short delay instead. + await self.mouse_event( + NSEventType.LeftMouseDown, + point, + delay=0.1, + modifierFlags=NSEventModifierFlagCommand if add else 0, + ) + await self.mouse_event( + NSEventType.LeftMouseUp, + point, + delay=0.1, + modifierFlags=NSEventModifierFlagCommand if add else 0, + ) + + async def _refresh_action(self, offset): + # Create a scroll event where event phase = Began, Momenum scroll phase = Begin, + # and the pixel value is equal to the requested offset. + cg_event = core_graphics.CGEventCreateScrollWheelEvent( + None, kCGScrollEventUnitPixel, 2, offset, 0 + ) + core_graphics.CGEventSetLocation( + cg_event, + self.native_detailedlist.convertPoint(NSPoint(200, 200), toView=None), + ) + core_graphics.CGEventSetFlags(cg_event, 0) + + core_graphics.CGEventSetIntegerValueField( + cg_event, + kCGScrollWheelEventScrollPhase, + kCGScrollPhaseBegan, + ) + core_graphics.CGEventSetIntegerValueField( + cg_event, + kCGScrollWheelEventMomentumPhase, + kCGMomentumScrollPhaseBegin, + ) + + ns_event = NSEvent.eventWithCGEvent(cg_event) + + # Scroll the view to the position where + self.native.contentView.scrollToPoint(NSPoint(0, -offset)) + self.native.reflectScrolledClipView(self.native.contentView) + + self.native_detailedlist.scrollWheel(ns_event) + + # Ensure the refresh indicator is hidden if there is no refresh handler. + is_disabled = self.widget.on_refresh._raw is None + assert self.native.refresh_indicator.isHidden() == is_disabled + + def refresh_available(self): + return self.scroll_position <= 0 + + async def non_refresh_action(self): + # 20px is enough to be visible, but not enough + await self._refresh_action(20) + # Simulate a short delay before releasing the pull-to-refresh + await asyncio.sleep(0.1) + self.scroll_to_top() + + async def refresh_action(self, active=True): + # 50px is enough to trigger a refresh + await self._refresh_action(50) + + if not active: + assert self.native.refresh_indicator.isHidden() + # If refresh is disabled, simulate a short delay before releasing the + # pull-to-refresh + await asyncio.sleep(0.1) + self.scroll_to_top() + else: + assert not self.native.refresh_indicator.isHidden() + # Wait for the scroll to relax after reload completion + while self.scroll_position < 0: + await asyncio.sleep(0.01) + + async def _perform_action(self, row, offset): + point = self.row_position(row) + # First click to show menu + await self.mouse_event( + NSEventType.RightMouseDown, + point, + delay=0.1, + ) + await self.redraw("Action menu has been displayed") + + # Pick a point a little to the right of the point where the menu was displayed, + # and slightly lower (in reversed y coordinates) to select a menu item. + point2 = NSPoint(point.x + 10, point.y - offset) + await self.mouse_event( + NSEventType.LeftMouseDown, + point2, + delay=0.1, + ) + await self.mouse_event( + NSEventType.LeftMouseUp, + point2, + delay=0.1, + ) + + async def perform_primary_action(self, row, active=True): + # 10px is enough to select the first menu item. It doesn't matter whether the + # action is active or not; if the action is inactive, it will either press the + # wrong action, or press empty space. + await self._perform_action(row, 10) + + async def perform_secondary_action(self, row, active=True): + # 30px is enough to select the second menu item. However the secondary action + # will be in position 1 on the menu if the primary action is disabled. It + # doesn't matter whether the action is active or not; if the action is inactive, + # it will either press the wrong action, or press empty space. + await self._perform_action(row, 30 if self.impl.primary_action_enabled else 10) diff --git a/cocoa/tests_backend/widgets/progressbar.py b/cocoa/tests_backend/widgets/progressbar.py index 3a01b227e6..02830047e1 100644 --- a/cocoa/tests_backend/widgets/progressbar.py +++ b/cocoa/tests_backend/widgets/progressbar.py @@ -18,3 +18,7 @@ def is_animating_indeterminate(self): @property def position(self): return float(self.native.doubleValue / self.native.maxValue) + + async def wait_for_animation(self): + # Cocoa ProgressBar has internal animation handling; no special handling required. + pass diff --git a/cocoa/tests_backend/widgets/table.py b/cocoa/tests_backend/widgets/table.py index 53765c5559..1d76952900 100644 --- a/cocoa/tests_backend/widgets/table.py +++ b/cocoa/tests_backend/widgets/table.py @@ -11,7 +11,7 @@ class TableProbe(SimpleProbe): native_class = NSScrollView - supports_icons = True + supports_icons = 2 # All columns supports_keyboard_shortcuts = True supports_widgets = True diff --git a/core/src/toga/sources/list_source.py b/core/src/toga/sources/list_source.py index 4db887e1dd..b72e696fb9 100644 --- a/core/src/toga/sources/list_source.py +++ b/core/src/toga/sources/list_source.py @@ -47,7 +47,7 @@ def __init__(self, **data): name doesn't start with ``_``), the source to which the row belongs will be notified. """ - self._source: Source = None + self._source: Source | None = None for name, value in data.items(): setattr(self, name, value) @@ -74,6 +74,17 @@ def __setattr__(self, attr: str, value): if self._source is not None: self._source.notify("change", item=self) + def __delattr__(self, attr: str): + """Remove an attribute from the Row object, notifying the source of the change. + + :param attr: The attribute to change. + :param value: The new attribute value. + """ + super().__delattr__(attr) + if not attr.startswith("_"): + if self._source is not None: + self._source.notify("change", item=self) + class ListSource(Source): def __init__(self, accessors: list[str], data: Iterable | None = None): diff --git a/core/src/toga/widgets/detailedlist.py b/core/src/toga/widgets/detailedlist.py index 11c4b12208..2b4d818f9a 100644 --- a/core/src/toga/widgets/detailedlist.py +++ b/core/src/toga/widgets/detailedlist.py @@ -1,98 +1,122 @@ +from __future__ import annotations + import warnings +from typing import Any from toga.handlers import wrapped_handler -from toga.sources import ListSource +from toga.sources import ListSource, Row, Source from .base import Widget class DetailedList(Widget): - """A widget to hold data in a list form. Rows are selectable and can be deleted. An - updated function can be invoked by pulling the list down. - - Args: - id (str): An identifier for this widget. - data (list of `dict`): List of dictionaries with required 'icon', 'title', and - 'subtitle' keys as well as optional custom keys to store additional - info like 'pk' for a database primary key (think Django ORM) - on_delete (``Callable``): Function that is invoked on row deletion. - on_refresh (``Callable``): Function that is invoked on user initialized refresh. - on_select (``Callable``): Function that is invoked on row selection. - style (:obj:`Style`): An optional style object. If no style is provided then - a new one will be created for the widget. - - Examples: - >>> import toga - >>> def selection_handler(widget, row): - >>> print('Row {} of widget {} was selected.'.format(row, widget)) - >>> - >>> dlist = toga.DetailedList( - ... data=[ - ... { - ... 'icon': '', - ... 'title': 'John Doe', - ... 'subtitle': 'Employee of the Month', - ... 'pk': 100 - ... } - ... ], - ... on_select=selection_handler - ... ) - """ - - MIN_HEIGHT = 100 - MIN_WIDTH = 100 - def __init__( self, id=None, - data=None, - on_delete=None, - on_refresh=None, - on_select=None, style=None, - factory=None, # DEPRECATED! + data: Any = None, + accessors: tuple[str, str, str] = ("title", "subtitle", "icon"), + missing_value: str = "", + primary_action: str | None = "Delete", + on_primary_action: callable = None, + secondary_action: str | None = "Action", + on_secondary_action: callable = None, + on_refresh: callable = None, + on_select: callable = None, + on_delete: callable = None, # DEPRECATED ): + """Create a new DetailedList widget. + + :param id: The ID for the widget. + :param style: A style object. If no style is provided, a default style will be + applied to the widget. + :param data: Initial :any:`data` to be displayed in the list. + :param accessors: The accessors to use to retrieve the data for each item, in + the form (title, subtitle, icon). + :param missing_value: The text that will be shown when a row doesn't provide a + value for its title or subtitle. + :param on_select: Initial :any:`on_select` handler. + :param primary_action: The name for the primary action. + :param on_primary_action: Initial :any:`on_primary_action` handler. + :param secondary_action: The name for the secondary action. + :param on_secondary_action: Initial :any:`on_secondary_action` handler. + :param on_refresh: Initial :any:`on_refresh` handler. + :param on_delete: **DEPRECATED**; use ``on_primary_action``. + """ super().__init__(id=id, style=style) + ###################################################################### - # 2022-09: Backwards compatibility + # 2023-06: Backwards compatibility ###################################################################### - # factory no longer used - if factory: - warnings.warn("The factory argument is no longer used.", DeprecationWarning) + if on_delete: + if on_primary_action: + raise ValueError("Cannot specify both on_delete and on_primary_action") + else: + warnings.warn( + "DetailedList.on_delete has been renamed DetailedList.on_primary_action.", + DeprecationWarning, + ) + on_primary_action = on_delete ###################################################################### # End backwards compatibility. ###################################################################### + # Prime the attributes and handlers that need to exist when the widget is created. + self._accessors = accessors + self._primary_action = primary_action + self._secondary_action = secondary_action + self._missing_value = missing_value self._data = None - self._on_delete = None - self._on_refresh = None - # at least _on_select must be defined before setting data for the Gtk impl - self._on_select = None + self.on_select = None + self._impl = self.factory.DetailedList(interface=self) self.data = data - self.on_delete = on_delete + self.on_primary_action = on_primary_action + self.on_secondary_action = on_secondary_action self.on_refresh = on_refresh self.on_select = on_select @property - def data(self): - """The data source of the widget. It accepts data in the form of - ``list`` of ``dict`` or :class:`ListSource` + def enabled(self) -> bool: + """Is the widget currently enabled? i.e., can the user interact with the widget? + DetailedList widgets cannot be disabled; this property will always return True; any + attempt to modify it will be ignored. + """ + return True + + @enabled.setter + def enabled(self, value): + pass + + def focus(self): + "No-op; DetailedList cannot accept input focus" + pass + + @property + def data(self) -> ListSource: + """The data to display in the table. + + When setting this property: - Returns: - Returns a (:obj:`ListSource`). + * A :any:`Source` will be used as-is. It must either be a :any:`ListSource`, or + a custom class that provides the same methods. + + * A value of None is turned into an empty ListSource. + + * Otherwise, the value must be an iterable, which is copied into a new + ListSource. Items are converted as shown :ref:`here `. """ return self._data @data.setter - def data(self, data): + def data(self, data: Any): if data is None: - self._data = ListSource(data=[], accessors=["icon", "title", "subtitle"]) - elif isinstance(data, (list, tuple)): - self._data = ListSource(data=data, accessors=["icon", "title", "subtitle"]) - else: + self._data = ListSource(data=[], accessors=self.accessors) + elif isinstance(data, Source): self._data = data + else: + self._data = ListSource(data=data, accessors=self.accessors) self._data.add_listener(self._impl) self._impl.change_source(source=self._data) @@ -101,74 +125,124 @@ def scroll_to_top(self): """Scroll the view so that the top of the list (first row) is visible.""" self.scroll_to_row(0) - def scroll_to_row(self, row): + def scroll_to_row(self, row: int): """Scroll the view so that the specified row index is visible. - Args: - row: The index of the row to make visible. Negative values refer - to the nth last row (-1 is the last row, -2 second last, - and so on) + :param row: The index of the row to make visible. Negative values refer to the + nth last row (-1 is the last row, -2 second last, and so on). """ - if row >= 0: - self._impl.scroll_to_row(row) - else: - self._impl.scroll_to_row(len(self.data) + row) + if len(self.data) > 1: + if row >= 0: + self._impl.scroll_to_row(min(row, len(self.data))) + else: + self._impl.scroll_to_row(max(len(self.data) + row, 0)) def scroll_to_bottom(self): """Scroll the view so that the bottom of the list (last row) is visible.""" self.scroll_to_row(-1) @property - def on_delete(self): - """The function invoked on row deletion. The delete handler must accept two - arguments. The first is a ref. to the widget and the second the row that is - about to be deleted. + def accessors(self) -> list[str]: + """The accessors used to populate the list (read-only)""" + return self._accessors - Examples: - >>> def delete_handler(widget, row): - >>> print('row ', row, 'is going to be deleted from widget', widget) - - Returns: - The function that is invoked when deleting a row. + @property + def missing_value(self) -> str: + """The text that will be shown when a row doesn't provide a value for its + title or subtitle. """ - return self._on_delete - - @on_delete.setter - def on_delete(self, handler: callable): - self._on_delete = wrapped_handler(self, handler) - self._impl.set_on_delete(self._on_delete) + return self._missing_value @property - def on_refresh(self): + def selection(self) -> Row | None: + """The current selection of the table. + + Returns the selected Row object, or :any:`None` if no row is currently selected. """ - Returns: - The function to be invoked on user initialized refresh. + try: + return self.data[self._impl.get_selection()] + except TypeError: + return None + + @property + def on_primary_action(self) -> callable: + """The handler to invoke when the user performs the primary action on a row of + the DetailedList. + + The primary action is "swipe left" on platforms that use swipe interactions; + other platforms may manifest this action in other ways (e.g, a context menu). + + If no ``on_primary_action`` handler is provided, the primary action will be + disabled in the UI. """ - return self._on_refresh + return self._on_primary_action - @on_refresh.setter - def on_refresh(self, handler: callable): - self._on_refresh = wrapped_handler(self, handler, self._impl.after_on_refresh) - self._impl.set_on_refresh(self._on_refresh) + @on_primary_action.setter + def on_primary_action(self, handler: callable): + self._on_primary_action = wrapped_handler(self, handler) + self._impl.set_primary_action_enabled(handler is not None) @property - def selection(self): - """The current selection. + def on_secondary_action(self) -> callable: + """The handler to invoke when the user performs the secondary action on a row of + the DetailedList. + + The secondary action is "swipe right" on platforms that use swipe interactions; + other platforms may manifest this action in other ways (e.g, a context menu). - A value of None indicates no selection. + If no ``on_secondary_action`` handler is provided, the secondary action will be + disabled in the UI. """ - return self._impl.get_selection() + return self._on_secondary_action + + @on_secondary_action.setter + def on_secondary_action(self, handler: callable): + self._on_secondary_action = wrapped_handler(self, handler) + self._impl.set_secondary_action_enabled(handler is not None) @property - def on_select(self): - """The handler function must accept two arguments, widget and row. + def on_refresh(self) -> callable: + """The callback function to invoke when the user performs a refresh action + (usually "pull down") on the DetailedList. - Returns: - The function to be invoked on selecting a row. + If no ``on_refresh`` handler is provided, the refresh UI action will be + disabled. """ + return self._on_refresh + + @on_refresh.setter + def on_refresh(self, handler: callable): + self._on_refresh = wrapped_handler( + self, handler, cleanup=self._impl.after_on_refresh + ) + self._impl.set_refresh_enabled(handler is not None) + + @property + def on_select(self) -> callable: + """The callback function that is invoked when a row of the DetailedList is selected.""" return self._on_select @on_select.setter def on_select(self, handler: callable): self._on_select = wrapped_handler(self, handler) - self._impl.set_on_select(self._on_select) + + ###################################################################### + # 2023-06: Backwards compatibility + ###################################################################### + + @property + def on_delete(self): + """**DEPRECATED**; Use :any:`on_primary_action`""" + warnings.warn( + "DetailedList.on_delete has been renamed DetailedList.on_primary_action.", + DeprecationWarning, + ) + return self.on_primary_action + + @on_delete.setter + def on_delete(self, handler): + warnings.warn( + "DetailedList.on_delete has been renamed DetailedList.on_primary_action.", + DeprecationWarning, + ) + self.on_primary_action = handler diff --git a/core/tests/sources/test_node.py b/core/tests/sources/test_node.py index f6f503f723..2b11ba4d88 100644 --- a/core/tests/sources/test_node.py +++ b/core/tests/sources/test_node.py @@ -103,15 +103,70 @@ def test_leaf_node_properties(leaf_node): def test_modify_attributes(source, node): """If node attributes are modified, a change notification is sent""" node.val1 = "new value" + assert node.val1 == "new value" source.notify.assert_called_once_with("change", item=node) source.notify.reset_mock() + # Deleting an attribute causes a change notification + del node.val1 + assert not hasattr(node, "val1") + source.notify.assert_called_once_with("change", item=node) + source.notify.reset_mock() + + # Setting an attribute starting with with an underscore isn't a notifiable event + node._secret = "secret value" + assert node._secret == "secret value" + source.notify.assert_not_called() + + # Deleting an attribute starting with with an underscore isn't a notifiable event + del node._secret + assert not hasattr(node, "_secret") + source.notify.assert_not_called() + # An attribute that wasn't in the original attribute set # still causes a change notification node.val3 = "other value" + assert node.val3 == "other value" source.notify.assert_called_once_with("change", item=node) source.notify.reset_mock() + # Deleting an attribute that wasn't in the original attribute set + # still causes a change notification + del node.val3 + assert not hasattr(node, "val3") + source.notify.assert_called_once_with("change", item=node) + source.notify.reset_mock() + + +def test_modify_attributes_no_source(node): + """Node attributes can be modified on a node with no source""" + node.source = None + + node.val1 = "new value" + assert node.val1 == "new value" + + # Deleting an attribute causes a change notification + del node.val1 + assert not hasattr(node, "val1") + + # Setting an attribute starting with with an underscore isn't a notifiable event + node._secret = "secret value" + assert node._secret == "secret value" + + # Deleting an attribute starting with with an underscore isn't a notifiable event + del node._secret + assert not hasattr(node, "_secret") + + # An attribute that wasn't in the original attribute set + # still causes a change notification + node.val3 = "other value" + assert node.val3 == "other value" + + # Deleting an attribute that wasn't in the original attribute set + # still causes a change notification + del node.val3 + assert not hasattr(node, "val3") + def test_modify_children(source, node): """Node children can be retrieved and modified""" diff --git a/core/tests/sources/test_row.py b/core/tests/sources/test_row.py index 3fd37e0de6..3c77897d14 100644 --- a/core/tests/sources/test_row.py +++ b/core/tests/sources/test_row.py @@ -7,17 +7,71 @@ def test_row(): "A row can be created and modified" source = Mock() row = Row(val1="value 1", val2=42) + # Set a source. row._source = source + # initial values are as expected assert row.val1 == "value 1" assert row.val2 == 42 + # An existing attribute can be updated. row.val1 = "new value" + assert row.val1 == "new value" source.notify.assert_called_once_with("change", item=row) source.notify.reset_mock() + # Deleting an attribute causes a change notification + del row.val1 + assert not hasattr(row, "val1") + source.notify.assert_called_once_with("change", item=row) + source.notify.reset_mock() + + # Setting an attribute with an underscore isn't a notifiable event + row._secret = "secret value" + assert row._secret == "secret value" + source.notify.assert_not_called() + # An attribute that wasn't in the original attribute set # still causes a change notification row.val3 = "other value" + assert row.val3 == "other value" + source.notify.assert_called_once_with("change", item=row) + source.notify.reset_mock() + + # Deleting an attribute that wasn't in the original attribute set + # still causes a change notification + del row.val3 + assert not hasattr(row, "val") source.notify.assert_called_once_with("change", item=row) source.notify.reset_mock() + + +def test_row_without_source(): + "A row with no source can be created and modified" + row = Row(val1="value 1", val2=42) + + # initial values are as expected + assert row.val1 == "value 1" + assert row.val2 == 42 + + # An existing attribute can be updated. + row.val1 = "new value" + assert row.val1 == "new value" + + # Deleting an attribute causes a change notification + del row.val1 + assert not hasattr(row, "val1") + + # Setting an attribute starting with with an underscore isn't a notifiable event + row._secret = "secret value" + assert row._secret == "secret value" + + # An attribute that wasn't in the original attribute set + # still causes a change notification + row.val3 = "other value" + assert row.val3 == "other value" + + # Deleting an attribute that wasn't in the original attribute set + # still causes a change notification + del row.val3 + assert not hasattr(row, "val") diff --git a/core/tests/test_deprecated_factory.py b/core/tests/test_deprecated_factory.py index 418bba091d..47c1926b34 100644 --- a/core/tests/test_deprecated_factory.py +++ b/core/tests/test_deprecated_factory.py @@ -67,12 +67,6 @@ def test_canvas_created(self): self.assertEqual(widget._impl.interface, widget) self.assertNotEqual(widget.factory, self.factory) - def test_detailed_list_created(self): - with self.assertWarns(DeprecationWarning): - widget = toga.DetailedList(factory=self.factory) - self.assertEqual(widget._impl.interface, widget) - self.assertNotEqual(widget.factory, self.factory) - ###################################################################### # End backwards compatibility. ###################################################################### diff --git a/core/tests/widgets/test_detailedlist.py b/core/tests/widgets/test_detailedlist.py index b510dac616..eb051a632c 100644 --- a/core/tests/widgets/test_detailedlist.py +++ b/core/tests/widgets/test_detailedlist.py @@ -1,106 +1,348 @@ +from unittest.mock import Mock + +import pytest + import toga from toga.sources import ListSource -from toga_dummy.utils import TestCase - - -class TestDetailedList(TestCase): - def setUp(self): - super().setUp() - - self.on_select = None - self.on_delete = None - self.on_refresh = None - - self.dlist = toga.DetailedList( - on_select=self.on_select, - on_delete=self.on_delete, - on_refresh=self.on_refresh, - ) - - def test_widget_created(self): - self.assertEqual(self.dlist._impl.interface, self.dlist) - self.assertActionPerformed(self.dlist, "create DetailedList") - - def test_detailedlist_property(self): - test_list = ["test1", "test2", " "] - self.dlist.data = test_list - listsource_list = ListSource( - data=test_list, accessors=["icon", "label1", "label2"] - ) - for i in range(len(self.dlist.data)): - self.assertEqual(self.dlist.data[i].icon, listsource_list[i].icon) - - test_tuple = ("ttest1", "ttest2", " ") - self.dlist.data = test_tuple - listsource_tuple = ListSource( - data=test_tuple, accessors=["icon", "label1", "label2"] - ) - for i in range(len(self.dlist.data)): - self.assertEqual(self.dlist.data[i].icon, listsource_tuple[i].icon) - - self.dlist.data = listsource_list - for i in range(len(self.dlist.data)): - self.assertEqual(self.dlist.data[i].icon, listsource_list[i].icon) - - def test_scroll_to_row(self): - test_list = ["test1", "test2", "test3", " "] - self.dlist.data = test_list - self.dlist.scroll_to_row(2) - self.assertValueSet(self.dlist, "scroll to", 2) - - def test_scroll_to_top(self): - test_list = ["test1", "test2", "test3", " "] - self.dlist.data = test_list - self.dlist.scroll_to_top() - self.assertValueSet(self.dlist, "scroll to", 0) - - def test_scroll_to_bottom(self): - test_list = ["test1", "test2", "test3", " "] - self.dlist.data = test_list - self.dlist.scroll_to_bottom() - self.assertValueSet(self.dlist, "scroll to", len(self.dlist.data) - 1) - - def test_on_delete(self): - self.assertIsNone(self.dlist.on_delete._raw) - - # set a new callback - def callback(widget, **extra): - return f"called {type(widget)} with {extra}" - - self.dlist.on_delete = callback - self.assertEqual(self.dlist.on_delete._raw, callback) - self.assertEqual( - self.dlist.on_delete(None, a=1), - "called with {'a': 1}", - ) - self.assertValueSet(self.dlist, "on_delete", self.dlist.on_delete) - - def test_on_refresh(self): - self.assertIsNone(self.dlist.on_refresh._raw) - - # set a new callback - def callback(widget, **extra): - return f"called {type(widget)} with {extra}" - - self.dlist.on_refresh = callback - self.assertEqual(self.dlist.on_refresh._raw, callback) - self.assertEqual( - self.dlist.on_refresh(None, a=1), - "called with {'a': 1}", - ) - self.assertValueSet(self.dlist, "on_refresh", self.dlist.on_refresh) - - def test_on_select(self): - self.assertIsNone(self.dlist._on_select._raw) - - # set a new callback - def callback(widget, **extra): - return f"called {type(widget)} with {extra}" - - self.dlist.on_select = callback - self.assertEqual(self.dlist.on_select._raw, callback) - self.assertEqual( - self.dlist.on_select(None, a=1), - "called with {'a': 1}", - ) - self.assertValueSet(self.dlist, "on_select", self.dlist.on_select) +from toga_dummy.utils import ( + assert_action_not_performed, + assert_action_performed, + assert_action_performed_with, +) + + +@pytest.fixture +def on_select_handler(): + return Mock() + + +@pytest.fixture +def on_refresh_handler(): + return Mock(return_value=None) + + +@pytest.fixture +def on_primary_action_handler(): + return Mock() + + +@pytest.fixture +def on_secondary_action_handler(): + return Mock() + + +@pytest.fixture +def source(): + return ListSource( + accessors=["key", "value", "icon"], + data=[ + {"key": "first", "value": 111, "other": "aaa"}, + {"key": "second", "value": 222, "other": "bbb"}, + {"key": "third", "value": 333, "other": "ccc"}, + ], + ) + + +@pytest.fixture +def detailedlist( + source, + on_select_handler, + on_refresh_handler, + on_primary_action_handler, + on_secondary_action_handler, +): + return toga.DetailedList( + accessors=["key", "value", "icon"], + data=source, + on_select=on_select_handler, + on_refresh=on_refresh_handler, + on_primary_action=on_primary_action_handler, + on_secondary_action=on_secondary_action_handler, + ) + + +def test_detailedlist_created(): + "An minimal DetailedList can be created" + detailedlist = toga.DetailedList() + assert detailedlist._impl.interface == detailedlist + assert_action_performed(detailedlist, "create DetailedList") + + assert len(detailedlist.data) == 0 + assert detailedlist.accessors == ("title", "subtitle", "icon") + assert detailedlist.missing_value == "" + assert detailedlist.on_select._raw is None + assert detailedlist.on_refresh._raw is None + assert detailedlist.on_primary_action._raw is None + assert detailedlist.on_secondary_action._raw is None + assert detailedlist._primary_action == "Delete" + assert detailedlist._secondary_action == "Action" + + assert_action_performed_with(detailedlist, "refresh enabled", enabled=False) + assert_action_performed_with(detailedlist, "primary action enabled", enabled=False) + assert_action_performed_with( + detailedlist, "secondary action enabled", enabled=False + ) + + +def test_create_with_values( + source, + on_select_handler, + on_refresh_handler, + on_primary_action_handler, + on_secondary_action_handler, +): + "A DetailedList can be created with initial values" + detailedlist = toga.DetailedList( + data=source, + accessors=("key", "value", "icon"), + missing_value="Boo!", + on_select=on_select_handler, + on_refresh=on_refresh_handler, + primary_action="Primary", + on_primary_action=on_primary_action_handler, + secondary_action="Secondary", + on_secondary_action=on_secondary_action_handler, + ) + assert detailedlist._impl.interface == detailedlist + assert_action_performed(detailedlist, "create DetailedList") + + assert len(detailedlist.data) == 3 + assert detailedlist.accessors == ("key", "value", "icon") + assert detailedlist.missing_value == "Boo!" + assert detailedlist.on_select._raw == on_select_handler + assert detailedlist.on_refresh._raw == on_refresh_handler + assert detailedlist.on_primary_action._raw == on_primary_action_handler + assert detailedlist.on_secondary_action._raw == on_secondary_action_handler + assert detailedlist._primary_action == "Primary" + assert detailedlist._secondary_action == "Secondary" + + assert_action_performed_with(detailedlist, "refresh enabled", enabled=True) + assert_action_performed_with(detailedlist, "primary action enabled", enabled=True) + assert_action_performed_with(detailedlist, "secondary action enabled", enabled=True) + + +def test_disable_no_op(detailedlist): + "DetailedList doesn't have a disabled state" + # Enabled by default + assert detailedlist.enabled + + # Try to disable the widget + detailedlist.enabled = False + + # Still enabled. + assert detailedlist.enabled + + +def test_focus_noop(detailedlist): + "Focus is a no-op." + + detailedlist.focus() + assert_action_not_performed(detailedlist, "focus") + + +@pytest.mark.parametrize( + "data, all_attributes, extra_attributes", + [ + # List of lists + ( + [ + ["Alice", 123, "icon1"], + ["Bob", 234, "icon2"], + ["Charlie", 345, "icon3"], + ], + True, + False, + ), + # List of tuples + ( + [ + ("Alice", 123, "icon1"), + ("Bob", 234, "icon2"), + ("Charlie", 345, "icon3"), + ], + True, + False, + ), + # List of dictionaries + ( + [ + {"key": "Alice", "value": 123, "icon": "icon1", "extra": "extra1"}, + {"key": "Bob", "value": 234, "icon": "icon2", "extra": "extra2"}, + {"key": "Charlie", "value": 345, "icon": "icon3", "extra": "extra3"}, + ], + True, + True, + ), + # List of bare data + ( + [ + "Alice", + 1234, + "Charlie", + ], + False, + False, + ), + ], +) +def test_set_data( + detailedlist, + on_select_handler, + data, + all_attributes, + extra_attributes, +): + "Data can be set from a variety of sources" + + # The selection hasn't changed yet. + on_select_handler.assert_not_called() + + # Change the data + detailedlist.data = data + + # This triggered the select handler + on_select_handler.assert_called_once_with(detailedlist) + + # A ListSource has been constructed + assert isinstance(detailedlist.data, ListSource) + assert len(detailedlist.data) == 3 + + # The accessors are mapped in order. + assert detailedlist.data[0].key == "Alice" + assert detailedlist.data[2].key == "Charlie" + + if all_attributes: + assert detailedlist.data[1].key == "Bob" + + assert detailedlist.data[0].value == 123 + assert detailedlist.data[1].value == 234 + assert detailedlist.data[2].value == 345 + + assert detailedlist.data[0].icon == "icon1" + assert detailedlist.data[1].icon == "icon2" + assert detailedlist.data[2].icon == "icon3" + else: + assert detailedlist.data[1].key == 1234 + + if extra_attributes: + assert detailedlist.data[0].extra == "extra1" + assert detailedlist.data[1].extra == "extra2" + assert detailedlist.data[2].extra == "extra3" + + +def test_selection(detailedlist, on_select_handler): + "The current selection can be retrieved" + # Selection is initially empty + assert detailedlist.selection is None + on_select_handler.assert_not_called() + + # Select an item + detailedlist._impl.simulate_selection(1) + + # Selection returns a single row + assert detailedlist.selection == detailedlist.data[1] + + # Selection handler was triggered + on_select_handler.assert_called_once_with(detailedlist) + + +def test_refresh(detailedlist, on_refresh_handler): + "Completion of a refresh event triggers the cleanup handler" + # Stimulate a refresh. + detailedlist._impl.stimulate_refresh() + + # refresh handler was invoked + on_refresh_handler.assert_called_once_with(detailedlist) + + # The post-refresh handler was invoked on the backend + assert_action_performed_with( + detailedlist, + "after on refresh", + widget=detailedlist, + result=None, + ) + + +def test_scroll_to_top(detailedlist): + "A DetailedList can be scrolled to the top" + detailedlist.scroll_to_top() + + assert_action_performed_with(detailedlist, "scroll to row", row=0) + + +@pytest.mark.parametrize( + "row, effective", + [ + # Positive index + (0, 0), + (2, 2), + # Greater index than available rows + (10, 3), + # Negative index + (-1, 2), + (-3, 0), + # Greater negative index than available rows + (-10, 0), + ], +) +def test_scroll_to_row(detailedlist, row, effective): + "A DetailedList can be scrolled to a specific row" + detailedlist.scroll_to_row(row) + + assert_action_performed_with(detailedlist, "scroll to row", row=effective) + + +def test_scroll_to_row_no_data(detailedlist): + "If there's no data, scrolling is a no-op" + detailedlist.data.clear() + + detailedlist.scroll_to_row(5) + + assert_action_not_performed(detailedlist, "scroll to row") + + +def test_scroll_to_bottom(detailedlist): + "A DetailedList can be scrolled to the top" + detailedlist.scroll_to_bottom() + + assert_action_performed_with(detailedlist, "scroll to row", row=2) + + +###################################################################### +# 2023-07: Backwards compatibility +###################################################################### +def test_deprecated_names(on_primary_action_handler): + "Deprecated names still work" + + # Can't specify both on_delete and on_primary_action + with pytest.raises( + ValueError, + match=r"Cannot specify both on_delete and on_primary_action", + ): + toga.DetailedList(on_delete=Mock(), on_primary_action=Mock()) + + # on_delete is redirected at construction + with pytest.warns( + DeprecationWarning, + match="DetailedList.on_delete has been renamed DetailedList.on_primary_action", + ): + select = toga.DetailedList(on_delete=on_primary_action_handler) + + # on_delete accessor is redirected to on_primary_action + with pytest.warns( + DeprecationWarning, + match="DetailedList.on_delete has been renamed DetailedList.on_primary_action", + ): + assert select.on_delete._raw == on_primary_action_handler + + assert select.on_primary_action._raw == on_primary_action_handler + + # on_delete mutator is redirected to on_primary_action + new_handler = Mock() + with pytest.warns( + DeprecationWarning, + match="DetailedList.on_delete has been renamed DetailedList.on_primary_action", + ): + select.on_delete = new_handler + + assert select.on_primary_action._raw == new_handler diff --git a/docs/reference/api/index.rst b/docs/reference/api/index.rst index 92615ab524..dbbf4281aa 100644 --- a/docs/reference/api/index.rst +++ b/docs/reference/api/index.rst @@ -26,7 +26,8 @@ General widgets :doc:`Button ` A button that can be pressed or clicked. :doc:`Canvas ` Area you can draw on :doc:`DateInput ` A widget to select a calendar date - :doc:`DetailedList ` A list of complex content + :doc:`DetailedList ` An ordered list of content where each item has an icon, a main heading, + and a line of supplementary text. :doc:`Divider ` A separator used to visually distinguish two sections of content in a layout. :doc:`ImageView ` Image Viewer diff --git a/docs/reference/api/widgets/detailedlist.rst b/docs/reference/api/widgets/detailedlist.rst index aae6be4c9f..9ba0e20be6 100644 --- a/docs/reference/api/widgets/detailedlist.rst +++ b/docs/reference/api/widgets/detailedlist.rst @@ -1,6 +1,13 @@ DetailedList ============ +An ordered list where each item has an icon, a title, and a line of text. Scroll bars +will be provided if necessary. + +.. figure:: /reference/images/DetailedList.png + :width: 300px + :align: center + .. rst-class:: widget-support .. csv-filter:: Availability (:ref:`Key `) :header-rows: 1 @@ -8,11 +15,103 @@ DetailedList :included_cols: 4,5,6,7,8,9,10 :exclude: {0: '(?!(DetailedList|Component))'} +Usage +----- +The simplest way to create a DetailedList is to pass a list of dictionaries, with +each dictionary containing three keys: ``icon``, ``title``, and ``subtitle``: -Usage +.. code-block:: python + + import toga + + table = toga.DetailedList( + data=[ + { + "icon": toga.Icon("icons/arthur"), + "title": "Arthur Dent", + "subtitle": "Where's the tea?" + }, + { + "icon": toga.Icon("icons/ford"), + "title": "Ford Prefect", + "subtitle": "Do you know where my towel is?" + }, + { + "icon": toga.Icon("icons/tricia"), + "title": "Tricia McMillan", + "subtitle": "What planet are you from?" + }, + ] + ) + +If you want to customize the keys used in the dictionary, you can do this by +providing an ``accessors`` argument to the DetailedList when it is constructed. +``accessors`` is a tuple containing the attributes that will be used to provide the +icon, title, and subtitle, respectively: + +.. code-block:: python + + import toga + + table = toga.DetailedList( + accessors=("picture", "name", "quote"), + data=[ + { + "picture": toga.Icon("icons/arthur"), + "name": "Arthur Dent", + "quote": "Where's the tea?" + }, + { + "picture": toga.Icon("icons/ford"), + "name": "Ford Prefect", + "quote": "Do you know where my towel is?" + }, + { + "picture": toga.Icon("icons/tricia"), + "name": "Tricia McMillan", + "quote": "What planet are you from?" + }, + ] + ) + +If the value provided by the title or subtitle accessor is ``None``, or the accessor +isn't defined, the ``missing_value`` will be displayed. Any other value will be +converted into a string. + +The icon accessor should return an :any:`Icon`. If it returns ``None``, or the +accessor isn't defined, then no icon will be displayed, but space for the icon will +remain in the layout. + +Items in a DetailedList can respond to a primary and secondary action. On platforms that +use swipe interactions, the primary action will be associated with "swipe left", and the +secondary action will be associated with "swipe right". Other platforms may +implement the primary and secondary actions using a different UI interaction (e.g., a +right-click context menu). The primary and secondary actions will only be enabled in +the DetailedList UI if a handler has been provided. + +By default, the primary and secondary action will be labeled as "Delete" and "Action", +respectively. These names can be overridden by providing a ``primary_action`` and +``secondary_action`` argument when constructing the DetailedList. Although the primary +action is labeled "Delete" by default, the DetailedList will not perform any data +deletion as part of the UI interaction. It is the responsibility of the application to +implement any data deletion behavior as part of the ``on_primary_action`` handler. + +The DetailedList as a whole can also respond to a refresh UI action. This is usually +implemented as a "pull down" action, such as you might see on a social media timeline. +This action will only be enabled in the UI if an ``on_refresh`` handler has been +provided. + +Notes ----- +* The iOS Human Interface Guidelines differentiate between "Normal" and "Destructive" + actions on a row. Toga will interpret any action with a name of "Delete" or "Remove" + as destructive, and will render the action appropriately. + +* The WinForms implementation currently uses a column layout similar to :any:`Table`, + and does not support the primary, secondary or refresh actions. + Reference --------- diff --git a/docs/reference/api/widgets/table.rst b/docs/reference/api/widgets/table.rst index 1202490060..a2b67c2615 100644 --- a/docs/reference/api/widgets/table.rst +++ b/docs/reference/api/widgets/table.rst @@ -98,7 +98,8 @@ Notes * macOS does not support changing the font used to render table content. -* Icons in cells are not currently supported on Android or Winforms. +* On Winforms, icons are only supported in the first column. On Android, icons are not + supported at all. * The Android implementation is `not scalable `_ beyond about 1,000 cells. diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index 959147839d..3329aeda64 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -6,7 +6,7 @@ ActivityIndicator,General Widget,:class:`~toga.ActivityIndicator`,A spinning act Button,General Widget,:class:`~toga.Button`,Basic clickable Button,|y|,|y|,|y|,|y|,|y|,|b|,|b| Canvas,General Widget,:class:`~toga.Canvas`,Area you can draw on,|b|,|b|,|b|,|b|,,, DateInput,General Widget,:class:`~toga.DateInput`,A widget to select a calendar date,,,|y|,,|y|,, -DetailedList,General Widget,:class:`~toga.DetailedList`,A list of complex content,|b|,|b|,,|b|,|b|,, +DetailedList,General Widget,:class:`~toga.DetailedList`,"An ordered list of content where each item has an icon, a main heading, and a line of supplementary text.",|y|,|y|,|b|,|y|,|y|,, Divider,General Widget,:class:`~toga.Divider`,A horizontal or vertical line,|y|,|y|,|y|,,,|b|, ImageView,General Widget,:class:`~toga.ImageView`,A widget that displays an image,|y|,|y|,|y|,|y|,|y|,, Label,General Widget,:class:`~toga.Label`,Text label,|y|,|y|,|y|,|y|,|y|,|b|,|b| diff --git a/docs/reference/images/DetailedList.png b/docs/reference/images/DetailedList.png new file mode 100644 index 0000000000..9daa34faca Binary files /dev/null and b/docs/reference/images/DetailedList.png differ diff --git a/dummy/src/toga_dummy/widgets/detailedlist.py b/dummy/src/toga_dummy/widgets/detailedlist.py index 6b79970bd0..09e84fdbb1 100644 --- a/dummy/src/toga_dummy/widgets/detailedlist.py +++ b/dummy/src/toga_dummy/widgets/detailedlist.py @@ -1,40 +1,49 @@ +from ..utils import not_required from .base import Widget +@not_required # Testbed coverage is complete for this widget. class DetailedList(Widget): def create(self): self._action("create DetailedList") def change_source(self, source): self._action("change source", source=source) + self.interface.on_select(None) def insert(self, index, item): - self._action("insert", index=index, item=item) + self._action("insert item", index=index, item=item) def change(self, item): - self._action("change", item=item) + self._action("change item", item=item) def remove(self, index, item): - self._action("remove", index=index, item=item) + self._action("remove item", index=index, item=item) def clear(self): self._action("clear") def get_selection(self): - self._action("get selection") - return None + return self._get_value("selection", None) - def set_on_refresh(self, handler): - self._set_value("on_refresh", handler) + def set_refresh_enabled(self, enabled): + self._action("refresh enabled", enabled=enabled) + + def set_primary_action_enabled(self, enabled): + self._action("primary action enabled", enabled=enabled) + + def set_secondary_action_enabled(self, enabled): + self._action("secondary action enabled", enabled=enabled) def after_on_refresh(self, widget, result): self._action("after on refresh", widget=widget, result=result) - def set_on_delete(self, handler): - self._set_value("on_delete", handler) + def scroll_to_row(self, row): + self._action("scroll to row", row=row) - def set_on_select(self, handler): - self._set_value("on_select", handler) + def simulate_selection(self, row): + self._set_value("selection", row) + self.interface.on_select(None) - def scroll_to_row(self, row): - self._set_value("scroll to", row) + def stimulate_refresh(self): + self.interface.on_refresh(None) diff --git a/examples/beeliza/beeliza/app.py b/examples/beeliza/beeliza/app.py index d3bf430ff4..ef9dc25254 100644 --- a/examples/beeliza/beeliza/app.py +++ b/examples/beeliza/beeliza/app.py @@ -13,13 +13,13 @@ async def handle_input(self, widget, **kwargs): # Display the input as a chat entry. input_text = self.text_input.value self.chat.data.append( - # User's avatar is from http://avatars.adorable.io - # using user@beeware.org - dict( - icon=toga.Icon("resources/user.png"), - title="You", - subtitle=input_text, - ) + { + # User's avatar is from http://avatars.adorable.io + # using user@beeware.org + "icon": toga.Icon("resources/user.png"), + "title": "You", + "subtitle": input_text, + } ) # Clear the current input, ready for more input. self.text_input.value = "" @@ -31,13 +31,14 @@ async def handle_input(self, widget, **kwargs): # ... and they respond response = self.partner.respond(input_text) + # Display the response self.chat.data.append( - dict( - icon=toga.Icon("resources/brutus.png"), - title="Brutus", - subtitle=response, - ) + { + "icon": toga.Icon("resources/brutus.png"), + "title": "Brutus", + "subtitle": response, + } ) # Scroll so the most recent entry is visible. @@ -61,17 +62,24 @@ def startup(self): ) # Buttons - self.text_input = toga.TextInput(style=Pack(flex=1, padding=5)) + self.text_input = toga.TextInput( + style=Pack(flex=1, padding=5), + on_confirm=self.handle_input, + ) send_button = toga.Button( - "Send", on_press=self.handle_input, style=Pack(padding=5) + "Send", + on_press=self.handle_input, + style=Pack(padding=5), ) input_box = toga.Box( - children=[self.text_input, send_button], style=Pack(direction=ROW) + children=[self.text_input, send_button], + style=Pack(direction=ROW), ) # Outermost box outer_box = toga.Box( - children=[self.chat, input_box], style=Pack(direction=COLUMN) + children=[self.chat, input_box], + style=Pack(direction=COLUMN), ) # Add the content on the main window @@ -80,6 +88,9 @@ def startup(self): # Show the main window self.main_window.show() + # Give the text input focus. + self.text_input.focus() + def main(): return BeelizaApp("Beeliza", "org.beeware.beeliza") diff --git a/examples/detailedlist/detailedlist/app.py b/examples/detailedlist/detailedlist/app.py index 83cbc6f28b..3e2a3bd8e4 100644 --- a/examples/detailedlist/detailedlist/app.py +++ b/examples/detailedlist/detailedlist/app.py @@ -9,13 +9,23 @@ class ExampleDetailedListApp(toga.App): # Detailed list callback functions - def on_select_handler(self, widget, row, **kwargs): + def on_select_handler(self, widget, **kwargs): + row = widget.selection self.label.text = ( - f"Bee is {row.title} in {row.subtitle}" + f"Bee is {getattr(row, 'title', '')} in {getattr(row, 'subtitle', '')}" if row is not None else "No row selected" ) + def on_refresh_switch(self, switch): + self.dl.on_refresh = self.on_refresh_handler if switch.value else None + + def on_delete_switch(self, switch): + self.dl.on_primary_action = self.on_delete_handler if switch.value else None + + def on_visit_switch(self, switch): + self.dl.on_secondary_action = self.on_visit_handler if switch.value else None + async def on_refresh_handler(self, widget, **kwargs): self.label.text = "Refreshing list..." # We are using a local data source, so there's literally no reason @@ -25,7 +35,10 @@ async def on_refresh_handler(self, widget, **kwargs): self.label.text = "List was refreshed." def on_delete_handler(self, widget, row, **kwargs): - self.label.text = f"Row {row.subtitle} is going to be deleted." + self.dl.data.remove(row) + + def on_visit_handler(self, widget, row, **kwargs): + self.label.text = "We're not a travel agent." # Button callback functions def insert_handler(self, widget, **kwargs): @@ -58,11 +71,43 @@ def startup(self): children=[self.btn_insert, self.btn_remove], style=Pack(direction=ROW) ) + # Switches to enable/disable actions + switch_style = Pack(padding=10) + self.switch_box = toga.Box( + style=Pack(direction=ROW), + children=[ + toga.Box(style=Pack(flex=1)), # Spacer + toga.Switch( + "Delete", + value=True, + on_change=self.on_delete_switch, + style=switch_style, + ), + toga.Switch( + "Visit", + value=True, + on_change=self.on_visit_switch, + style=switch_style, + ), + toga.Switch( + "Refresh", + value=True, + on_change=self.on_refresh_switch, + style=switch_style, + ), + toga.Box(style=Pack(flex=1)), # Spacer + ], + ) + # Label to show responses. self.label = toga.Label("Ready.") self.dl = toga.DetailedList( data=[ + {}, # Missing values + {"icon": None, "title": None, "subtitle": None}, # None values + ] + + [ { "icon": toga.Icon("resources/brutus.png"), "title": translation["string"], @@ -70,15 +115,18 @@ def startup(self): } for translation in bee_translations ], + missing_value="MISSING", on_select=self.on_select_handler, - on_delete=self.on_delete_handler, + on_primary_action=self.on_delete_handler, + secondary_action="Visit", + on_secondary_action=self.on_visit_handler, on_refresh=self.on_refresh_handler, style=Pack(flex=1), ) # Outermost box outer_box = toga.Box( - children=[self.btn_box, self.dl, self.label], + children=[self.btn_box, self.switch_box, self.dl, self.label], style=Pack( flex=1, direction=COLUMN, diff --git a/examples/table/table/app.py b/examples/table/table/app.py index fef0607d2c..9ef72df62e 100644 --- a/examples/table/table/app.py +++ b/examples/table/table/app.py @@ -4,28 +4,36 @@ from toga.constants import COLUMN, ROW from toga.style import Pack -# Include some non-string objects to make sure conversion works correctly. headings = ["Title", "Year", "Rating", "Genre"] -bee_movies = [ - ("The Secret Life of Bees", 2008, 7.3, "Drama"), - ("Bee Movie", 2007, 6.1, "Animation, Adventure"), - ("Bees", 1998, 6.3, "Horror"), - ("The Girl Who Swallowed Bees", 2007, 7.5), # Missing a genre - ("Birds Do It, Bees Do It", 1974, 7.3, "Documentary"), - ("Bees: A Life for the Queen", 1998, 8.0, "TV Movie"), - ("Bees in Paradise", 1944, 5.4, None), # None genre - ("Keeper of the Bees", 1947, 6.3, "Drama"), -] class ExampleTableApp(toga.App): lbl_fontsize = None + def load_data(self): + yak = toga.Icon.TOGA_ICON + red = toga.Icon("icons/red") + green = toga.Icon("icons/green") + + # Include some non-string objects to make sure conversion works correctly. + self.bee_movies = [ + ((yak, "The Secret Life of Bees"), 2008, (green, 7.3), "Drama"), + ((None, "Bee Movie"), 2007, (red, 6.1), "Animation, Adventure"), + ((None, "Bees"), 1998, (red, 6.3), "Horror"), + ((None, "Despicable Bee"), 2010, (green, 7.5)), # Missing genre + ((None, "Birds Do It, Bees Do It"), 1974, (green, 7.3), "Documentary"), + ((None, "Bees: A Life for the Queen"), 1998, (green, 8.0), "TV Movie"), + ((None, "Bees in Paradise"), 1944, (red, 5.4), None), # None genre + ((yak, "Keeper of the Bees"), 1947, (red, 6.3), "Drama"), + ] + # Table callback functions def on_select_handler1(self, widget, **kwargs): row = self.table1.selection self.label_table1.text = ( - f"You selected row: {row.title}" if row is not None else "No row selected" + f"You selected row: {row.title[1]}" + if row is not None + else "No row selected" ) def on_select_handler2(self, widget, **kwargs): @@ -50,7 +58,7 @@ def on_activate2(self, widget, row, **kwargs): # Button callback functions def insert_handler(self, widget, **kwargs): - self.table1.data.insert(0, random.choice(bee_movies)) + self.table1.data.insert(0, random.choice(self.bee_movies)) def delete_handler(self, widget, **kwargs): if self.table1.selection: @@ -64,7 +72,7 @@ def clear_handler(self, widget, **kwargs): self.table1.data.clear() def reset_handler(self, widget, **kwargs): - self.table1.data = bee_movies + self.table1.data = self.bee_movies def toggle_handler(self, widget, **kwargs): try: @@ -122,11 +130,12 @@ def startup(self): ) # Data to populate the table. + self.load_data() if toga.platform.current_platform == "android": # FIXME: beeware/toga#1392 - Android Table doesn't allow lots of content - table_data = bee_movies * 10 + table_data = self.bee_movies * 10 else: - table_data = bee_movies * 1000 + table_data = self.bee_movies * 1000 self.table1 = toga.Table( headings=headings, @@ -217,9 +226,10 @@ def build_activate_message(cls, row, table_index): adjective = random.choice( ["magnificent", "amazing", "awesome", "life-changing"] ) + genre = (getattr(row, "genre", "") or "no-genre").lower() return ( - f"You selected the {adjective} {getattr(row, 'genre', '').lower()} movie " - f"{row.title} ({row.year}) from Table {table_index}" + f"You selected the {adjective} {genre} movie " + f"{row.title[1]} ({row.year}) from Table {table_index}" ) diff --git a/examples/table/table/icons/green.png b/examples/table/table/icons/green.png new file mode 100644 index 0000000000..fa414187c6 Binary files /dev/null and b/examples/table/table/icons/green.png differ diff --git a/examples/table/table/icons/red.png b/examples/table/table/icons/red.png new file mode 100644 index 0000000000..dace499b72 Binary files /dev/null and b/examples/table/table/icons/red.png differ diff --git a/gtk/src/toga_gtk/widgets/detailedlist.py b/gtk/src/toga_gtk/widgets/detailedlist.py index 5608381680..f627e4d5f3 100644 --- a/gtk/src/toga_gtk/widgets/detailedlist.py +++ b/gtk/src/toga_gtk/widgets/detailedlist.py @@ -1,220 +1,326 @@ +import html + from travertino.size import at_least -from ..libs import Gdk, Gio, GLib, Gtk +from toga_gtk.libs import Gdk, Gio, Gtk, Pango + from .base import Widget -from .internal.buttons.refresh import RefreshButton -from .internal.buttons.scroll import ScrollButton -from .internal.rows.texticon import TextIconRow -# TODO: Verify if right clicking a row currently works with touch screens, if not, -# use Gtk.GestureLongPress -class DetailedList(Widget): - """Gtk DetailedList implementation. +class DetailedListRow(Gtk.ListBoxRow): + """A row in a DetailedList.""" - Gtk.ListBox inside a Gtk.ScrolledWindow. - """ + def __init__(self, dl, row): + super().__init__() + self.row = row + self.row._impl = self - def create(self): - # Not the same as selected row. _active_row is the one with its buttons exposed. - self._active_row = None + # The row is a built as a stack, so that the action buttons can be pushed onto + # the stack as required. + self.stack = Gtk.Stack() + self.stack.set_homogeneous(True) + self.add(self.stack) - self.gtk_on_select_signal_handler = None + self.content = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - self.list_box = Gtk.ListBox() + # Initial Icon is empty; it will be populated on the initial update + self.icon = None - self.list_box.set_selection_mode(Gtk.SelectionMode.SINGLE) + self.text = Gtk.Label(xalign=0) - self.store = Gio.ListStore() - # We need to provide a function that transforms whatever is in the store into - # a `Gtk.ListBoxRow`, but the items in the store already are `Gtk.ListBoxRow` thus - # the identity function. - self.list_box.bind_model(self.store, lambda a: a) + # The three line below are necessary for right to left text. + self.text.set_hexpand(True) + self.text.set_ellipsize(Pango.EllipsizeMode.END) + self.text.set_margin_end(12) - self.scrolled_window = Gtk.ScrolledWindow() + self.content.pack_end(self.text, True, True, 5) - self.scrolled_window.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - self.scrolled_window.set_min_content_width(self.interface._MIN_WIDTH) - self.scrolled_window.set_min_content_height(self.interface._MIN_HEIGHT) + # Update the content for the row. + self.update(dl, row) - self.scrolled_window.add(self.list_box) + self.stack.add_named(self.content, "content") - self.refresh_button = RefreshButton(self.scrolled_window.get_vadjustment()) + # Make sure the widgets have been made visible. + self.show_all() - self.scroll_button = ScrollButton(self.scrolled_window.get_vadjustment()) - self.scroll_button.set_scroll(lambda: self.scroll_to_row(-1)) + def update(self, dl, row): + """Update the contents of the rendered row, using data from `row`, and accessors from the detailedList""" - self.native = Gtk.Overlay() - self.native.add_overlay(self.scrolled_window) + # Set the title and subtitle as a block of HTML text. + try: + title = getattr(self.row, dl.accessors[0]) + if title is not None: + title = str(title) + else: + title = dl.missing_value + except AttributeError: + title = dl.missing_value + + try: + subtitle = getattr(self.row, dl.accessors[1]) + if subtitle is not None: + subtitle = str(subtitle) + else: + subtitle = dl.missing_value + except AttributeError: + subtitle = dl.missing_value + + markup = "".join( + [ + html.escape(title), + "\n", + "", + html.escape(subtitle), + "", + ] + ) + self.text.set_markup(markup) + + # Update the icon + if self.icon: + self.content.remove(self.icon) + + try: + pixbuf = getattr(self.row, dl.accessors[2])._impl.native_32 + except AttributeError: + pixbuf = None + + if pixbuf is not None: + self.icon = Gtk.Image.new_from_pixbuf(pixbuf) + self.content.pack_start(self.icon, False, False, 6) + else: + self.icon = None + + def show_actions(self, action_buttons): + self.stack.add_named(action_buttons, "actions") + self.stack.set_visible_child_name("actions") + + def hide_actions(self): + self.stack.set_visible_child_name("content") + self.stack.remove(self.stack.get_child_by_name("actions")) - self.refresh_button.overlay_over(self.native) - self.scroll_button.overlay_over(self.native) - self.gtk_on_select_signal_handler = self.list_box.connect( - "row-selected", self.gtk_on_row_selected +class DetailedList(Widget): + def create(self): + # Not the same as selected row. _active_row is the one with its buttons exposed. + self._active_row = None + + # Main functional widget is a ListBox. + self.native_detailedlist = Gtk.ListBox() + self.native_detailedlist.set_selection_mode(Gtk.SelectionMode.SINGLE) + self.native_detailedlist.connect("row-selected", self.gtk_on_row_selected) + + self.store = Gio.ListStore() + # We need to provide a function that transforms whatever is in the store into a + # `Gtk.ListBoxRow`, but the items in the store already are `Gtk.ListBoxRow`, so + # this is the identity function. + self.native_detailedlist.bind_model(self.store, lambda a: a) + + # Put the ListBox into a vertically scrolling window. + scrolled_window = Gtk.ScrolledWindow() + scrolled_window.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scrolled_window.set_min_content_width(self.interface._MIN_WIDTH) + scrolled_window.set_min_content_height(self.interface._MIN_HEIGHT) + scrolled_window.add(self.native_detailedlist) + + self.native_vadj = scrolled_window.get_vadjustment() + self.native_vadj.connect("value-changed", self.gtk_on_value_changed) + + # Define a revealer widget that can be used to show/hide with a crossfade. + self.native_revealer = Gtk.Revealer() + self.native_revealer.set_transition_type(Gtk.RevealerTransitionType.CROSSFADE) + self.native_revealer.set_valign(Gtk.Align.END) + self.native_revealer.set_halign(Gtk.Align.CENTER) + self.native_revealer.set_margin_bottom(12) + self.native_revealer.set_reveal_child(False) + + # Define a refresh button. + self.native_refresh_button = Gtk.Button.new_from_icon_name( + "view-refresh-symbolic", Gtk.IconSize.BUTTON ) + self.native_refresh_button.set_can_focus(False) + self.native_refresh_button.connect("clicked", self.gtk_on_refresh_clicked) + + style_context = self.native_refresh_button.get_style_context() + style_context.add_class("osd") + style_context.add_class("toga-detailed-list-floating-buttons") + style_context.remove_class("button") - self.right_click_gesture = Gtk.GestureMultiPress.new(self.list_box) - self.right_click_gesture.set_button(3) - self.right_click_gesture.set_propagation_phase(Gtk.PropagationPhase.BUBBLE) - self.right_click_gesture.connect("pressed", self.gtk_on_right_click) + # Add the refresh button to the revealer + self.native_revealer.add(self.native_refresh_button) + + # The actual native widget is an overlay, made up of the scrolled window, with + # the revealer over the top. + self.native = Gtk.Overlay() + self.native.add_overlay(scrolled_window) + self.native.add_overlay(self.native_revealer) + + # Set up a gesture to capture right clicks. + self.gesture = Gtk.GestureMultiPress.new(self.native_detailedlist) + self.gesture.set_button(3) + self.gesture.set_propagation_phase(Gtk.PropagationPhase.BUBBLE) + self.gesture.connect("pressed", self.gtk_on_right_click) + + # Set up a box that contains action buttons. This widget can be can be re-used + # for any row when it is activated. + self.native_action_buttons = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + action_buttons_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + + # TODO: Can we replace "magic words" like delete with an appropriate icon? + # self.native_primary_action_button = Gtk.Button.new_from_icon_name( + # "user-trash-symbolic", Gtk.IconSize.BUTTON + # ) + action_buttons_hbox.pack_start(Gtk.Box(), True, True, 0) + + self.native_primary_action_button = Gtk.Button.new_with_label( + self.interface._primary_action + ) + self.native_primary_action_button.connect( + "clicked", self.gtk_on_primary_clicked + ) + action_buttons_hbox.pack_start( + self.native_primary_action_button, False, False, 10 + ) + + # TODO: Can we replace "magic words" like delete with an appropriate icon? + # self.native_secondary_action_button = Gtk.Button.new_from_icon_name( + # "user-trash-symbolic", Gtk.IconSize.BUTTON + # ) + self.native_secondary_action_button = Gtk.Button.new_with_label( + self.interface._secondary_action + ) + self.native_secondary_action_button.connect( + "clicked", self.gtk_on_secondary_clicked + ) + action_buttons_hbox.pack_start( + self.native_secondary_action_button, False, False, 10 + ) + + action_buttons_hbox.pack_start(Gtk.Box(), True, True, 0) + + self.native_action_buttons.pack_start(action_buttons_hbox, True, False, 0) + self.native_action_buttons.show_all() def row_factory(self, item): - """ - Args: - item (:obj:`Row`) - Returns: - Returns a (:obj:`TextIconRow`) - """ - return TextIconRow(self.interface.factory, self, item) - - def destroy(self): - self.disconnect(self.gtk_on_select_signal_handler) - super().destroy() + return DetailedListRow(self.interface, item) def change_source(self, source): - """ - Args: - source (:obj:`ListSource`) - """ self.store.remove_all() for item in source: self.store.append(self.row_factory(item)) - # We can't know the dimensions of each row (and thus of the list) until gtk allocates - # space for it. Gtk does emit `size-allocate` after allocation, but I couldn't find any - # guarantees that the rows have their sizes allocated in the order they are inserted - # in the `ListStore` and in my opinion that's unlikely to be the case. - - # Therefore we would need to wait for `size-allocate` on all rows and either update - # the visibility of the buttons on all `size-allocates` or have a counter and only do - # it on the last `size-allocate`. Obviously none of those options are desirable. - - # Fortunately functions added with `idle_add` are run when gtk is idle and thus after - # any size allocation. This solves our problem and from the perspective of the user - # happens immediately. - - # Even though we are adding the callback to the global loop, it only runs once. - # This is what the lambda is for. If a callback returns `False` then it's not ran again. - # I used a lambda because returning `False` from `self._list_items_changed()` would mean - # returning `False` on success. - GLib.idle_add(lambda: not self._list_items_changed()) - def insert(self, index, item): - """ - Args: - index (int) - item (:obj:`Row`) - """ + self.hide_actions() item_impl = self.row_factory(item) self.store.insert(index, item_impl) - self.list_box.show_all() - self._list_items_changed() + self.native_detailedlist.show_all() + self.update_refresh_button() def change(self, item): - """ - Args: - item (:obj:`Row`) - """ - index = item._impl.get_index() - self.remove(item, index) - item_impl = self.row_factory(item) - self.store.insert(index, item_impl) + item._impl.update(self.interface, item) def remove(self, item, index): - """Removes a row from the store. Doesn't remove the row from the interface. - - Args: - item (:obj:`Row`) - index (int) - """ - if index is None: - index = item._impl.get_index() - - if self._active_row == item._impl: - self._active_row = None - + self.hide_actions() self.store.remove(index) - - if self.interface.on_delete is not None: - self.interface.on_delete(self.interface, item._impl.interface) - - item._impl.destroy() - self._list_items_changed() + self.update_refresh_button() def clear(self): + self.hide_actions() self.store.remove_all() - self._list_items_changed() + self.update_refresh_button() def get_selection(self): - item_impl = self.list_box.get_selected_row() + item_impl = self.native_detailedlist.get_selected_row() if item_impl is None: return None else: - return item_impl.interface + return item_impl.get_index() def scroll_to_row(self, row: int): - item = self.store[row] - item.scroll_to_center() + # Rows are equally spaced; so the top of row N of M is at N/M of the overall height. + # We set the position based on the top of the window, so aim to put the scroller + # half the widget height above the start of the selected row, clipping at 0 + self.native_vadj.set_value( + max( + row / len(self.store) * self.native_vadj.get_upper() + - self.native.get_allocation().height / 2, + 0, + ) + ) - def set_on_refresh(self, handler: callable): - if handler is not None: - self.refresh_button.set_on_refresh(self.gtk_on_refresh_clicked) + def set_refresh_enabled(self, enabled): + self.update_refresh_button() - def set_on_select(self, handler: callable): - pass + @property + def refresh_enabled(self): + return self.interface.on_refresh._raw is not None - def set_on_delete(self, handler: callable): - pass + def set_primary_action_enabled(self, enabled): + self.native_primary_action_button.set_visible(enabled) + + def set_secondary_action_enabled(self, enabled): + self.native_secondary_action_button.set_visible(enabled) + + @property + def primary_action_enabled(self): + return self.interface.on_primary_action._raw is not None + + @property + def secondary_action_enabled(self): + return self.interface.on_secondary_action._raw is not None + + @property + def actions_enabled(self): + return self.primary_action_enabled or self.secondary_action_enabled def after_on_refresh(self, widget, result): - # No special handling required pass - def gtk_on_refresh_clicked(self): - if self.interface.on_refresh is not None: - self.interface.on_refresh(self.interface) + def gtk_on_value_changed(self, adj): + # The vertical scroll value has changed. + # Update the refresh button; hide the buttons on the active row (if they're active) + self.update_refresh_button() + self.hide_actions() - def gtk_on_row_selected(self, w: Gtk.ListBox, item_impl: Gtk.ListBoxRow): - if self.interface.on_select is not None: - if item_impl is not None: - self.interface.on_select(self.interface, item_impl.interface) - else: - self.interface.on_select(self.interface, None) + def gtk_on_refresh_clicked(self, widget): + self.interface.on_refresh(self.interface) - if self._active_row is not None and self._active_row != item_impl: - self._active_row.hide_buttons() - self._active_row = None + def gtk_on_row_selected(self, w: Gtk.ListBox, item_impl: Gtk.ListBoxRow): + self.hide_actions() + self.interface.on_select(self.interface) def gtk_on_right_click(self, gesture, n_press, x, y): - item_impl = self.list_box.get_row_at_y(y) - - if item_impl is None: - return - rect = Gdk.Rectangle() - rect.x, rect.y = item_impl.translate_coordinates(self.list_box, x, y) + item_impl = self.native_detailedlist.get_row_at_y(y) + rect.x, rect.y = item_impl.translate_coordinates(self.native_detailedlist, x, y) + + self.hide_actions() - if self._active_row is not None and self._active_row != item_impl: - self._active_row.hide_buttons() + if self.actions_enabled: + self.native_detailedlist.select_row(item_impl) + self._active_row = item_impl + self._active_row.show_actions(self.native_action_buttons) - self._active_row = item_impl - item_impl.on_right_click(rect) + def hide_actions(self): + if self._active_row is not None: + self._active_row.hide_actions() + self._active_row = None - if self.interface.on_select is not None: - self.list_box.select_row(item_impl) + def gtk_on_primary_clicked(self, widget): + self.interface.on_primary_action(None, row=self._active_row.row) + self.hide_actions() - def _list_items_changed(self): - """Some components such as the refresh button and scroll button change their - appearance based on how many items there are on the list or the size of the - items. + def gtk_on_secondary_clicked(self, widget): + self.interface.on_secondary_action(None, row=self._active_row.row) + self.hide_actions() - If either of those things changes the buttons need to be notified to recalculate - their positions. - """ - self.refresh_button.list_changed() - self.scroll_button.list_changed() - return True + def update_refresh_button(self): + # If the scroll is currently at the top, and refresh is currently enabled, + # reveal the refresh widget. + show_refresh = self.refresh_enabled and ( + self.native_vadj.get_value() == self.native_vadj.get_lower() + ) + self.native_revealer.set_reveal_child(show_refresh) def rehint(self): self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) diff --git a/gtk/src/toga_gtk/widgets/internal/__init__.py b/gtk/src/toga_gtk/widgets/internal/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/gtk/src/toga_gtk/widgets/internal/buttons/__init__.py b/gtk/src/toga_gtk/widgets/internal/buttons/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/gtk/src/toga_gtk/widgets/internal/buttons/base.py b/gtk/src/toga_gtk/widgets/internal/buttons/base.py deleted file mode 100644 index cf9bce798c..0000000000 --- a/gtk/src/toga_gtk/widgets/internal/buttons/base.py +++ /dev/null @@ -1,56 +0,0 @@ -class ParentPosition: - def __init__(self, adj): - self.adj = adj - - def is_parent_scrollable(self): - page_size = self.adj.get_page_size() - upper = self.adj.get_upper() - lower = self.adj.get_lower() - - return upper - lower > page_size - - def is_parent_at_top(self): - value = self.adj.get_value() - lower = self.adj.get_lower() - is_at_top = value == lower - - return is_at_top - - def is_parent_at_bottom(self): - page_size = self.adj.get_page_size() - value = self.adj.get_value() - upper = self.adj.get_upper() - is_at_bottom = value + page_size == upper - - return is_at_bottom - - def is_parent_scrolled(self): - is_at_top = self._is_parent_at_top() - is_at_bottom = self._is_parent_at_bottom() - - return not is_at_top and not is_at_bottom - - def is_parent_large(self): - page_size = self.adj.get_page_size() - upper = self.adj.get_upper() - lower = self.adj.get_lower() - - return 2 * page_size < upper - lower - - def is_parent_distant_from_top(self): - value = self.adj.get_value() - page_size = self.adj.get_page_size() - lower = self.adj.get_lower() - - distance_from_top = value - lower - - return distance_from_top >= 0.5 * page_size - - def is_parent_distant_from_bottom(self): - value = self.adj.get_value() - page_size = self.adj.get_page_size() - upper = self.adj.get_upper() - - distance_from_bottom = upper - (value + page_size) - - return distance_from_bottom >= 0.5 * page_size diff --git a/gtk/src/toga_gtk/widgets/internal/buttons/refresh.py b/gtk/src/toga_gtk/widgets/internal/buttons/refresh.py deleted file mode 100644 index 00581abb5e..0000000000 --- a/gtk/src/toga_gtk/widgets/internal/buttons/refresh.py +++ /dev/null @@ -1,190 +0,0 @@ -from toga_gtk.libs import Gtk - -from .base import ParentPosition - - -class RefreshButtonWidget(Gtk.Revealer): - def __init__(self, position: Gtk.Align, margin: int, *args, **kwargs): - super().__init__(*args, **kwargs) - self.parent = None - - self.refresh_btn = Gtk.Button.new_from_icon_name( - "view-refresh-symbolic", Gtk.IconSize.BUTTON - ) - self.refresh_btn.set_can_focus(False) - refresh_btn_context = self.refresh_btn.get_style_context() - refresh_btn_context.add_class("osd") - refresh_btn_context.add_class("toga-detailed-list-floating-buttons") - refresh_btn_context.remove_class("button") - - self.close_btn = Gtk.Button.new_from_icon_name( - "close-symbolic", Gtk.IconSize.BUTTON - ) - self.close_btn.set_can_focus(False) - close_btn_context = self.close_btn.get_style_context() - close_btn_context.add_class("osd") - close_btn_context.add_class("toga-detailed-list-floating-buttons") - - self.hbox = Gtk.HBox() - self.hbox.add(self.refresh_btn) - self.hbox.add(self.close_btn) - - self.set_transition_type(Gtk.RevealerTransitionType.CROSSFADE) - self.set_valign(position) - self.set_halign(Gtk.Align.CENTER) - self.set_margin_top(margin) - self.set_margin_bottom(margin) - - self.set_reveal_child(False) - self.add(self.hbox) - - def set_on_refresh(self, gtk_on_refresh: callable): - self.refresh_btn.connect("clicked", gtk_on_refresh) - - def set_on_close(self, gtk_on_close: callable): - self.close_btn.connect("clicked", gtk_on_close) - - def show(self): - self.set_reveal_child(True) - - def hide(self): - self.refresh_btn.hide() - self.close_btn.hide() - self.set_reveal_child(False) - - def show_close(self): - return self.close_btn.show_now() - - def hide_close(self): - return self.close_btn.hide() - - def show_refresh(self): - return self.refresh_btn.show_now() - - def is_visible(self): - return self.get_reveal_child() - - -class RefreshButton(ParentPosition): - """Shows a refresh button at the top of a list when the user is at the bottom of the - list. Shows a refresh button at the bottom of a list when the user is at the top of - the list. When there is not enough content to scroll, show the button at the bottom - and have a side button to move it to the top. After moving the button to the top, - show a button to move it to the bottom. - - Example: - ------------- - | Refresh | X | - ------------- - """ - - def __init__(self, adj: Gtk.Adjustment, margin=12, *args, **kwargs): - super().__init__(adj, *args, **kwargs) - self.margin = margin - self._parent = None - self.on_refresh = None - - self.button_top = RefreshButtonWidget(Gtk.Align.START, self.margin) - self.button_top.set_on_refresh(self.gtk_on_refresh_clicked) - self.button_top.set_on_close(self.gtk_on_close_clicked) - - self.button_bottom = RefreshButtonWidget(Gtk.Align.END, self.margin) - self.button_bottom.set_on_refresh(self.gtk_on_refresh_clicked) - self.button_bottom.set_on_close(self.gtk_on_close_clicked) - - self.gtk_adj_handler = self.adj.connect( - "value-changed", self.gtk_on_value_changed - ) - - def overlay_over(self, parent): - self._parent = parent - self.list_changed() - parent.add_overlay(self.button_top) - parent.add_overlay(self.button_bottom) - - def destroy(self, *args, **kwargs): - self.adj.disconnect(self.gtk_adj_handler) - self.button_top.destroy() - self.button_bottom.destroy() - return super().destroy(*args, **kwargs) - - def set_on_refresh(self, on_refresh: callable): - self.on_refresh = on_refresh - - def gtk_on_value_changed(self, adj: Gtk.Adjustment): - self.list_changed() - - def gtk_on_refresh_clicked(self, w: Gtk.Button): - if self.on_refresh is not None: - self.on_refresh() - - def gtk_on_close_clicked(self, w: Gtk.Button): - is_top_visible = self.button_top.is_visible() - is_bottom_visible = self.button_bottom.is_visible() - - if not is_top_visible: - self._show_top_full() - - if not is_bottom_visible: - self._show_bottom_full() - - def _hide_all(self): - self.button_top.hide() - self.button_bottom.hide() - - def _show_top_full(self): - self._hide_all() - - self.button_top.show() - self.button_top.show_close() - self.button_top.show_refresh() - - def _show_top_refresh(self): - self._hide_all() - - self.button_top.show() - self.button_top.hide_close() - self.button_top.show_refresh() - - def _show_bottom_full(self): - self._hide_all() - - self.button_bottom.show() - self.button_bottom.show_close() - self.button_bottom.show_refresh() - - def _show_bottom_refresh(self): - self._hide_all() - - self.button_bottom.show() - self.button_bottom.show_refresh() - self.button_bottom.hide_close() - - def _show_both_full(self): - self._hide_all() - self._show_bottom_full() - - def list_changed(self): - if self.on_refresh is None: - self._hide_all() - return - - is_scrollable = self.is_parent_scrollable() - is_at_top = self.is_parent_at_top() - is_at_bottom = self.is_parent_at_bottom() - - if not is_scrollable: - self._show_both_full() - return - - if is_at_top: - self._show_bottom_refresh() - return - - if is_at_bottom: - self._show_top_refresh() - return - - if not is_at_top and not is_at_bottom: - self._hide_all() - return diff --git a/gtk/src/toga_gtk/widgets/internal/buttons/scroll.py b/gtk/src/toga_gtk/widgets/internal/buttons/scroll.py deleted file mode 100644 index c81e1863cf..0000000000 --- a/gtk/src/toga_gtk/widgets/internal/buttons/scroll.py +++ /dev/null @@ -1,82 +0,0 @@ -from toga_gtk.libs import Gtk - -from .base import ParentPosition - - -class ScrollButton(ParentPosition): - def __init__( - self, adj: Gtk.Adjustment, bottom_margin=12, right_margin=20, *args, **kwargs - ): - super().__init__(adj, *args, **kwargs) - self.bottom_margin = bottom_margin - self.right_margin = right_margin - - self._parent = None - self._is_attached_to_parent = False - self._do_scroll = None - - self.button = Gtk.Button.new_from_icon_name( - "go-bottom-symbolic", Gtk.IconSize.BUTTON - ) - - self.button.set_can_focus(False) - - button_context = self.button.get_style_context() - button_context.add_class("osd") - button_context.add_class("toga-detailed-list-floating-buttons") - - self.revealer = Gtk.Revealer() - - self.revealer.set_can_focus(False) - self.revealer.set_transition_type(Gtk.RevealerTransitionType.CROSSFADE) - self.revealer.set_valign(Gtk.Align.END) - self.revealer.set_halign(Gtk.Align.END) - self.revealer.set_margin_bottom(self.bottom_margin) - self.revealer.set_margin_end(self.right_margin) - - self.revealer.add(self.button) - self.revealer.set_reveal_child(False) - - self.button.connect("clicked", self.gtk_on_clicked) - - self.adj.connect("value-changed", self.gtk_on_value_changed) - - def overlay_over(self, parent): - self._parent = parent - parent.add_overlay(self.revealer) - self.list_changed() - - def set_scroll(self, do_scroll: callable): - self._do_scroll = do_scroll - - def show(self): - self.revealer.set_reveal_child(True) - - def hide(self): - self.revealer.set_reveal_child(False) - - def gtk_on_clicked(self, w: Gtk.Button): - if self._do_scroll is not None: - self._do_scroll() - - def gtk_on_value_changed(self, adj: Gtk.Alignment): - self.list_changed() - - def list_changed(self): - is_scrollable = self.is_parent_scrollable() - is_at_top = self.is_parent_at_top() - is_at_bottom = self.is_parent_at_bottom() - - is_distant_from_top = self.is_parent_distant_from_top() - is_distant_from_bottom = self.is_parent_distant_from_bottom() - - if ( - is_scrollable - and not is_at_top - and not is_at_bottom - and is_distant_from_top - and is_distant_from_bottom - ): - self.show() - else: - self.hide() diff --git a/gtk/src/toga_gtk/widgets/internal/rows/__init__.py b/gtk/src/toga_gtk/widgets/internal/rows/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/gtk/src/toga_gtk/widgets/internal/rows/base.py b/gtk/src/toga_gtk/widgets/internal/rows/base.py deleted file mode 100644 index 00b080f9ef..0000000000 --- a/gtk/src/toga_gtk/widgets/internal/rows/base.py +++ /dev/null @@ -1,63 +0,0 @@ -from toga_gtk.libs import Gtk - -from .scrollable import ScrollableRow - - -class BaseRow(ScrollableRow): - def __init__(self, interface, *args, **kwargs): - """ - Args: - interface (:obj:`Row`) - """ - super().__init__(*args, **kwargs) - # Keep a reference to the original core.toga.sources.list_source.Row - self.interface = interface - interface._impl = self - - -class HiddenButtonsRow(BaseRow): - """You can add a content box and a set of buttons to this row. - - You can toggle the content with `toggle_content()`. - """ - - def __init__(self, dl, *args, **kwargs): - super().__init__(*args, **kwargs) - self._dl = dl - - self._content_name = "content" - self._buttons_name = "buttons" - - self.stack = Gtk.Stack() - - self.content = Gtk.Box() - - self.buttons = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - self.buttons_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - self.buttons.pack_start(self.buttons_hbox, True, False, 0) - - self.stack.add_named(self.content, self._content_name) - self.stack.add_named(self.buttons, self._buttons_name) - - self.add(self.stack) - - def add_content(self, content: Gtk.Widget): - self.content.add(content) - - def add_button(self, button: Gtk.Button): - self.buttons_hbox.pack_start(button, True, False, 10) - - def show_buttons(self): - self.stack.set_visible_child_name(self._buttons_name) - - def hide_buttons(self): - self.stack.set_visible_child_name(self._content_name) - - def toggle_content(self): - visible_child = self.stack.get_visible_child_name() - - if visible_child == self._content_name: - self.show_buttons() - - if visible_child == self._buttons_name: - self.hide_buttons() diff --git a/gtk/src/toga_gtk/widgets/internal/rows/scrollable.py b/gtk/src/toga_gtk/widgets/internal/rows/scrollable.py deleted file mode 100644 index 199bd9a4d0..0000000000 --- a/gtk/src/toga_gtk/widgets/internal/rows/scrollable.py +++ /dev/null @@ -1,149 +0,0 @@ -from toga_gtk.libs import GLib, Gtk - - -class ScrollableRow(Gtk.ListBoxRow): - """You can use and inherit from this class as if it were Gtk.ListBoxRow, nothing - from the original implementation is changed. - - There are three new public methods: scroll_to_top(), scroll_to_center() and - scroll_to_bottom(). 'top', 'center' and 'bottom' are with respect to where in the - visible region the row will move to. - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - # We need to wait until this widget is allocated to scroll it in, - # for that we use signals and callbacks. The handler_is of the - # signal is used to disconnect, and we store it here. - self._gtk_scroll_handler_id_value = None - - # The animation function will use this variable to control whether the animation is - # progressing, whether the user manually scrolled the list during the animation and whether - # the list size changed. - # In any case the animation will be stopped. - self._animation_control = None - - def scroll_to_top(self): - self.scroll_to_position("TOP") - - def scroll_to_center(self): - self.scroll_to_position("CENTER") - - def scroll_to_bottom(self): - self.scroll_to_position("BOTTOM") - - def scroll_to_position(self, position): - """Scrolls the parent Gtk.ListBox until child is in the center of the view. - - `position` is one of "TOP", "CENTER" or "BOTTOM" - """ - if position not in ("TOP", "CENTER", "BOTTOM"): - return False - - # Test whether the widget has already been allocated. - list_box = self.get_parent() - _, y = self.translate_coordinates(list_box, 0, 0) - if y >= 0: - self.gtk_do_scroll_to_position(position) - else: - # Wait for 'size-allocate' because we will need the - # dimensions of the widget. At this point - # widget.size_request is already available but that's - # only the requested size, not the size it will get. - self._scroll_handler_id = self.connect( - "size-allocate", - # We don't need `wdiget` and `gpointer` but we do need to capture `position` - lambda widget, gpointer: self.gtk_do_scroll_to_position(position), - ) - - return True - - def gtk_do_scroll_to_position(self, position): - # Disconnect from the signal that called us - self._gtk_scroll_handler_id = None - - list_box = self.get_parent() - adj = list_box.get_adjustment() - - page_size = adj.get_page_size() - - # `height` and `y` are always valid because we are - # being called after `size-allocate` - row_height = self.get_allocation().height - # `y` is the position of the top of the row in the frame of - # reference of the parent Gtk.ListBox - _, y = self.translate_coordinates(list_box, 0, 0) - - # `offset` is the remaining space in the visible region - offset = page_size - row_height - - value_at_top = y - value_at_center = value_at_top - offset / 2 - value_at_bottom = value_at_top - offset - - # `value` is the position the parent Gtk.ListBox will put at the - # top of the visible region. - value = 0.0 - - if position == "TOP": - value = value_at_top - - if position == "CENTER": - value = value_at_center - - if position == "BOTTOM": - value = value_at_bottom - - if value > 0: - # We need to capture `value` - GLib.idle_add(lambda: self.gtk_animate_scroll_to_position(value)) - - def gtk_animate_scroll_to_position(self, final): - # If this function returns True it is executed again. - # If this function returns False, it is not executed anymore. - # Set self._animation_control to None after the animation is over. - list_box = self.get_parent() - adj = list_box.get_adjustment() - - list_height = self.get_allocation().height - current = adj.get_value() - step = 1 - tol = 1e-9 - - if self._animation_control is not None: - # Whether the animation is progressing as planned or the user scrolled the list. - position_change = abs(current - self._animation_control["last_position"]) - # Whether the list size changed. - size_change = list_height - self._animation_control["list_height"] - - if position_change == 0 or position_change > step + tol or size_change != 0: - self._animation_control = None - return False - - self._animation_control = {"last_position": current, "list_height": list_height} - - distance = final - current - - if abs(distance) < step: - adj.set_value(final) - self._animation_control = None - return False - - if distance > step: - adj.set_value(current + step) - return True - - if distance < -step: - adj.set_value(current - step) - return True - - @property - def _gtk_scroll_handler_id(self): - return self._gtk_scroll_handler_id_value - - @_gtk_scroll_handler_id.setter - def _gtk_scroll_handler_id(self, value): - if self._gtk_scroll_handler_id_value is not None: - self.disconnect(self._gtk_scroll_handler_id_value) - - self._gtk_scroll_handler_id_value = value diff --git a/gtk/src/toga_gtk/widgets/internal/rows/texticon.py b/gtk/src/toga_gtk/widgets/internal/rows/texticon.py deleted file mode 100644 index e0db3a9a65..0000000000 --- a/gtk/src/toga_gtk/widgets/internal/rows/texticon.py +++ /dev/null @@ -1,100 +0,0 @@ -import html -import warnings - -from toga_gtk.libs import Gtk, Pango - -from .base import HiddenButtonsRow - - -class TextIconRow(HiddenButtonsRow): - """Create a TextIconRow from a toga.sources.Row. - - A reference to the original row is kept in self.toga_row, this is useful for - comparisons. - """ - - def __init__(self, factory: callable, *args, **kwargs): - super().__init__(*args, **kwargs) - - # This is the factory of the DetailedList implementation. - self.factory = factory - - self.icon = self.get_icon(self.interface, self.factory) - - text = Gtk.Label(xalign=0) - - # The three line below are necessary for right to left text. - text.set_hexpand(True) - text.set_ellipsize(Pango.EllipsizeMode.END) - text.set_margin_end(12) - - text_markup = self.markup(self.interface) - text.set_markup(text_markup) - - content = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - - vbox.pack_start(text, True, True, 0) - - if self.icon is not None: - content.pack_start(self.icon, False, False, 6) - - content.pack_start(vbox, True, True, 5) - - self.add_content(content) - - self._delete_button = Gtk.Button.new_from_icon_name( - "user-trash-symbolic", Gtk.IconSize.BUTTON - ) - self._delete_button.connect("clicked", self.gtk_on_delete_clicked) - self.add_button(self._delete_button) - - @property - def title(self): - return self.interface.title - - @property - def subtitle(self): - return self.interface.subtitle - - def get_icon( - self, - row, - factory=None, # DEPRECATED! - ): - ###################################################################### - # 2022-09: Backwards compatibility - ###################################################################### - # factory no longer used - if factory: - warnings.warn("The factory argument is no longer used.", DeprecationWarning) - ###################################################################### - # End backwards compatibility. - ###################################################################### - - if getattr(row, "icon") is None: - return None - else: - dpr = self.get_scale_factor() - return Gtk.Image.new_from_pixbuf( - getattr(row.icon._impl, "native_" + str(32 * dpr)) - ) - - @staticmethod - def markup(row): - markup = [ - html.escape(row.title or ""), - "\n", - "", - html.escape(row.subtitle or ""), - "", - ] - return "".join(markup) - - def on_right_click(self, rect): - handler = self._dl.interface.on_delete - if handler is not None: - self.toggle_content() - - def gtk_on_delete_clicked(self, w: Gtk.ListBoxRow): - self._dl.interface.data.remove(self.interface) diff --git a/gtk/src/toga_gtk/widgets/progressbar.py b/gtk/src/toga_gtk/widgets/progressbar.py index a8e358456e..f44ea3b026 100644 --- a/gtk/src/toga_gtk/widgets/progressbar.py +++ b/gtk/src/toga_gtk/widgets/progressbar.py @@ -21,9 +21,13 @@ async def pulse(progressbar): """A background task to animate running indeterminate progress bars.""" - while True: - progressbar.native.pulse() - await asyncio.sleep(0.1) + try: + while True: + progressbar.native.pulse() + await asyncio.sleep(0.1) + except asyncio.CancelledError: + # Being cancelled is expected behavior. + pass class ProgressBar(Widget): diff --git a/gtk/tests/widgets/test_detailedlist.py b/gtk/tests/widgets/test_detailedlist.py deleted file mode 100644 index ef332e10b6..0000000000 --- a/gtk/tests/widgets/test_detailedlist.py +++ /dev/null @@ -1,214 +0,0 @@ -import os -import unittest - -try: - import gi - - gi.require_version("Gtk", "3.0") - from gi.repository import Gtk -except ImportError: - import sys - - # If we're on Linux, Gtk *should* be available. If it isn't, make - # Gtk an object... but in such a way that every test will fail, - # because the object isn't actually the Gtk interface. - if sys.platform == "linux": - Gtk = object() - else: - Gtk = None - -import toga - - -def handle_events(): - while Gtk.events_pending(): - Gtk.main_iteration_do(blocking=False) - - -@unittest.skipIf( - Gtk is None, "Can't run GTK implementation tests on a non-Linux platform" -) -class TestGtkDetailedList(unittest.TestCase): - def setUp(self): - # An app needs to exist so that paths resolve. - _ = toga.App("Demo App", "org.beeware.demo") - icon = toga.Icon( - os.path.join( - os.path.dirname(__file__), - "../../../demo/toga_demo/resources/brutus-32.png", - ) - ) - self.dl = toga.DetailedList( - [ - dict( - icon=icon, - title="Item %i" % i, - subtitle="this is the subtitle for item %i" % i, - ) - for i in range(10) - ] - ) - - # make a shortcut for easy use - self.gtk_dl = self.dl._impl - - self.window = Gtk.Window() - self.window.add(self.dl._impl.native) - - def assertRowEqual(self, row, data): - for attr in ("icon", "title", "subtitle"): - self.assertEqual(getattr(row, attr), data[attr]) - - def test_change_source(self): - # Clear the table directly - self.gtk_dl.clear() - - # Assign pre-constructed data - a = dict(icon=None, title="A", subtitle="a subtitle") - b = dict(icon=None, title="B", subtitle="b subtitle") - - self.dl.data = [a, b] - - # Make sure the data was stored correctly - store = self.gtk_dl.store - self.assertRowEqual(store[0], a) - self.assertRowEqual(store[1], b) - - # Clear the table with empty assignment - self.dl.data = [] - - # Make sure the table is empty - self.assertEqual(len(store), 0) - - # Repeat with a few different cases - self.dl.data = None - self.assertEqual(len(store), 0) - - self.dl.data = () - self.assertEqual(len(store), 0) - - def test_insert(self): - # Insert a row - row_data = dict(icon=None, title="A", subtitle="a subtitle") - - INSERTED_AT = 0 - self.dl.data.insert(INSERTED_AT, row_data) - - # Make sure it's in there - self.assertEqual(len(self.gtk_dl.store), 1) - - # Make sure the row got stored correctly - result_row = self.gtk_dl.store[INSERTED_AT] - self.assertRowEqual(result_row, row_data) - - def test_remove(self): - # Insert a row - row_data = dict(icon=None, title="1", subtitle="2") - - INSERTED_AT = 0 - row = self.dl.data.insert(INSERTED_AT, row_data) - - # Make sure it's in there - self.assertEqual(len(self.gtk_dl.store), 1) - - # Then remove it - self.gtk_dl.remove(row, index=INSERTED_AT) - - # Make sure its gone - self.assertIsNone(self.gtk_dl.store.get_item(INSERTED_AT)) - - def test_change(self): - # Insert a row - row_data = dict(icon=None, title="1", subtitle="2") - - INSERTED_AT = 0 - row = self.dl.data.insert(INSERTED_AT, row_data) - - # Make sure it's in there - self.assertEqual(len(self.gtk_dl.store), 1) - - # Change a column - row.title = "something changed" - # (not testing that self.gtk_dl.change is called. The Core API - # unit tests should ensure this already.) - - # Make sure the value changed - result_row = self.gtk_dl.store[INSERTED_AT] - self.assertRowEqual( - result_row, dict(icon=None, title="something changed", subtitle="2") - ) - - # Make sure the row was replaced and not appended - self.assertEqual(len(self.gtk_dl.store), 1) - - def test_row_persistence(self): - self.dl.data.insert(0, dict(icon=None, title="A1", subtitle="A2")) - self.dl.data.insert(0, dict(icon=None, title="B1", subtitle="B2")) - - # B should now precede A - # tests passes if A "knows" it has moved to index 1 - - self.assertRowEqual( - self.gtk_dl.store[0], dict(icon=None, title="B1", subtitle="B2") - ) - self.assertRowEqual( - self.gtk_dl.store[1], dict(icon=None, title="A1", subtitle="A2") - ) - - def test_on_select_row(self): - # Insert two dummy rows - self.dl.data = [] - self.dl.data.append(dict(icon=None, title="A1", subtitle="A2")) - b = self.dl.data.append(dict(icon=None, title="B1", subtitle="B2")) - - # Create a flag - succeed = False - - def on_select(table, row, *kw): - # Make sure the right row was selected - self.assertEqual(row, b) - - nonlocal succeed - succeed = True - - self.dl.on_select = on_select - - # Select row B - self.gtk_dl.list_box.select_row(self.gtk_dl.list_box.get_row_at_index(1)) - - # Allow on_select to call - handle_events() - - self.assertTrue(succeed) - - def test_on_select_deleted_node(self): - # Insert two nodes - self.dl.data = [] - - self.dl.data.append(dict(icon=None, title="A1", subtitle="A2")) - b = self.dl.data.append(dict(icon=None, title="B1", subtitle="B2")) - - # Create a flag - succeed = False - - def on_select(table, row): - nonlocal succeed - if row is not None: - # Make sure the right row was selected - self.assertEqual(row, b) - - # Remove row B. This should trigger on_select again - table.data.remove(row) - else: - self.assertEqual(row, None) - succeed = True - - self.dl.on_select = on_select - - # Select row B - self.gtk_dl.list_box.select_row(b._impl) - - # Allow on_select to call - handle_events() - - self.assertTrue(succeed) diff --git a/gtk/tests_backend/widgets/detailedlist.py b/gtk/tests_backend/widgets/detailedlist.py new file mode 100644 index 0000000000..e5c505712c --- /dev/null +++ b/gtk/tests_backend/widgets/detailedlist.py @@ -0,0 +1,152 @@ +import asyncio +import html + +from toga_gtk.libs import GLib, Gtk + +from .base import SimpleProbe + + +class DetailedListProbe(SimpleProbe): + native_class = Gtk.Overlay + supports_actions = True + supports_refresh = True + + def __init__(self, widget): + super().__init__(widget) + self.native_detailedlist = widget._impl.native_detailedlist + self.native_vadj = widget._impl.native_vadj + assert isinstance(self.native_detailedlist, Gtk.ListBox) + + @property + def row_count(self): + return len(self.impl.store) + + def assert_cell_content(self, row, title, subtitle, icon=None): + row = self.impl.store[row] + + assert ( + str(row.text.get_label()) + == f"{html.escape(title)}\n{html.escape(subtitle)}" + ) + + if icon: + assert row.icon.get_pixbuf() == icon._impl.native_32 + else: + assert row.icon is None + + @property + def max_scroll_position(self): + return int(self.native_vadj.get_upper() - self.native_vadj.get_page_size()) + + @property + def scroll_position(self): + return int(self.native_vadj.get_value()) + + async def wait_for_scroll_completion(self): + # No animation associated with scroll, so this is a no-op + pass + + async def select_row(self, row, add=False): + self.native_detailedlist.select_row(self.impl.store[row]) + + def refresh_available(self): + return self.impl.native_revealer.get_child_revealed() + + async def non_refresh_action(self): + # Non-refresh is ... don't press the button, so it's a no op. + pass + + async def refresh_action(self, active=True): + if active: + assert self.refresh_available() + + # This is a Red code/Blue code thing. We're in an async context, but the + # method performing the click handler is non-async, and the event handler is + # async. We lose the async context on the way, so we can't invoke the event + # handler. To avoid this, defer the click event to the run loop, so the + # event handler isn't inside an async context. + def click_refresh(data): + self.impl.native_refresh_button.clicked() + + GLib.idle_add(click_refresh, None) + + # A short pause to allow the click handler to be processed. + await asyncio.sleep(0.1) + else: + assert not self.refresh_available() + + async def perform_primary_action(self, row, active=True): + item = self.impl.store[row] + row_height = self.native_vadj.get_upper() / len(self.impl.store) + + # item's widget stack is showing content + assert item.stack.get_visible_child_name() == "content" + self.impl.gesture.emit("pressed", 1, 100, row * row_height + row_height / 2) + + if active: + await self.redraw("Action bar is visible") + + # Confirm primary action is visible, and click it. + assert item.stack.get_visible_child_name() == "actions" + assert self.impl.native_primary_action_button.get_visible() + self.impl.native_primary_action_button.clicked() + + await self.redraw("Primary action button clicked") + else: + # Visibility of the action bar is dependent on the state + # of the other action. + if self.widget.on_secondary_action._raw is not None: + # Actions are visible, but the primary button isn't. + assert item.stack.get_visible_child_name() == "actions" + assert not self.impl.native_primary_action_button.get_visible() + await self.redraw( + "Action bar is visible, but primary action isn't available" + ) + + # Select the previous row to hide the action bar. + await self.select_row(row - 1) + + # Item's content stack has been fully restored + await self.redraw("Action bar is not visible") + + # Item's content stack has been fully restored + assert item.stack.get_visible_child_name() == "content" + + async def perform_secondary_action(self, row, active=True): + item = self.impl.store[row] + row_height = self.native_vadj.get_upper() / len(self.impl.store) + + # item's widget stack is showing content + assert item.stack.get_visible_child_name() == "content" + + # Right click on the row + self.impl.gesture.emit("pressed", 1, 100, row * row_height + row_height / 2) + + if active: + await self.redraw("Action bar is visible") + + # Confirm Secondary action is visible, and click it. + assert item.stack.get_visible_child_name() == "actions" + assert self.impl.native_secondary_action_button.get_visible() + self.impl.native_secondary_action_button.clicked() + + await self.redraw("Secondary action button clicked") + else: + # Visibility of the action bar is dependent on the state + # of the other action. + if self.widget.on_primary_action._raw is not None: + # Actions are visible, but the secondary button isn't. + assert item.stack.get_visible_child_name() == "actions" + assert not self.impl.native_secondary_action_button.get_visible() + await self.redraw( + "Action bar is visible, but secondary action isn't available" + ) + + # Select the previous row to hide the action bar. + await self.select_row(row - 1) + + # Item's content stack has been fully restored + await self.redraw("Action bar is not visible") + + # Item's content stack has been fully restored + assert item.stack.get_visible_child_name() == "content" diff --git a/gtk/tests_backend/widgets/progressbar.py b/gtk/tests_backend/widgets/progressbar.py index aa151b8a5d..beec388c5d 100644 --- a/gtk/tests_backend/widgets/progressbar.py +++ b/gtk/tests_backend/widgets/progressbar.py @@ -1,3 +1,5 @@ +import asyncio + from toga_gtk.libs import Gtk from .base import SimpleProbe @@ -17,3 +19,16 @@ def is_animating_indeterminate(self): @property def position(self): return self.native.get_fraction() + + async def wait_for_animation(self): + # This is a Red code/Blue code thing. As the test is running async, + # but we're invoking the "start" method synchronously, there's no + # guarantee that the async animation task will actually run. We + # explicitly wait_for the task to ensure it runs. + if self.impl._task: + try: + await asyncio.wait_for(self.impl._task, 0.2) + except asyncio.TimeoutError: + # Timeout is the expected outcome, as the task will run until it is + # cancelled. + pass diff --git a/gtk/tests_backend/widgets/table.py b/gtk/tests_backend/widgets/table.py index 8be5ff1ed2..fc0219363b 100644 --- a/gtk/tests_backend/widgets/table.py +++ b/gtk/tests_backend/widgets/table.py @@ -7,7 +7,7 @@ class TableProbe(SimpleProbe): native_class = Gtk.ScrolledWindow - supports_icons = True + supports_icons = 2 # All columns supports_keyboard_shortcuts = False supports_widgets = False diff --git a/iOS/src/toga_iOS/images.py b/iOS/src/toga_iOS/images.py index f24b742c1a..c3da2eba8a 100644 --- a/iOS/src/toga_iOS/images.py +++ b/iOS/src/toga_iOS/images.py @@ -21,6 +21,11 @@ def __init__(self, interface, path=None, data=None): ) if self.native is None: raise ValueError("Unable to load image from data") + self.native.retain() + + def __del__(self): + if self.native: + self.native.release() def get_width(self): return self.native.size.width diff --git a/iOS/src/toga_iOS/libs/uikit.py b/iOS/src/toga_iOS/libs/uikit.py index d069d3f436..1c001b84d0 100644 --- a/iOS/src/toga_iOS/libs/uikit.py +++ b/iOS/src/toga_iOS/libs/uikit.py @@ -210,6 +210,16 @@ class UIBarButtonSystemItem(Enum): UIColor.declare_class_property("whiteColor") UIColor.declare_class_property("yellowColor") +###################################################################### +# UIContextualAction.h +UIContextualAction = ObjCClass("UIContextualAction") + + +class UIContextualActionStyle(Enum): + Normal = 0 + Destructive = 1 + + ###################################################################### # UIControl.h @@ -334,6 +344,10 @@ class UIStackViewAlignment(Enum): LastBaseline = 5 +###################################################################### +# UISwipeActionsConfiguration.h +UISwipeActionsConfiguration = ObjCClass("UISwipeActionsConfiguration") + ###################################################################### # UISwitch.h UISwitch = ObjCClass("UISwitch") diff --git a/iOS/src/toga_iOS/widgets/detailedlist.py b/iOS/src/toga_iOS/widgets/detailedlist.py index 5706ef0016..26a55e71a3 100644 --- a/iOS/src/toga_iOS/widgets/detailedlist.py +++ b/iOS/src/toga_iOS/widgets/detailedlist.py @@ -1,18 +1,23 @@ -from rubicon.objc import SEL, objc_method, objc_property +from rubicon.objc import ( + SEL, + ObjCBlock, + ObjCInstance, + objc_method, + objc_property, +) from travertino.size import at_least from toga_iOS.libs import ( NSIndexPath, + UIContextualAction, + UIContextualActionStyle, UIControlEventValueChanged, UIRefreshControl, + UISwipeActionsConfiguration, UITableViewCell, - UITableViewCellEditingStyleDelete, - UITableViewCellEditingStyleInsert, - UITableViewCellEditingStyleNone, UITableViewCellSeparatorStyleNone, UITableViewCellStyleSubtitle, UITableViewController, - UITableViewRowAnimationLeft, UITableViewScrollPositionNone, ) from toga_iOS.widgets.base import Widget @@ -32,93 +37,156 @@ def tableView_numberOfRowsInSection_(self, tableView, section: int) -> int: @objc_method def tableView_cellForRowAtIndexPath_(self, tableView, indexPath): - cell = tableView.dequeueReusableCellWithIdentifier_("row") + cell = tableView.dequeueReusableCellWithIdentifier("row") if cell is None: - cell = UITableViewCell.alloc().initWithStyle_reuseIdentifier_( - UITableViewCellStyleSubtitle, "row" + cell = ( + UITableViewCell.alloc() + .initWithStyle(UITableViewCellStyleSubtitle, reuseIdentifier="row") + .autorelease() ) + value = self.interface.data[indexPath.item] - cell.textLabel.text = str(getattr(value, "title", "")) - cell.detailTextLabel.text = str(getattr(value, "subtitle", "")) + try: + label = getattr(value, self.interface.accessors[0]) + if label is None: + cell.textLabel.text = self.interface.missing_value + else: + cell.textLabel.text = str(label) + except AttributeError: + cell.textLabel.text = self.interface.missing_value + + try: + label = getattr(value, self.interface.accessors[1]) + if label is None: + cell.detailTextLabel.text = self.interface.missing_value + else: + cell.detailTextLabel.text = str(label) + except AttributeError: + cell.detailTextLabel.text = self.interface.missing_value - # If the value has an icon attribute, get the _impl. try: - cell.imageView.image = value.icon._impl.native + cell.imageView.image = getattr( + value, self.interface.accessors[2] + )._impl.native except AttributeError: - pass + cell.imageView.image = None return cell @objc_method - def tableView_commitEditingStyle_forRowAtIndexPath_( - self, tableView, editingStyle: int, indexPath + def tableView_didSelectRowAtIndexPath_(self, tableView, indexPath): + self.interface.on_select(None) + + # UITableViewDelegate methods + @objc_method + def tableView_trailingSwipeActionsConfigurationForRowAtIndexPath_( + self, tableView, indexPath ): - if editingStyle == UITableViewCellEditingStyleDelete: - item = self.interface.data[indexPath.row] - if editingStyle == UITableViewCellEditingStyleDelete: - if self.interface.on_delete: - self.interface.on_delete(self.interface, row=item) - - tableView.beginUpdates() - self.interface.data.remove(item) - tableView.deleteRowsAtIndexPaths_withRowAnimation_( - [indexPath], UITableViewRowAnimationLeft + if self.impl.primary_action_enabled: + actions = [ + UIContextualAction.contextualActionWithStyle( + UIContextualActionStyle.Destructive + if self.interface._primary_action in self.impl.DESTRUCTIVE_NAMES + else UIContextualActionStyle.Normal, + title=self.interface._primary_action, + handler=self.impl.primary_action_handler(indexPath.row), ) - tableView.endUpdates() - elif editingStyle == UITableViewCellEditingStyleInsert: - pass - elif editingStyle == UITableViewCellEditingStyleNone: - pass + ] + else: + actions = [] - @objc_method - def refresh(self): - self.interface.on_refresh(self.interface) + return UISwipeActionsConfiguration.configurationWithActions(actions) @objc_method - def tableView_willSelectRowAtIndexPath_(self, tableView, indexPath): - index = indexPath.row - if index == -1: - selection = None + def tableView_leadingSwipeActionsConfigurationForRowAtIndexPath_( + self, tableView, indexPath + ): + if self.impl.secondary_action_enabled: + actions = [ + UIContextualAction.contextualActionWithStyle( + UIContextualActionStyle.Destructive + if self.interface._secondary_action in self.impl.DESTRUCTIVE_NAMES + else UIContextualActionStyle.Normal, + title=self.interface._secondary_action, + handler=self.impl.secondary_action_handler(indexPath.row), + ) + ] else: - selection = self.interface.data[index] + actions = [] - if self.interface.on_select: - self.interface.on_select(self.interface, row=selection) + return UISwipeActionsConfiguration.configurationWithActions(actions) - # @objc_method - # def tableView_heightForRowAtIndexPath_(self, tableView, indexPath) -> float: - # return 48.0 + @objc_method + def refresh(self): + self.interface.on_refresh(None) class DetailedList(Widget): + DESTRUCTIVE_NAMES = {"Delete", "Remove"} + def create(self): - self.controller = TogaTableViewController.alloc().init() - self.controller.interface = self.interface - self.controller.impl = self - self.native = self.controller.tableView + self.native_controller = TogaTableViewController.alloc().init() + self.native_controller.interface = self.interface + self.native_controller.impl = self + self.native = self.native_controller.tableView self.native.separatorStyle = UITableViewCellSeparatorStyleNone + self.native.delegate = self.native_controller + + self.primary_action_enabled = False + self.secondary_action_enabled = False # Add the layout constraints self.add_constraints() - def set_on_refresh(self, handler: callable or None) -> None: - if callable(handler): - self.controller.refreshControl = UIRefreshControl.alloc().init() - self.controller.refreshControl.addTarget( - self.controller, - action=SEL("refresh"), - forControlEvents=UIControlEventValueChanged, - ) + def set_refresh_enabled(self, enabled): + if enabled: + if self.native_controller.refreshControl is None: + self.native_controller.refreshControl = UIRefreshControl.alloc().init() + self.native_controller.refreshControl.addTarget( + self.native_controller, + action=SEL("refresh"), + forControlEvents=UIControlEventValueChanged, + ) else: - if self.controller.refreshControl: - self.controller.refreshControl.removeFromSuperview() - self.controller.refreshControl = None + if self.native_controller.refreshControl: + self.native_controller.refreshControl.removeFromSuperview() + self.native_controller.refreshControl = None + + def set_primary_action_enabled(self, enabled): + self.primary_action_enabled = enabled + + def primary_action_handler(self, row): + def handle_primary_action( + action: ObjCInstance, + sourceView: ObjCInstance, + actionPerformed: ObjCInstance, + ) -> None: + item = self.interface.data[row] + self.interface.on_primary_action(self, row=item) + ObjCBlock(actionPerformed, None, bool)(True) + + return handle_primary_action + + def set_secondary_action_enabled(self, enabled): + self.secondary_action_enabled = enabled + + def secondary_action_handler(self, row): + def handle_secondary_action( + action: ObjCInstance, + sourceView: ObjCInstance, + actionPerformed: ObjCInstance, + ) -> None: + item = self.interface.data[row] + self.interface.on_secondary_action(self, row=item) + ObjCBlock(actionPerformed, None, bool)(True) + + return handle_secondary_action def after_on_refresh(self, widget, result): - self.controller.refreshControl.endRefreshing() - self.controller.tableView.reloadData() + self.native_controller.refreshControl.endRefreshing() + self.native_controller.tableView.reloadData() def change_source(self, source): self.native.reloadData() @@ -136,15 +204,11 @@ def clear(self): self.native.reloadData() def get_selection(self): - return None - - def set_on_select(self, handler): - # No special handling required - pass - - def set_on_delete(self, handler): - # No special handling required - pass + path = self.native.indexPathForSelectedRow + if path: + return path.item + else: + return None def scroll_to_row(self, row): self.native.scrollToRowAtIndexPath( diff --git a/iOS/src/toga_iOS/widgets/progressbar.py b/iOS/src/toga_iOS/widgets/progressbar.py index d00235ba96..3cbb80c03b 100644 --- a/iOS/src/toga_iOS/widgets/progressbar.py +++ b/iOS/src/toga_iOS/widgets/progressbar.py @@ -26,10 +26,14 @@ async def indeterminate_animator(progressbar): period. """ value = 0.95 - while True: - progressbar.native.setProgress(value, animated=True) - value = 1 - value - await asyncio.sleep(1.0) + try: + while True: + progressbar.native.setProgress(value, animated=True) + value = 1 - value + await asyncio.sleep(1.0) + except asyncio.CancelledError: + # Being cancelled is expected behavior. + pass class ProgressBar(Widget): diff --git a/iOS/tests_backend/widgets/detailedlist.py b/iOS/tests_backend/widgets/detailedlist.py new file mode 100644 index 0000000000..16d990a507 --- /dev/null +++ b/iOS/tests_backend/widgets/detailedlist.py @@ -0,0 +1,162 @@ +import asyncio + +from rubicon.objc.api import Block + +from toga_iOS.libs import ( + NSIndexPath, + NSPoint, + UIContextualActionStyle, + UITableView, + UITableViewController, +) + +from .base import SimpleProbe, UIControlEventValueChanged + + +class DetailedListProbe(SimpleProbe): + native_class = UITableView + supports_actions = True + supports_refresh = True + + def __init__(self, widget): + super().__init__(widget) + self.native_controller = widget._impl.native_controller + assert isinstance(self.native_controller, UITableViewController) + + @property + def row_count(self): + # Need to use the long form of this method because the first argument when used + # as a selector is ambiguous with a property of the same name on the object. + return int( + self.native.delegate.tableView_numberOfRowsInSection_(self.native, 0) + ) + + def assert_cell_content(self, row, title, subtitle, icon=None): + # Need to use the long form of this method because the first argument when used + # as a selector is ambiguous with a property of the same name on the object. + cell = self.native.delegate.tableView_cellForRowAtIndexPath_( + self.native, + NSIndexPath.indexPathForRow(row, inSection=0), + ) + assert str(cell.textLabel.text) == title + assert str(cell.detailTextLabel.text) == subtitle + + if icon: + assert cell.imageView.image == icon._impl.native + else: + assert cell.imageView.image is None + + @property + def max_scroll_position(self): + return max( + 0, int(self.native.contentSize.height - self.native.frame.size.height) + ) + + @property + def scroll_position(self): + return int(self.native.contentOffset.y) + + async def wait_for_scroll_completion(self): + position = self.scroll_position + current = None + # Iterate until 2 successive reads of the scroll position, + # 0.05s apart, return the same value + while position != current: + position = current + await asyncio.sleep(0.05) + current = self.scroll_position + + async def select_row(self, row, add=False): + path = NSIndexPath.indexPathForRow(row, inSection=0) + self.native.selectRowAtIndexPath(path, animated=False, scrollPosition=0) + # Need to use the long form of this method because the first argument when used + # as a selector is ambiguous with a property of the same name on the object. + self.native.delegate.tableView_didSelectRowAtIndexPath_(self.native, path) + + def refresh_available(self): + return self.scroll_position <= 0 + + async def non_refresh_action(self): + # iOS completely handles refresh actions, so there's no testing path + pass + + async def refresh_action(self, active=True): + if active: + assert self.native_controller.refreshControl is not None + self.native_controller.refreshControl.beginRefreshing() + self.native_controller.refreshControl.sendActionsForControlEvents( + UIControlEventValueChanged + ) + self.native.setContentOffset( + NSPoint(0, -self.native_controller.refreshControl.frame.size.height) + ) + + # Wait for the scroll to relax after reload completion + while self.scroll_position < 0: + await asyncio.sleep(0.01) + else: + assert self.native_controller.refreshControl is None + + def _perform_action(self, action, row, label, handler_factory): + # This is a little convoluted, and not an ideal test, but :shrug:. The + # Primary/Secondary actions are swipe actions with confirmation buttons, but iOS + # doesn't expose any way to programmatically generate a swipe, or to + # programmatically reveal and press the confirmation button. However, we *can* + # generate the action object that a swipe would create, and invoke the handler + # associated with that action. + assert str(action.title) == label + assert action.style == UIContextualActionStyle.Normal.value + + action_done = False + + @Block + def on_action_performed(done: bool) -> None: + nonlocal action_done + action_done = True + + # Ideally, we'd use `action.handler` to get the handler associated with the + # action, but https://github.com/beeware/rubicon-objc/issues/225 prevents the + # retrieval of blocks by property on ARM64 hardware. So - we use the same + # factory method to generate a fresh copy of the handler, and invoke the copy. + handler_factory(row)(action, self.native, on_action_performed) + + # Confirm the completion handler was invoked. + assert action_done + + async def perform_primary_action(self, row, active=True): + path = NSIndexPath.indexPathForRow(row, inSection=0) + # Need to use the long form of this method because the first argument when used + # as a selector is ambiguous with a property of the same name on the object. + config = self.native.delegate.tableView_trailingSwipeActionsConfigurationForRowAtIndexPath_( + self.native, path + ) + + if active: + assert len(config.actions) == 1 + self._perform_action( + config.actions[0], + row=row, + label=self.widget._primary_action, + handler_factory=self.impl.primary_action_handler, + ) + else: + assert len(config.actions) == 0 + + async def perform_secondary_action(self, row, active=True): + path = NSIndexPath.indexPathForRow(row, inSection=0) + # Need to use the long form of this method because the first argument when used + # as a selector is ambiguous with a property of the same name on the object. + config = self.native.delegate.tableView_leadingSwipeActionsConfigurationForRowAtIndexPath_( + self.native, path + ) + + if active: + assert len(config.actions) == 1 + self._perform_action( + config.actions[0], + row=row, + label=self.widget._secondary_action, + handler_factory=self.impl.secondary_action_handler, + ) + else: + assert len(config.actions) == 0 diff --git a/iOS/tests_backend/widgets/progressbar.py b/iOS/tests_backend/widgets/progressbar.py index d9c528dc2a..860a36d46b 100644 --- a/iOS/tests_backend/widgets/progressbar.py +++ b/iOS/tests_backend/widgets/progressbar.py @@ -1,3 +1,5 @@ +import asyncio + from toga_iOS.libs import UIProgressView from .base import SimpleProbe @@ -17,3 +19,9 @@ def is_animating_indeterminate(self): @property def position(self): return self.native.progress + + async def wait_for_animation(self): + # We need to enforce a short sleep here because iOS implements it's own + # animation as a background task, and we need to give that animation time to + # run. + await asyncio.sleep(0.1) diff --git a/testbed/tests/testbed.py b/testbed/tests/testbed.py index fd8da6b187..bd44db7bd5 100644 --- a/testbed/tests/testbed.py +++ b/testbed/tests/testbed.py @@ -129,13 +129,18 @@ def get_terminal_size(*args, **kwargs): except ValueError: run_slow = False - # If there are no other specified arguments, default to running the whole suite. - # Only show coverage if we're running the full suite. + # If `--coverage` is in the arguments, display a coverage report + try: + args.remove("--coverage") + report_coverage = True + except ValueError: + report_coverage = False + + # If there are no other specified arguments, default to running the whole suite, + # and reporting coverage. if len(args) == 0: args = ["tests"] report_coverage = True - else: - report_coverage = False thread = Thread( target=partial( diff --git a/testbed/tests/widgets/test_detailedlist.py b/testbed/tests/widgets/test_detailedlist.py new file mode 100644 index 0000000000..23bb316a2d --- /dev/null +++ b/testbed/tests/widgets/test_detailedlist.py @@ -0,0 +1,316 @@ +from unittest.mock import Mock + +import pytest + +import toga +from toga.sources import ListSource +from toga.style.pack import Pack + +from .properties import ( # noqa: F401 + test_enable_noop, + test_flex_widget_size, + test_focus_noop, +) + + +@pytest.fixture +def on_select_handler(): + return Mock() + + +@pytest.fixture +def on_refresh_handler(): + return Mock() + + +@pytest.fixture +def on_primary_action_handler(): + return Mock() + + +@pytest.fixture +def on_secondary_action_handler(): + return Mock() + + +@pytest.fixture +def source(): + red = toga.Icon("resources/icons/red") + green = toga.Icon("resources/icons/green") + + return ListSource( + accessors=["a", "b", "c", "d"], + data=[ + { + "a": f"A{i}", + "b": f"B{i}", + "c": {0: None, 1: red, 2: green}[i % 3], + "d": f"C{i}", + } + for i in range(0, 100) + ], + ) + + +@pytest.fixture +async def widget( + source, + on_select_handler, + on_refresh_handler, + on_primary_action_handler, + on_secondary_action_handler, +): + return toga.DetailedList( + data=source, + accessors=("a", "b", "c"), + missing_value="MISSING!", + on_select=on_select_handler, + on_refresh=on_refresh_handler, + primary_action="Twist", + on_primary_action=on_primary_action_handler, + secondary_action="Shout", + on_secondary_action=on_secondary_action_handler, + style=Pack(flex=1), + ) + + +async def test_scroll(widget, probe): + """The detailedList can be scrolled""" + + # Due to the interaction of scrolling with the header row, the scroll might be <0. + assert probe.scroll_position <= 0 + # Refresh is available at the top of the page. + if probe.supports_refresh: + assert probe.refresh_available() + + # Scroll to the bottom of the detailedList + widget.scroll_to_bottom() + await probe.wait_for_scroll_completion() + await probe.redraw("DetailedList scrolled to bottom") + # Refresh is not available when we're not at the top of the page. + if probe.supports_refresh: + assert not probe.refresh_available() + + # max_scroll_position is not perfectly accurate on Winforms. + assert probe.scroll_position == pytest.approx(probe.max_scroll_position, abs=10) + + # Scroll to the middle of the detailedList + widget.scroll_to_row(50) + await probe.wait_for_scroll_completion() + await probe.redraw("DetailedList scrolled to mid row") + if probe.supports_refresh: + assert not probe.refresh_available() + + # Row 50 should be visible. It could be at the top of the screen, or the bottom of + # the screen; we don't really care which - as long as it's roughly in the middle of + # the scroll range, call it a win. + assert probe.scroll_position == pytest.approx( + probe.max_scroll_position / 2, abs=400 + ) + + # Scroll to the top of the detailedList + widget.scroll_to_top() + await probe.wait_for_scroll_completion() + await probe.redraw("DetailedList scrolled to bottom") + if probe.supports_refresh: + assert probe.refresh_available() + + # Due to the interaction of scrolling with the header row, the scroll might be <0. + assert probe.scroll_position <= 0 + + +async def test_select(widget, probe, source, on_select_handler): + """Rows can be selected""" + # Initial selection is empty + assert widget.selection is None + await probe.redraw("No row is selected") + on_select_handler.assert_not_called() + + # A single row can be selected + await probe.select_row(1) + await probe.redraw("Second row is selected") + assert widget.selection == source[1] + + # Winforms generates two events, first removing the old selection and then adding + # the new one. + on_select_handler.assert_called_with(widget) + on_select_handler.reset_mock() + + # Trying to multi-select only does a single select + await probe.select_row(2, add=True) + await probe.redraw("Third row is selected") + assert widget.selection == source[2] + on_select_handler.assert_called_with(widget) + on_select_handler.reset_mock() + + +class MyData: + def __init__(self, text): + self.text = text + + def __str__(self): + return f"" + + +async def test_row_changes(widget, probe): + """Meta test for adding and removing data to the detailedList""" + red = toga.Icon("resources/icons/red") + green = toga.Icon("resources/icons/green") + + # Change the data source for something smaller + widget.data = [ + { + "a": MyData(i), + "b": i, + "c": {0: None, 1: red}[i % 2], + } + for i in range(0, 5) + ] + await probe.redraw("Data source has been changed") + + assert probe.row_count == 5 + # All cell contents are strings + probe.assert_cell_content(4, "", "4", icon=None) + + # Append a row to the detailedList + widget.data.append({"a": "AX", "b": "BX", "c": green}) + await probe.redraw("Full row has been appended") + + assert probe.row_count == 6 + probe.assert_cell_content(4, "", "4", icon=None) + probe.assert_cell_content(5, "AX", "BX", icon=green) + + # Insert a row into the middle of the detailedList; + # All text values are None; icon doesn't exist + widget.data.insert(2, {"a": None, "b": None, "c": None}) + await probe.redraw("Empty row has been inserted") + + assert probe.row_count == 7 + # Missing value has been populated + probe.assert_cell_content(2, "MISSING!", "MISSING!", icon=None) + probe.assert_cell_content(5, "", "4", icon=None) + probe.assert_cell_content(6, "AX", "BX", icon=green) + + # Change content on the partial row + widget.data[2].a = "ANEW" + widget.data[2].b = MyData("NEW") + widget.data[2].c = red + await probe.redraw("Empty row has been updated") + + assert probe.row_count == 7 + probe.assert_cell_content(2, "ANEW", "", icon=red) + probe.assert_cell_content(5, "", "4", icon=None) + probe.assert_cell_content(6, "AX", "BX", icon=green) + + # Remove all text attributes from the new row + del widget.data[2].a + del widget.data[2].b + del widget.data[2].c + await probe.redraw("Empty row has had data attributes removed") + + assert probe.row_count == 7 + probe.assert_cell_content(2, "MISSING!", "MISSING!", icon=None) + probe.assert_cell_content(5, "", "4", icon=None) + probe.assert_cell_content(6, "AX", "BX", icon=green) + + # Delete a row + del widget.data[3] + await probe.redraw("Row has been removed") + assert probe.row_count == 6 + probe.assert_cell_content(2, "MISSING!", "MISSING!", icon=None) + probe.assert_cell_content(4, "", "4", icon=None) + probe.assert_cell_content(5, "AX", "BX", icon=green) + + # Clear the detailedList + widget.data.clear() + await probe.redraw("Data has been cleared") + assert probe.row_count == 0 + + +async def test_refresh(widget, probe): + "Refresh can be triggered" + if not probe.supports_refresh: + pytest.skip("This backend doesn't support the refresh action") + + # Set a refresh handler that simulates a reload altering data. + def add_row(event_widget, **kwargs): + assert event_widget == widget + assert kwargs == {} + widget.data.insert(0, {"a": "NEW A", "b": "NEW B"}) + + widget.on_refresh = add_row + + # A partial pull-to-refresh doesn't reload data. + await probe.non_refresh_action() + await probe.redraw("A non-refresh action has occurred") + assert len(widget.data) == 100 + + await probe.refresh_action() + # It can take a couple of cycles for the refresh handler to fully execute; + # impose a small delay to ensure it's been processed. + await probe.redraw("A refresh action has occurred") + # New data has been added + assert len(widget.data) == 101 + + # Disable refresh + widget.on_refresh = None + + # A non-refresh action still doesn't reload data. + await probe.non_refresh_action() + await probe.redraw("A non-refresh action has occurred") + assert len(widget.data) == 101 + + # Disable refresh a second time, ensuring it's a no-op + widget.on_refresh = None + + # A full refresh action doesn't reload data + await probe.refresh_action(active=False) + await probe.redraw("A refresh action was performed without triggering refresh") + assert len(widget.data) == 101 + + +async def test_actions( + widget, + probe, + on_primary_action_handler, + on_secondary_action_handler, +): + "Actions can be performed on detailed list items" + if not probe.supports_actions: + pytest.skip("This backend doesn't support primary or secondary actions") + + await probe.perform_primary_action(3) + await probe.redraw("A primary action was performed on row 3") + on_primary_action_handler.assert_called_once_with(widget, row=widget.data[3]) + on_primary_action_handler.reset_mock() + + await probe.perform_secondary_action(4) + await probe.redraw("A secondary action was performed on row 4") + on_secondary_action_handler.assert_called_once_with(widget, row=widget.data[4]) + on_secondary_action_handler.reset_mock() + + # Disable secondary action + widget.on_secondary_action = None + await probe.perform_secondary_action(5, active=False) + await probe.redraw("An attempt at a secondary action was made") + on_secondary_action_handler.assert_not_called() + + # Disable primary action + widget.on_primary_action = None + await probe.perform_primary_action(5, active=False) + await probe.redraw("An attempt at a primary action was made") + on_primary_action_handler.assert_not_called() + + # Enable secondary action again + widget.on_secondary_action = on_secondary_action_handler + await probe.perform_secondary_action(5) + await probe.redraw("A secondary action was performed on row 5") + on_secondary_action_handler.assert_called_once_with(widget, row=widget.data[5]) + on_secondary_action_handler.reset_mock() + + # Enable primary action again + widget.on_primary_action = on_primary_action_handler + await probe.perform_primary_action(6) + await probe.redraw("A primary action was performed on row 6") + on_primary_action_handler.assert_called_once_with(widget, row=widget.data[6]) + on_primary_action_handler.reset_mock() diff --git a/testbed/tests/widgets/test_progressbar.py b/testbed/tests/widgets/test_progressbar.py index 28ce7897a7..a08e005b16 100644 --- a/testbed/tests/widgets/test_progressbar.py +++ b/testbed/tests/widgets/test_progressbar.py @@ -1,5 +1,3 @@ -import asyncio - import pytest import toga @@ -71,10 +69,8 @@ async def test_start_stop_indeterminate(widget, probe): # Start the progress bar widget.start() - # We need to actually sleep here, rather than just wait for a redraw, - # because some platforms implement their own animation, and we need - # to give that animation time to run. - await asyncio.sleep(0.1) + await probe.wait_for_animation() + await probe.redraw("Indeterminate progress bar is running") # Widget should now be started assert widget.is_running @@ -84,6 +80,7 @@ async def test_start_stop_indeterminate(widget, probe): # Try to change the progress bar value widget.value = 0.37 + await probe.wait_for_animation() await probe.redraw("Progress bar should be changed to 0.37") # Probe is still running; value doesn't change @@ -94,6 +91,7 @@ async def test_start_stop_indeterminate(widget, probe): # Start the progress bar again. This should be a no-op. widget.start() + await probe.wait_for_animation() await probe.redraw("Progress bar should be started again") # Probe is still running; value doesn't change @@ -104,6 +102,7 @@ async def test_start_stop_indeterminate(widget, probe): # Stop the progress bar widget.stop() + await probe.wait_for_animation() await probe.redraw("Progress bar should be stopped again") # Widget should now be stopped @@ -125,6 +124,7 @@ async def test_animation_starts_on_max_change(widget, probe): # Switch to indeterminate widget.max = None + await probe.wait_for_animation() await probe.redraw("Progress bar should be switched to indeterminate") # Widget is still running, animation has started @@ -133,6 +133,7 @@ async def test_animation_starts_on_max_change(widget, probe): # Switch back to determinate widget.max = 50 + await probe.wait_for_animation() await probe.redraw("Progress bar should be switched to determinate") # Widget is still running, animation has stopped diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index 7f797432fd..f6c0cb9a3a 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -127,7 +127,7 @@ async def test_scroll(widget, probe): assert probe.max_scroll_position > 600 # max_scroll_position is not perfectly accurate on Winforms. - assert probe.scroll_position == pytest.approx(probe.max_scroll_position, abs=5) + assert probe.scroll_position == pytest.approx(probe.max_scroll_position, abs=10) # Scroll to the middle of the table widget.scroll_to_row(50) @@ -428,18 +428,23 @@ def __str__(self): async def test_cell_icon(widget, probe): "An icon can be used as a cell value" - red = toga.Icon("resources/icons/red") if probe.supports_icons else None - green = toga.Icon("resources/icons/green") if probe.supports_icons else None + # 0 = no support + # 1 = first column only + # 2 = all columns + support = probe.supports_icons + + red = toga.Icon("resources/icons/red") + green = toga.Icon("resources/icons/green") widget.data = [ { - # Normal text, - "a": f"A{i}", # A tuple - "b": { - 0: (None, "B0"), # String + "a": { + 0: (None, "A0"), # String 1: (red, None), # None 2: (green, 2), # Integer }[i % 3], + # Normal text, + "b": f"B{i}", # An object with an icon attribute. "c": MyIconData(f"C{i}", {0: red, 1: green, 2: None}[i % 3]), } @@ -447,16 +452,20 @@ async def test_cell_icon(widget, probe): ] await probe.redraw("Table has data with icons") - probe.assert_cell_content(0, 0, "A0") - probe.assert_cell_content(0, 1, "B0", icon=None) - probe.assert_cell_content(0, 2, "", icon=red) + probe.assert_cell_content(0, 0, "A0", icon=None) + probe.assert_cell_content(0, 1, "B0") + probe.assert_cell_content( + 0, 2, "", icon=red if (support == 2) else None + ) - probe.assert_cell_content(1, 0, "A1") - probe.assert_cell_content(1, 1, "MISSING!", icon=red) - probe.assert_cell_content(1, 2, "", icon=green) + probe.assert_cell_content(1, 0, "MISSING!", icon=red if support else None) + probe.assert_cell_content(1, 1, "B1") + probe.assert_cell_content( + 1, 2, "", icon=green if (support == 2) else None + ) - probe.assert_cell_content(2, 0, "A2") - probe.assert_cell_content(2, 1, "2", icon=green) + probe.assert_cell_content(2, 0, "2", icon=green if support else None) + probe.assert_cell_content(2, 1, "B2") probe.assert_cell_content(2, 2, "", icon=None) diff --git a/winforms/src/toga_winforms/icons.py b/winforms/src/toga_winforms/icons.py index 717ad42aa4..283e60ff9d 100644 --- a/winforms/src/toga_winforms/icons.py +++ b/winforms/src/toga_winforms/icons.py @@ -13,9 +13,9 @@ def __init__(self, interface, path): try: if path.suffix == ".ico": self.native = WinIcon(str(path)) + self.bitmap = Bitmap.FromHicon(self.native.Handle) else: - icon_bitmap = Bitmap(str(path)) - icon_handle = icon_bitmap.GetHicon() - self.native = WinIcon.FromHandle(icon_handle) + self.bitmap = Bitmap(str(path)) + self.native = WinIcon.FromHandle(self.bitmap.GetHicon()) except ArgumentException: raise ValueError(f"Unable to load icon from {path}") diff --git a/winforms/src/toga_winforms/widgets/detailedlist.py b/winforms/src/toga_winforms/widgets/detailedlist.py index 8d3d67d8f9..58b96f2517 100644 --- a/winforms/src/toga_winforms/widgets/detailedlist.py +++ b/winforms/src/toga_winforms/widgets/detailedlist.py @@ -1,135 +1,56 @@ -import System.Windows.Forms as WinForms -from travertino.size import at_least +from toga.sources import Row -from .base import Widget +from .table import Table -class DetailedList(Widget): +# Wrap a DetailedList source to make it compatible with a Table. +class TableSource: + def __init__(self, interface): + self.interface = interface + + def __len__(self): + return len(self.interface.data) + + def __getitem__(self, index): + row = self.interface.data[index] + title, subtitle, icon = ( + getattr(row, attr, None) for attr in self.interface.accessors + ) + return Row(title=(icon, title), subtitle=subtitle) + + +class DetailedList(Table): + # The following methods are overridden from Table. + @property + def _headings(self): + return None + + @property + def _accessors(self): + return ("title", "subtitle") + + @property + def _multiple_select(self): + return False + + @property + def _data(self): + return self._table_source + def create(self): - self.native = WinForms.ListView() - self.native.View = WinForms.View.Details - self.native.HeaderStyle = getattr(WinForms.ColumnHeaderStyle, "None") - self._list_index_to_image_index = {} - self._cache = [] - self._first_item = 0 - - self.native.Columns.Add(self._create_column("title")) - self.native.Columns.Add(self._create_column("subtitle")) - - self.native.FullRowSelect = True - self.native.DoubleBuffered = True - self.native.VirtualMode = True - - self.native.ItemSelectionChanged += self.winforms_item_selection_changed - self.native.RetrieveVirtualItem += self.winforms_retrieve_virtual_item - self.native.CacheVirtualItems += self.winforms_cache_virtual_items - - def winforms_retrieve_virtual_item(self, sender, e): - # Because ListView is in VirtualMode, it's necessary implement - # VirtualItemsSelectionRangeChanged event to create ListViewItem when it's needed - if ( - self._cache - and e.ItemIndex >= self._first_item - and e.ItemIndex < self._first_item + len(self._cache) - ): - e.Item = self._cache[e.ItemIndex - self._first_item] - else: - row = self.interface.data[e.ItemIndex] - e.Item = self.build_item(row=row, index=e.ItemIndex) - - def winforms_cache_virtual_items(self, sender, e): - if ( - self._cache - and e.StartIndex >= self._first_item - and e.EndIndex < self._first_item + len(self._cache) - ): - # If the newly requested cache is a subset of the old cache, - # no need to rebuild everything, so do nothing - return - - # Now we need to rebuild the cache. - self._first_item = e.StartIndex - new_length = e.EndIndex - e.StartIndex + 1 - self._cache = [] - - # Fill the cache with the appropriate ListViewItems. - for i in range(new_length): - index = i + self._first_item - row = self.interface.data[index] - self._cache.append(self.build_item(row=row, index=index)) - - def winforms_item_selection_changed(self, sender, e): - if self.interface.on_select: - self.interface.on_select( - self.interface, row=self.interface.data[e.ItemIndex] - ) - - def _create_column(self, accessor): - col = WinForms.ColumnHeader() - col.Name = accessor - col.Width = -2 - return col - - def change_source(self, source): - self.update_data() - - def update_data(self): - self.native.VirtualListSize = len(self.interface.data) - image_list = WinForms.ImageList() - self._list_index_to_image_index = {} - counter = 0 - for i, item in enumerate(self.interface.data): - if item.icon is not None: - image_list.Images.Add(item.icon._impl.native) - self._list_index_to_image_index[i] = counter - counter += 1 - self.native.SmallImageList = image_list - self._cache = [] - - def insert(self, index, item): - self.update_data() - - def change(self, item): - self.interface.factory.not_implemented("Table.change()") - - def remove(self, item, index): - self.update_data() - - def clear(self): - self.native.Items.Clear() - - def get_selection(self): - if not self.native.SelectedIndices.Count: - return None - return self.interface.data[self.native.SelectedIndices[0]] - - def set_on_delete(self, handler): - pass - - def set_on_select(self, handler): - pass - - def set_on_double_click(self, handler): - self.interface.factory.not_implemented("Table.set_on_double_click()") - - def set_on_refresh(self, handler): - pass - - def after_on_refresh(self, widget, result): - pass - - def scroll_to_row(self, row): - self.native.EnsureVisible(row) - - def rehint(self): - self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) - - def build_item(self, row, index): - item = WinForms.ListViewItem(row.title) - image_index = self._list_index_to_image_index.get(index) - if image_index is not None: - item.ImageIndex = image_index - if row.subtitle is not None: - item.SubItems.Add(row.subtitle) - return item + super().create() + self._table_source = TableSource(self.interface) + + # DetailedList doesn't have an on_activate handler. + self.native.MouseDoubleClick -= self.winforms_double_click + + def set_primary_action_enabled(self, enabled): + self.primary_action_enabled = enabled + + def set_secondary_action_enabled(self, enabled): + self.secondary_action_enabled = enabled + + def set_refresh_enabled(self, enabled): + self.refresh_enabled = enabled + + after_on_refresh = None diff --git a/winforms/src/toga_winforms/widgets/table.py b/winforms/src/toga_winforms/widgets/table.py index 8f56b41e71..9e0a0bfe5d 100644 --- a/winforms/src/toga_winforms/widgets/table.py +++ b/winforms/src/toga_winforms/widgets/table.py @@ -11,6 +11,23 @@ class Table(Widget): _background_supports_alpha = False + # The following methods are overridden in DetailedList. + @property + def _headings(self): + return self.interface.headings + + @property + def _accessors(self): + return self.interface.accessors + + @property + def _multiple_select(self): + return self.interface.multiple_select + + @property + def _data(self): + return self.interface.data + def create(self): self.native = WinForms.ListView() self.native.View = WinForms.View.Details @@ -18,7 +35,7 @@ def create(self): self._first_item = 0 self._pending_resize = True - headings = self.interface.headings + headings = self._headings self.native.HeaderStyle = ( getattr(WinForms.ColumnHeaderStyle, "None") if headings is None @@ -26,15 +43,16 @@ def create(self): ) dataColumn = [] - for i, accessor in enumerate(self.interface.accessors): + for i, accessor in enumerate(self._accessors): heading = None if headings is None else headings[i] dataColumn.append(self._create_column(heading, accessor)) self.native.FullRowSelect = True - self.native.MultiSelect = self.interface.multiple_select + self.native.MultiSelect = self._multiple_select self.native.DoubleBuffered = True self.native.VirtualMode = True self.native.Columns.AddRange(dataColumn) + self.native.SmallImageList = WinForms.ImageList() self.native.ItemSelectionChanged += self.winforms_item_selection_changed self.native.RetrieveVirtualItem += self.winforms_retrieve_virtual_item @@ -60,9 +78,7 @@ def winforms_retrieve_virtual_item(self, sender, e): ): e.Item = self._cache[e.ItemIndex - self._first_item] else: - e.Item = WinForms.ListViewItem( - self.row_data(self.interface.data[e.ItemIndex]) - ) + e.Item = self._new_item(e.ItemIndex) def winforms_cache_virtual_items(self, sender, e): if ( @@ -81,11 +97,7 @@ def winforms_cache_virtual_items(self, sender, e): # Fill the cache with the appropriate ListViewItems. for i in range(new_length): - self._cache.append( - WinForms.ListViewItem( - self.row_data(self.interface.data[i + self._first_item]) - ) - ) + self._cache.append(self._new_item(i + self._first_item)) def winforms_item_selection_changed(self, sender, e): self.interface.on_select(None) @@ -93,7 +105,12 @@ def winforms_item_selection_changed(self, sender, e): def winforms_double_click(self, sender, e): hit_test = self.native.HitTest(e.X, e.Y) item = hit_test.Item - self.interface.on_activate(None, row=self.interface.data[item.Index]) + if item is not None: + self.interface.on_activate(None, row=self._data[item.Index]) + else: # pragma: no cover + # Double clicking outside of an item apparently doesn't raise the event, but + # that isn't guaranteed by the documentation. + pass def _create_column(self, heading, accessor): col = WinForms.ColumnHeader() @@ -113,10 +130,24 @@ def _resize_columns(self): def change_source(self, source): self.update_data() - def row_data(self, item): - # TODO: ListView only has built-in support for one icon per row. One possible - # workaround is in https://stackoverflow.com/a/46128593. - def strip_icon(item, attr): + def _new_item(self, index): + item = self._data[index] + + def icon(attr): + val = getattr(item, attr, None) + icon = None + if isinstance(val, tuple): + if val[0] is not None: + icon = val[0] + else: + try: + icon = val.icon + except AttributeError: + pass + + return None if icon is None else icon._impl + + def text(attr): val = getattr(item, attr, None) if isinstance(val, toga.Widget): warn("This backend does not support the use of widgets in cells") @@ -127,10 +158,29 @@ def strip_icon(item, attr): val = self.interface.missing_value return str(val) - return [strip_icon(item, attr) for attr in self.interface._accessors] + lvi = WinForms.ListViewItem( + [text(attr) for attr in self._accessors], + ) + + # TODO: ListView only has built-in support for one icon per row. One possible + # workaround is in https://stackoverflow.com/a/46128593. + icon = icon(self._accessors[0]) + if icon is not None: + lvi.ImageIndex = self._image_index(icon) + + return lvi + + def _image_index(self, icon): + images = self.native.SmallImageList.Images + key = str(icon.path) + index = images.IndexOfKey(key) + if index == -1: + index = images.Count + images.Add(key, icon.bitmap) + return index def update_data(self): - self.native.VirtualListSize = len(self.interface.data) + self.native.VirtualListSize = len(self._data) self._cache = [] def insert(self, index, item): @@ -147,7 +197,7 @@ def clear(self): def get_selection(self): selected_indices = list(self.native.SelectedIndices) - if self.interface.multiple_select: + if self._multiple_select: return selected_indices elif len(selected_indices) == 0: return None diff --git a/winforms/tests_backend/widgets/detailedlist.py b/winforms/tests_backend/widgets/detailedlist.py new file mode 100644 index 0000000000..434e5f84d1 --- /dev/null +++ b/winforms/tests_backend/widgets/detailedlist.py @@ -0,0 +1,10 @@ +from .table import TableProbe + + +class DetailedListProbe(TableProbe): + supports_actions = False + supports_refresh = False + + def assert_cell_content(self, row, title, subtitle, icon=None): + super().assert_cell_content(row, 0, title, icon=icon) + super().assert_cell_content(row, 1, subtitle) diff --git a/winforms/tests_backend/widgets/progressbar.py b/winforms/tests_backend/widgets/progressbar.py index e6886b31cd..76b43be525 100644 --- a/winforms/tests_backend/widgets/progressbar.py +++ b/winforms/tests_backend/widgets/progressbar.py @@ -20,3 +20,7 @@ def is_animating_indeterminate(self): @property def position(self): return self.native.Value / self.native.Maximum + + async def wait_for_animation(self): + # WinForms ProgressBar has internal animation handling; no special handling required. + pass diff --git a/winforms/tests_backend/widgets/table.py b/winforms/tests_backend/widgets/table.py index d2ec7916a2..99619506a8 100644 --- a/winforms/tests_backend/widgets/table.py +++ b/winforms/tests_backend/widgets/table.py @@ -1,4 +1,5 @@ import pytest +from System.Drawing import Bitmap from System.Windows.Forms import ( ColumnHeaderStyle, ListView, @@ -12,7 +13,7 @@ class TableProbe(SimpleProbe): native_class = ListView background_supports_alpha = False - supports_icons = False + supports_icons = 1 # First column only supports_keyboard_shortcuts = False supports_widgets = False @@ -28,8 +29,26 @@ def assert_cell_content(self, row, col, value=None, icon=None, widget=None): if widget: pytest.skip("This backend doesn't support widgets in Tables") else: - assert self.native.Items[row].SubItems[col].Text == value - assert icon is None + lvi = self.native.Items[row] + assert lvi.SubItems[col].Text == value + if col == 0: + if icon is None: + assert lvi.ImageIndex == -1 + assert lvi.ImageKey == "" + else: + imagelist = self.native.SmallImageList + size = imagelist.ImageSize + assert size.Width == size.Height == 16 + + # The image is resized and copied, so we need to compare the actual + # pixels. + actual = imagelist.Images[lvi.ImageIndex] + expected = Bitmap(icon._impl.bitmap, size) + for x in range(size.Width): + for y in range(size.Height): + assert actual.GetPixel(x, y) == expected.GetPixel(x, y) + else: + assert icon is None @property def max_scroll_position(self):