beeware / toga

A Python native, OS native GUI toolkit.
https://toga.readthedocs.io/en/latest/
BSD 3-Clause "New" or "Revised" License
4.38k stars 684 forks source link

On IOS option window does not render correctly #2945

Open kloknibor opened 2 weeks ago

kloknibor commented 2 weeks ago

Describe the bug

I have an app that on ios first loads a toga box, does load data than when finished opens a optioncontainer in the main window by changing the main_window.content.

When the option container first renders it overlays the webview behind. When I switch between different options the rendering will be fine after. I have tried to make a highly simplified version, but here all works as expected.

There is only one strange thing in my app, which is that I render a webview outside of the visible window to retrieve the data required.

Steps to reproduce

  1. Load a toga.box with a webview
  2. load data, for this a webview is situated outside of the visible area of the screen
  3. when finished load optioncontainer.
  4. See screenshots on how it renders on ios.

Expected behavior

I expect the webview inside the option of the option container to stay within the option container area and not overlap the tabs.

Screenshots

image image

Environment

Logs

no logging outputted in xcode

Additional context

No response

kloknibor commented 2 weeks ago

In this code example the same issue arises: import toga

import toga
from toga.style import Pack
from toga.style.pack import COLUMN
import asyncio

class App(toga.App):
    def startup(self):
        self.main_window = toga.MainWindow(title="Auto Timesheets Client")
        asyncio.create_task(self.show_window())

    async def show_window(self):

        pizza = toga.Box(style=Pack(flex=1))
        pasta = toga.Box(style=Pack(flex=1))

        # Create a WebView and make it full-screen by setting flex
        webview = toga.WebView(style=Pack(flex=1))
        webview.url = "https://beeware.org"
        pizza.add(webview)

        # Create the OptionContainer with tabs
        container = toga.OptionContainer(
            content=[
                toga.OptionItem("Pizza", pizza),
                toga.OptionItem("Pasta", pasta),
            ]
        )
        # Set the main window's content
        self.main_window.content = container

        self.main_window.show()

def main():
    # Initialize the application
    return App(formal_name="Auto Timesheets Client", app_id="org.beeware.auto.timesheets")
kloknibor commented 2 weeks ago

When the making of the option container is in the synchronous startup it seems to work, e.g. with this code:


import toga
from toga.style import Pack
from toga.style.pack import COLUMN
import asyncio

class App(toga.App):
    def startup(self):
        self.main_window = toga.MainWindow(title="Auto Timesheets Client")
        pizza = toga.Box(style=Pack(flex=1))
        pasta = toga.Box(style=Pack(flex=1))

        # Create a WebView and make it full-screen by setting flex
        webview = toga.WebView(style=Pack(flex=1))
        webview.url = "https://beeware.org"
        pizza.add(webview)

        # Create the OptionContainer with tabs
        container = toga.OptionContainer(
            content=[
                toga.OptionItem("Pizza", pizza),
                toga.OptionItem("Pasta", pasta),
            ]
        )
        # Set the main window's content
        self.main_window.content = container
        self.main_window.show()

def main():
    # Initialize the application
    return App(formal_name="Auto Timesheets Client", app_id="org.beeware.auto.timesheets")
freakboy3742 commented 2 weeks ago

Thanks for the report - that's definitely an odd one. What's especially odd is that it appears to be the native OptionContainer widget itself that has the problem - it looks like all the pieces are rendered correctly except the background on the tab bar itself. That suggests the issue is with the widget not correctly evaluating its internal size... but I'm not sure why that would be happening.

My initial guess is that it might be due to the main_window.show() call being deferred until after the app has started. As you've noted, if you construct the widgets in the startup method, the problem doesn't appear; that suggests to me that the "initial layout" is getting something stuck with an initial size that is zero sized because it's not visible, and that is influencing the rendering of the OptionContainer. More investigation is definitely required.

In terms of an immediate workaround, I'd suggest constructing as much of the layout as you can in the startup method, and then only populate the content that needs to be async in the task. For example - you can create the web view with no content, and then populate the webview in the async task.

kloknibor commented 2 weeks ago

@freakboy3742 thanks for the quick reply as always! I tried something similar as you suggested but tried to create the window in the synchronous init function of a togabox I was creating async. With this solution I now see a clear race condition, sometimes it shows correctly, sometimes it doesn't.

For now I'll just move the constructing of the window in my startup function in app.py this should work, thanks!

kloknibor commented 2 weeks ago

I have rewritten my code to this, but I still have the same issue when I call _show_main. As you can see the content is already generated in the startup. Any more suggestions for workarounds?:

import toga
import platform
import ctypes
from toga.style import Pack
from toga.style.pack import COLUMN

