diff --git a/changes/4275.feature.md b/changes/4275.feature.md new file mode 100644 index 0000000000..92de046e58 --- /dev/null +++ b/changes/4275.feature.md @@ -0,0 +1 @@ +On iOS 18-, system effects are applied to the title bar when a ScrollContainer is the content of the window and reaches the titlebar area; on iOS 26+, system effects are consistently applied to the title bar when a ScrollContainer simply reaches the top of the window container layout. diff --git a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py index 07cf2e14e0..39bc65a6e1 100644 --- a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py +++ b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py @@ -37,7 +37,7 @@ def refreshContent(self): # This cannot be covered in CI because the function used to emit # a scrolling event is unreliable. @objc_method - def wantsForwardedScrollEventsForAxis_(self, axis: int) -> None: # pragma: no cover + def wantsForwardedScrollEventsForAxis_(self, axis: int) -> bool: # pragma: no cover return True diff --git a/examples/scrollcontainer/pyproject.toml b/examples/scrollcontainer/pyproject.toml index bd453e3ece..674144472a 100644 --- a/examples/scrollcontainer/pyproject.toml +++ b/examples/scrollcontainer/pyproject.toml @@ -45,6 +45,7 @@ requires = [ "../../iOS", "std-nslog>=1.0.0", ] +info."UIDesignRequiresCompatibility" = true [tool.briefcase.app.scrollcontainer.android] requires = [ diff --git a/examples/scrollcontainer/scrollcontainer/app.py b/examples/scrollcontainer/scrollcontainer/app.py index 195592e46e..75db0e566b 100644 --- a/examples/scrollcontainer/scrollcontainer/app.py +++ b/examples/scrollcontainer/scrollcontainer/app.py @@ -19,8 +19,6 @@ class ScrollContainerApp(toga.App): TOGGLE_CHUNK = 10 def startup(self): - main_box = toga.Box(direction=COLUMN) - self.hswitch = toga.Switch( "Horizontal", value=False, @@ -46,10 +44,6 @@ def startup(self): value=False, on_change=lambda widget: self.update_content(), ) - main_box.add( - toga.Box(children=[self.hswitch, self.vswitch]), - toga.Box(children=[self.wide_switch, self.tall_switch, self.nested_switch]), - ) self.inner_box = toga.Box( direction=COLUMN, margin=10, background_color="yellow" @@ -59,16 +53,22 @@ def startup(self): vertical=self.vswitch.value, on_scroll=self.on_scroll, flex=1, - margin=10, background_color="pink", ) + self.button = toga.Button("Toggle Controls", on_press=self.on_control_press) self.update_content() self.scroller.content = self.inner_box - main_box.add(self.scroller) + + self.main_box = toga.Box(direction=COLUMN, margin_bottom=10) + self.main_box.add(self.scroller) + self.main_box.add( + toga.Box(children=[self.hswitch, self.vswitch]), + toga.Box(children=[self.wide_switch, self.tall_switch, self.nested_switch]), + ) self.main_window = toga.MainWindow(size=(400, 700)) - self.main_window.content = main_box + self.main_window.content = self.main_box self.main_window.show() self.commands.add( @@ -102,6 +102,20 @@ def startup(self): ), ) + def on_control_press(self, widget): + if self.main_window.content != self.scroller: + self.main_box.clear() + self.main_window.content = self.scroller + else: + self.main_window.content = self.main_box + self.main_box.add(self.scroller) + self.main_box.add( + toga.Box(children=[self.hswitch, self.vswitch]), + toga.Box( + children=[self.wide_switch, self.tall_switch, self.nested_switch] + ), + ) + def handle_hscrolling(self, widget): self.scroller.horizontal = self.hswitch.value @@ -110,6 +124,7 @@ def handle_vscrolling(self, widget): def update_content(self): self.inner_box.clear() + self.inner_box.add(self.button) width = 10 if self.wide_switch.value else 2 height = 30 if self.tall_switch.value else 2 @@ -117,7 +132,9 @@ def update_content(self): label_text = f"Label {x}" if self.nested_switch.value: self.inner_box.add( - toga.ScrollContainer(content=Item(width, label_text)), + toga.ScrollContainer( + content=Item(width, label_text), vertical=False + ), ) else: self.inner_box.add(Item(width, label_text)) diff --git a/iOS/src/toga_iOS/container.py b/iOS/src/toga_iOS/container.py index 585e970b65..593bb21424 100644 --- a/iOS/src/toga_iOS/container.py +++ b/iOS/src/toga_iOS/container.py @@ -112,6 +112,11 @@ def __init__( self.bottom_inset = 0 self.right_inset = 0 + self.top_unset = False + self.left_unset = False + self.bottom_unset = False + self.right_unset = False + def __del__(self): # Mark the contained native object as explicitly None so that the # constraints know the object has been deleted. diff --git a/iOS/src/toga_iOS/libs/__init__.py b/iOS/src/toga_iOS/libs/__init__.py index cd4018fa6f..f878b5dd0f 100644 --- a/iOS/src/toga_iOS/libs/__init__.py +++ b/iOS/src/toga_iOS/libs/__init__.py @@ -5,3 +5,4 @@ from .mapkit import * # NOQA from .uikit import * # NOQA from .webkit import * # NOQA +from .liquidglass import * # noqa: F401, F403 diff --git a/iOS/src/toga_iOS/libs/core_foundation.py b/iOS/src/toga_iOS/libs/core_foundation.py new file mode 100644 index 0000000000..792369d838 --- /dev/null +++ b/iOS/src/toga_iOS/libs/core_foundation.py @@ -0,0 +1,28 @@ +from ctypes import c_bool, c_char_p, c_uint32, c_void_p, cdll, util + +cf = cdll.LoadLibrary(util.find_library("CoreFoundation")) + +kCFStringEncodingUTF8 = 134217984 + +CFBundleRef = c_void_p +CFURLRef = c_void_p +CFStringRef = c_void_p + + +cf.CFBundleGetMainBundle.restype = CFBundleRef +cf.CFBundleGetMainBundle.argtypes = [] + +cf.CFBundleCopyBundleURL.restype = CFURLRef +cf.CFBundleCopyBundleURL.argtypes = [CFBundleRef] + +cf.CFBundleCopyInfoDictionaryForURL.restype = c_void_p +cf.CFBundleCopyInfoDictionaryForURL.argtypes = [CFURLRef] + +cf.CFDictionaryGetValue.restype = c_void_p +cf.CFDictionaryGetValue.argtypes = [c_void_p, c_void_p] + +cf.CFStringCreateWithCString.restype = CFStringRef +cf.CFStringCreateWithCString.argtypes = [c_void_p, c_char_p, c_uint32] + +cf.CFBooleanGetValue.restype = c_bool +cf.CFBooleanGetValue.argtypes = [c_void_p] diff --git a/iOS/src/toga_iOS/libs/liquidglass.py b/iOS/src/toga_iOS/libs/liquidglass.py new file mode 100644 index 0000000000..2218f6a20a --- /dev/null +++ b/iOS/src/toga_iOS/libs/liquidglass.py @@ -0,0 +1,29 @@ +from platform import ios_ver + +from .core_foundation import cf, kCFStringEncodingUTF8 +from .sdk_version import get_sdk_version + +bundle = cf.CFBundleGetMainBundle() +url = cf.CFBundleCopyBundleURL(bundle) +info_dict = cf.CFBundleCopyInfoDictionaryForURL(url) +key_cf = cf.CFStringCreateWithCString( + None, b"UIDesignRequiresCompatibility", kCFStringEncodingUTF8 +) +value_cf = cf.CFDictionaryGetValue(info_dict, key_cf) +if value_cf is None: + value_py = None +else: + value_py = bool(cf.CFBooleanGetValue(value_cf)) +supports_liquid_glass = False +if value_py is True: + supports_liquid_glass = False +elif value_py is False: + supports_liquid_glass = True +else: + supports_liquid_glass = int(get_sdk_version().split(".")[0]) >= 26 + +supports_liquid_glass = ( + supports_liquid_glass and int(ios_ver().release.split(".")[0]) >= 26 +) + +__all__ = ["supports_liquid_glass"] diff --git a/iOS/src/toga_iOS/libs/sdk_version.py b/iOS/src/toga_iOS/libs/sdk_version.py new file mode 100644 index 0000000000..07ad66e018 --- /dev/null +++ b/iOS/src/toga_iOS/libs/sdk_version.py @@ -0,0 +1,87 @@ +import ctypes + +MH_MAGIC = 0xFEEDFACE +MH_MAGIC_64 = 0xFEEDFACF + +LC_BUILD_VERSION = 0x32 + + +class MachHeader32(ctypes.Structure): + _fields_ = [ + ("magic", ctypes.c_uint32), + ("cputype", ctypes.c_uint32), + ("cpusubtype", ctypes.c_uint32), + ("filetype", ctypes.c_uint32), + ("ncmds", ctypes.c_uint32), + ("sizeofcmds", ctypes.c_uint32), + ("flags", ctypes.c_uint32), + ] + + +class MachHeader64(ctypes.Structure): + _fields_ = [ + ("magic", ctypes.c_uint32), + ("cputype", ctypes.c_uint32), + ("cpusubtype", ctypes.c_uint32), + ("filetype", ctypes.c_uint32), + ("ncmds", ctypes.c_uint32), + ("sizeofcmds", ctypes.c_uint32), + ("flags", ctypes.c_uint32), + ("reserved", ctypes.c_uint32), + ] + + +class LoadCommand(ctypes.Structure): + _fields_ = [ + ("cmd", ctypes.c_uint32), + ("cmdsize", ctypes.c_uint32), + ] + + +class BuildVersionCommand(ctypes.Structure): + _fields_ = [ + ("cmd", ctypes.c_uint32), + ("cmdsize", ctypes.c_uint32), + ("platform", ctypes.c_uint32), + ("minos", ctypes.c_uint32), + ("sdk", ctypes.c_uint32), + ("ntools", ctypes.c_uint32), + ] + + +libc = ctypes.CDLL(None) +_dyld_get_image_header = libc._dyld_get_image_header +_dyld_get_image_header.restype = ctypes.c_void_p +_dyld_get_image_header.argtypes = [ctypes.c_uint32] + + +def decode_version(v): + return f"{v >> 16}.{(v >> 8) & 0xFF}.{v & 0xFF}" + + +def get_sdk_version(): + mh_ptr = _dyld_get_image_header(0) + magic = ctypes.c_uint32.from_address(mh_ptr).value + + if magic == MH_MAGIC_64: + header = MachHeader64.from_address(mh_ptr) + cmd_ptr = mh_ptr + ctypes.sizeof(MachHeader64) + elif magic == MH_MAGIC: + header = MachHeader32.from_address(mh_ptr) + cmd_ptr = mh_ptr + ctypes.sizeof(MachHeader32) + else: + return None + + for _ in range(header.ncmds): + lc = LoadCommand.from_address(cmd_ptr) + + if lc.cmd == LC_BUILD_VERSION: + cmd = BuildVersionCommand.from_address(cmd_ptr) + return decode_version(cmd.sdk) + + cmd_ptr += lc.cmdsize + + return None + + +__all__ = ["get_sdk_version"] diff --git a/iOS/src/toga_iOS/widgets/base.py b/iOS/src/toga_iOS/widgets/base.py index 4d5e7d1a17..03d27c6d9b 100644 --- a/iOS/src/toga_iOS/widgets/base.py +++ b/iOS/src/toga_iOS/widgets/base.py @@ -3,6 +3,8 @@ from toga_iOS.colors import native_color from toga_iOS.constraints import Constraints +from ..libs import supports_liquid_glass + class Widget: def __init__(self, interface): @@ -22,6 +24,14 @@ def __init__(self, interface): # #3104 for details. self._default_background_color = self.native.backgroundColor + @property + def scroll_vertical(self): + return False + + # @property + # def scroll_horizontal(self): + # return False + @abstractmethod def create(self): ... @@ -78,7 +88,28 @@ def set_tab_index(self, tab_index): # APPLICATOR def set_bounds(self, x, y, width, height): - # print("SET BOUNDS", self, x, y, width, height, self.container.top_offset) + # print("SET BOUNDS", self, x, y, width, height, self.container.top_inset) + if supports_liquid_glass or self.container.content == self: + if self.scroll_vertical and self.container.top_unset and y == 0: + y -= self.container.top_inset + height += self.container.top_inset + # Right now, the below lines are irrelevant and lead to coverage issues, + # as we only have a title bar to bleed over. When constructs such as + # if ( + # self.scroll_vertical + # and self.container.bottom_unset + # and y + height == self.container.height + # ): + # height += self.container.bottom_inset + # if self.scroll_horizontal and self.container.left_unset and x == 0: + # x -= self.container.left_inset + # width += self.container.left_inset + # if ( + # self.scroll_horizontal + # and self.container.right_unset + # and x + width == self.container.width + # ): + # width += self.container.right_inset self.constraints.update(x, y, width, height) def set_text_align(self, alignment): diff --git a/iOS/src/toga_iOS/widgets/scrollcontainer.py b/iOS/src/toga_iOS/widgets/scrollcontainer.py index ccdaf93f14..1929592cab 100644 --- a/iOS/src/toga_iOS/widgets/scrollcontainer.py +++ b/iOS/src/toga_iOS/widgets/scrollcontainer.py @@ -90,6 +90,7 @@ def get_vertical(self): def set_vertical(self, value): self._allow_vertical = value + self.native.alwaysBounceVertical = value # If the scroll container has content, we need to force a refresh # to let the scroll container know how large its content is. if self.interface.content: @@ -102,8 +103,17 @@ def set_vertical(self, value): def get_horizontal(self): return self._allow_horizontal + @property + def scroll_vertical(self): + return self._allow_vertical + + # @property + # def scroll_horizontal(self): + # return self._allow_horizontal + def set_horizontal(self, value): self._allow_horizontal = value + self.native.alwaysBounceHorizontal = value # If the scroll container has content, we need to force a refresh # to let the scroll container know how large its content is. if self.interface.content: diff --git a/iOS/src/toga_iOS/window.py b/iOS/src/toga_iOS/window.py index 612173c4a6..7a9a39c829 100644 --- a/iOS/src/toga_iOS/window.py +++ b/iOS/src/toga_iOS/window.py @@ -267,6 +267,8 @@ def create_container(self): on_refresh=self.content_refreshed, on_native_layout=self.content_native_layout, ) + # Widgets can extend into the top space with safe blurring. + self.container.top_unset = True def content_native_layout(self, container): # Instead of manually computing the geometry at the top,