class App(toga.App):
    def startup(self):
        # hardcoded sections
        self.desktop_splash_width = 600
        self.desktop_splash_height = 200
        self.app_version = "Version 1.0.0"
        self.test_mobile = True

        # preparing all functionality for startup
        # Detect platform and get screen size
        self.platform = platform.system()
        # Create the main window
        self._create_main_window()
        self.main_window.show()
        self.screen_width, self.screen_height = self.get_screen_size()

        # constructing content
        self.splashscreen_container = self._construct_splash()
        self.main_window_container = self._construct_main()

        # Show a unified splash screen
        self._show_splash()

    def _create_main_window(self):
        # Common main window logic
        self.main_window = toga.MainWindow(title="Auto Timesheets Client")

    def get_screen_size(self):
        # Platform-specific screen size fetching
        if self.platform == "Windows":
            user32 = ctypes.windll.user32
            screen_width = user32.GetSystemMetrics(0)
            screen_height = user32.GetSystemMetrics(1)
            return screen_width, screen_height
        elif self.platform in ['iOS', 'iPadOS']:
            return self.main_window.size

        return None, None  # Default case for non-dynamic platforms

    def _construct_splash(self):
        # Create a black background container for the splash screen
        splashscreen_box = toga.Box(style=Pack(direction="column", background_color="black"))

        # Import the unified splash screen widget
        from .ui.widgets.splash_screen import SplashScreen
        if platform.system() in ['iOS', 'iPadOS', 'Android']:
            splash_screen = SplashScreen(self, screen_width=self.screen_width, screen_height=self.screen_height,
                                         splash_width=self.screen_width, splash_height=self.screen_height,
                                         version=self.app_version)
        else:
            # Calculate the center position for the splash screen
            x_position = ((self.screen_width - self.desktop_splash_width) // 2)
            y_position = ((self.screen_height - self.desktop_splash_height) // 2) - 150

            # Setting the window size to smaller on startup
            self.main_window.position = (x_position, y_position)
            self.main_window.size = self.desktop_splash_width, self.desktop_splash_height

            splash_screen = SplashScreen(self, screen_width=self.screen_width, screen_height=self.screen_height,
                                         splash_width=self.desktop_splash_width,
                                         splash_height=self.desktop_splash_height,
                                         version=self.app_version)
        splashscreen_box.add(splash_screen)
        return splashscreen_box

    def _construct_main(self):
        if self.test_mobile or platform.system() in ['iOS', 'iPadOS', 'Android']:
            # Do mobile imports
            from autotimesheets.ui.mobile.widgets.timeline_window import MapWindow
            from autotimesheets.ui.mobile.widgets.settings_window import SettingsWindow
            from autotimesheets.ui.mobile.widgets.timesheet_window import TimesheetWindow
            from autotimesheets.ui.mobile.widgets.web_window import WebWindow

            # set screensize
            if self.test_mobile:
                self.app.main_window.size = (650, 1100)
            else:
                self.app.main_window.size = (self.screen_width, self.screen_height)

            # making sure objects are accesable later
            self.map_window = MapWindow(app=self.app)

            # build mobile main window
            mainwindow_container = toga.OptionContainer(
                content=[
                    toga.OptionItem("Timeline", self.map_window),
                    toga.OptionItem("Timesheets", TimesheetWindow(app=self.app)),
                    toga.OptionItem("Web", WebWindow(app=self.app)),
                    toga.OptionItem("Settings", SettingsWindow(app=self.app))
                ]
            )

            return mainwindow_container

        else:
            from autotimesheets.ui.desktop import main_window
            mainwindow_container = toga.SplitContainer(direction=Direction.VERTICAL,
                                                 style=Pack(direction="column", background_color="black"))
            left_container = main_window.list_container(self.app)
            right_container = main_window.map_container(self.app)
            mainwindow_container.content = [left_container, right_container]
            return  mainwindow_container

    def _show_splash(self):
        self.main_window.content = self.splashscreen_container

    def _show_main(self, workorders=None, timesheets=None):
        # Set the main window's content
        self.main_window.content = self.main_window_container
        self.map_window.add_workorders(workorders)
        print(workorders)

def main():
    # Initialize the application
    return App(formal_name="Auto Timesheets Client", app_id="org.beeware.auto.timesheets")
kloknibor commented 2 weeks ago

Also there is another bug, there seems to be a padding top between the mainwindow title on ios and the start of the option container. As can also be seen in the screenshots earlier, the white bar at the top.

freakboy3742 commented 2 weeks ago

It's very difficult to debug examples when you provide code for a complete app, rather than a reduced example - but the thing that stands out is that your initial main_window is being shown before it has any content, and the splash screen is being added after the window is shown - and it's not clear when _show_main is being invoked at all. The design I was suggesting was that the main window content should be fully constructed and added to main window before the window is shown. It should be possible to swap content at any time; but that might point at the source of the bug if the initial content is empty/zero-sized, rather than being flexible.

(As an aside, I'll also note that the splash screen approach you're using based on hard-coding the size of the window isn't using the Toga API in the way it's intended to be used. If you need a layout to fill the window, then you need a flexible layout that fills the window, not content that is hard coded to a specific size).