diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1744211c5d..916e7e85bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -256,8 +256,9 @@ jobs: - "macOS-x86_64" - "macOS-arm64" - "windows" - - "linux-x11" - - "linux-wayland" + - "linux-x11-gtk3" + - "linux-wayland-gtk3" + - "linux-wayland-gtk4" - "android" - "iOS" - "textual-linux" @@ -282,7 +283,7 @@ jobs: # We use a fixed Ubuntu version rather than `-latest` because at some point, # `-latest` will be updated, but it will be a soft changeover, which would cause # the system Python version to become inconsistent from run to run. - - backend: "linux-x11" + - backend: "linux-x11-gtk3" platform: "linux" runs-on: "ubuntu-24.04" # The package list should be the same as in unix-prerequisites.rst, and the BeeWare @@ -311,7 +312,7 @@ jobs: setup-python: false # Use the system Python packages app-user-data-path: "$HOME/.local/share/testbed" - - backend: "linux-wayland" + - backend: "linux-wayland-gtk3" platform: "linux" runs-on: "ubuntu-24.04" # The package list should be the same as in unix-prerequisites.rst, and the BeeWare @@ -330,7 +331,7 @@ jobs: # Start Window Manager echo "Start window manager..." # mutter is being run inside a virtual X server because mutter's headless - # mode is not compatible with Gtk + # mode does not provide a Gdk.Display DISPLAY=:99 MUTTER_DEBUG_DUMMY_MODE_SPECS=2048x1536 \ mutter --nested --wayland --no-x11 --wayland-display toga & sleep 1 @@ -338,6 +339,36 @@ jobs: setup-python: false # Use the system Python packages app-user-data-path: "$HOME/.local/share/testbed" + - backend: "linux-wayland-gtk4" + platform: "linux" + runs-on: "ubuntu-24.04" + env: + XDG_RUNTIME_DIR: "/tmp" + # The package list should be the same as in unix-prerequisites.rst, and the BeeWare + # tutorial, plus mutter to provide a window manager. + pre-command: | + sudo apt update -y + sudo apt install -y --no-install-recommends \ + mutter pkg-config python3-dev libgirepository1.0-dev libcairo2-dev \ + gir1.2-webkit-6.0 gir1.2-xapp-1.0 gir1.2-geoclue-2.0 gir1.2-flatpak-1.0 \ + gir1.2-gtk-4.0 + + # Start Virtual X Server + echo "Start X server..." + Xvfb :99 -screen 0 2048x1536x24 & + sleep 1 + + # Start Window Manager + echo "Start window manager..." + # mutter is being run inside a virtual X server because mutter's headless + # mode does not provide a Gdk.Display + DISPLAY=:99 MUTTER_DEBUG_DUMMY_MODE_SPECS=2048x1536 \ + mutter --nested --wayland --no-x11 --wayland-display toga & + sleep 1 + briefcase-run-prefix: "WAYLAND_DISPLAY=toga TOGA_GTK=4" + setup-python: false # Use the system Python packages + app-user-data-path: "$HOME/.local/share/testbed" + - backend: "textual-linux" platform: "linux" runs-on: "ubuntu-latest" diff --git a/android/tests_backend/window.py b/android/tests_backend/window.py index aa694fdc54..64980323dd 100644 --- a/android/tests_backend/window.py +++ b/android/tests_backend/window.py @@ -12,6 +12,8 @@ class WindowProbe(BaseProbe, DialogsMixin): supports_fullscreen = True supports_presentation = True + supports_as_image = True + supports_focus = True def __init__(self, app, window): super().__init__(app) diff --git a/changes/3087.feature.rst b/changes/3087.feature.rst new file mode 100644 index 0000000000..f052795897 --- /dev/null +++ b/changes/3087.feature.rst @@ -0,0 +1 @@ +Initial experimental support for GTK4 has been added to Toga's GTK backend. This support can be enabled by setting ``TOGA_GTK=4`` in your environment. diff --git a/cocoa/tests_backend/window.py b/cocoa/tests_backend/window.py index 5115819381..2c16c51fe7 100644 --- a/cocoa/tests_backend/window.py +++ b/cocoa/tests_backend/window.py @@ -16,6 +16,8 @@ class WindowProbe(BaseProbe, DialogsMixin): supports_unminimize = True supports_minimize = True supports_placement = True + supports_as_image = True + supports_focus = True def __init__(self, app, window): super().__init__() diff --git a/docs/how-to/contribute/code.rst b/docs/how-to/contribute/code.rst index 1c5e579e16..2e9f03478c 100644 --- a/docs/how-to/contribute/code.rst +++ b/docs/how-to/contribute/code.rst @@ -447,6 +447,25 @@ that you could try to implement. Again, you'll need to add unit tests and/or backend probes for any new features you add. +Contribute to the GTK4 update +----------------------------- + +Toga's GTK support is currently based on the GTK3 API. This API works, and ships with +most Linux distributions, but is no longer maintained by the GTK team. We're in the +process of adding GTK4 support to Toga's GTK backend. You can help with this update +process. + +GTK4 support can be enabled by setting the ``TOGA_GTK=4`` environment variable. To +contribute to the update, pick a widget that currently has GTK3 support, and try +updating the widget's API to support GTK4 as well. You can identify a widget that hasn't +been ported by looking at the :ref:`GTK probe for the widget ` - widgets +that aren't ported yet will have an "if GTK4, skip" block at the top of the probe +definition. + +The code needs to support both GTK3 and GTK4; if there are significant differences in +API, you can add conditional branches based on the GTK version. See one of the widgets +that *has* been ported (e.g., Label) for examples of how this can be done. + Implement an entirely new platform backend ------------------------------------------ @@ -832,6 +851,11 @@ run``. You can also use slow mode or pytest specifiers with ``briefcase run``, using the same ``--`` syntax as you used in developer mode. +Finally, if you would like to run the tests against GTK4 on Linux, set the +environmental variable ``TOGA_GTK=4``. This is experimental and only partially +implemented, but we would greatly appreciate your help translating widgets from +GTK3 to GTK4. + .. _testbed-probe: How the testbed works diff --git a/docs/reference/api/widgets/mapview.rst b/docs/reference/api/widgets/mapview.rst index c7ddad72e3..f5e5fe8728 100644 --- a/docs/reference/api/widgets/mapview.rst +++ b/docs/reference/api/widgets/mapview.rst @@ -116,6 +116,11 @@ System requirements - OpenSUSE Tumbleweed: ``libwebkit2gtk3 typelib(WebKit2)`` - FreeBSD: ``webkit2-gtk3`` + MapView is not fully supported on GTK4. If you want to contribute to the GTK4 MapView + implementation, you will require v6.0 of the WebKit2 libraries. This is provided by + ``gir1.2-webkit-6.0`` on Ubuntu/Debian, and ``webkitgtk6.0`` on Fedora; for other + distributions, consult your distributions's platform documentation. + * Using MapView on Android requires the OSMDroid package in your project's Gradle dependencies. Ensure your app declares a dependency on ``org\.osmdroid:osmdroid-android:6.1.20`` or later. diff --git a/docs/reference/api/widgets/webview.rst b/docs/reference/api/widgets/webview.rst index ccc035fa9d..bfe16ed326 100644 --- a/docs/reference/api/widgets/webview.rst +++ b/docs/reference/api/widgets/webview.rst @@ -83,6 +83,11 @@ System requirements - OpenSUSE Tumbleweed: ``libwebkit2gtk3 typelib(WebKit2)`` - FreeBSD: ``webkit2-gtk3`` + WebView is not fully supported on GTK4. If you want to contribute to the GTK4 WebView + implementation, you will require v6.0 of the WebKit2 libraries. This is provided by + ``gir1.2-webkit-6.0`` on Ubuntu/Debian, and ``webkitgtk6.0`` on Fedora; for other + distributions, consult your distributions's platform documentation. + Notes ----- diff --git a/docs/reference/platforms/unix-prerequisites.rst b/docs/reference/platforms/unix-prerequisites.rst index bac54763f6..51e20ae514 100644 --- a/docs/reference/platforms/unix-prerequisites.rst +++ b/docs/reference/platforms/unix-prerequisites.rst @@ -41,6 +41,11 @@ If you're not using one of these, you'll need to work out how to install the dev libraries for ``python3``, ``cairo``, and ``gobject-introspection`` (and please let us know so we can improve this documentation!) +In addition to the dependencies above, if you would like to help add additional support +for GTK4, you need to also install ``gir1.2-gtk-4.0`` on Ubuntu/Debian, or ``gtk4`` on +Fedora or Arch. For other distributions, consult your distributions's platform +documentation. + Some widgets (most notably, the :ref:`WebView ` and :ref:`MapView ` widgets) have additional system requirements. Likewise, certain hardware features (:ref:`Location `) have diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index cfffc512c9..ac6e83d1e6 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -6,6 +6,7 @@ from .keys import gtk_accel from .libs import ( + GTK_VERSION, IS_WAYLAND, TOGA_DEFAULT_STYLES, Gdk, @@ -34,7 +35,7 @@ def __init__(self, interface): # Stimulate the build of the app self.native = Gtk.Application( application_id=self.interface.app_id, - flags=Gio.ApplicationFlags.FLAGS_NONE, + flags=Gio.ApplicationFlags.DEFAULT_FLAGS, ) self.native_about_dialog = None @@ -53,12 +54,20 @@ def gtk_startup(self, data=None): # Set any custom styles css_provider = Gtk.CssProvider() - css_provider.load_from_data(TOGA_DEFAULT_STYLES) - context = Gtk.StyleContext() - context.add_provider_for_screen( - Gdk.Screen.get_default(), css_provider, Gtk.STYLE_PROVIDER_PRIORITY_USER - ) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + css_provider.load_from_data(TOGA_DEFAULT_STYLES) + context = Gtk.StyleContext() + context.add_provider_for_screen( + Gdk.Screen.get_default(), css_provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + ) + elif GTK_VERSION >= (4, 12, 0): # pragma: no-cover-if-gtk3 + css_provider.load_from_string(TOGA_DEFAULT_STYLES) + elif GTK_VERSION >= (4, 8, 0): # pragma: no-cover-if-gtk3 + css_provider.load_from_data(TOGA_DEFAULT_STYLES, len(TOGA_DEFAULT_STYLES)) + else: # pragma: no-cover-if-gtk3 + # Earlier than GTK 4.8 + css_provider.load_from_data(TOGA_DEFAULT_STYLES.encode("utf-8")) ###################################################################### # Commands and menus @@ -173,20 +182,24 @@ def set_main_window(self, window): def get_screens(self): display = Gdk.Display.get_default() - if IS_WAYLAND: # pragma: no-cover-if-linux-x - # `get_primary_monitor()` doesn't work on wayland, so return as it is. - return [ - ScreenImpl(native=display.get_monitor(i)) - for i in range(display.get_n_monitors()) - ] - else: # pragma: no-cover-if-linux-wayland - primary_screen = ScreenImpl(display.get_primary_monitor()) - screen_list = [primary_screen] + [ - ScreenImpl(native=display.get_monitor(i)) - for i in range(display.get_n_monitors()) - if display.get_monitor(i) != primary_screen.native - ] - return screen_list + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + if IS_WAYLAND: # pragma: no-cover-if-linux-x + # `get_primary_monitor()` doesn't work on wayland, so return as it is. + return [ + ScreenImpl(native=display.get_monitor(i)) + for i in range(display.get_n_monitors()) + ] + + else: # pragma: no-cover-if-linux-wayland + primary_screen = ScreenImpl(display.get_primary_monitor()) + screen_list = [primary_screen] + [ + ScreenImpl(native=display.get_monitor(i)) + for i in range(display.get_n_monitors()) + if display.get_monitor(i) != primary_screen.native + ] + return screen_list + else: # pragma: no-cover-if-gtk3 + return [ScreenImpl(native=monitor) for monitor in display.get_monitors()] ###################################################################### # App state @@ -201,7 +214,10 @@ def get_dark_mode_state(self): ###################################################################### def beep(self): - Gdk.beep() + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + Gdk.beep() + else: # pragma: no-cover-if-gtk3 + Gdk.Display.get_default().beep() def _close_about(self, dialog, *args, **kwargs): self.native_about_dialog.destroy() diff --git a/gtk/src/toga_gtk/container.py b/gtk/src/toga_gtk/container.py index 564a9434de..8a40077e74 100644 --- a/gtk/src/toga_gtk/container.py +++ b/gtk/src/toga_gtk/container.py @@ -1,205 +1,435 @@ -from .libs import Gdk, Gtk +from .libs import GTK_VERSION, Gdk, Gtk -####################################################################################### +#################################################################################### # Implementation notes: # -# GDK/GTK renders everything at 96dpi. When HiDPI mode is enabled, it is managed at the -# compositor level. See https://wiki.archlinux.org/index.php/HiDPI#GDK_3_(GTK_3) for -# details. -####################################################################################### - - -class TogaContainer(Gtk.Fixed): - """A GTK container widget implementing Toga's layout. - - This is a GTK widget, with no Toga interface manifestation. - """ - - def __init__(self): - super().__init__() - self._content = None - self.min_width = 100 - self.min_height = 100 - - self.dpi = 96 - self.baseline_dpi = self.dpi - - # The dirty widgets are the set of widgets that are known to need - # re-hinting before any redraw occurs. - self._dirty_widgets = set() - - # A flag that can be used to explicitly flag that a redraw is required. - self.needs_redraw = True - - def refreshed(self): - pass - - def make_dirty(self, widget=None): - """Mark the container (or a specific widget in the container) as dirty. - - :param widget: If provided, this widget will be rehinted before the next layout. - """ - self.needs_redraw = True - if widget is not None: - self._dirty_widgets.add(widget) - self.queue_resize() - - @property - def width(self): - """The display width of the container. - - If the container doesn't have any content yet, the width is 0. - """ - if self._content is None: - return 0 - return self.get_allocated_width() - - @property - def height(self): - """The display height of the container. - - If the container doesn't have any content yet, the height is 0. - """ - if self._content is None: - return 0 - return self.get_allocated_height() - - @property - def content(self): - """The Toga implementation widget that is the root content of this container. - - All children of the root content will also be added to the container as a result - of assigning content. +# GDK/GTK renders everything at 96dpi. When HiDPI mode is enabled, it is managed +# at the compositor level. See +# https://wiki.archlinux.org/index.php/HiDPI#GDK_3_(GTK_3) for details. +# +# GTK3 and GTK4 have different layout mechanisms; GTK3 uses a Gtk.Fixed-based +# layout, where the widget forces the position of children as part of the layout +# process. GTK4 uses a Gtk.LayoutManager to perform layout. However, the actual +# layouts *should* be effectively the same. +#################################################################################### + +if GTK_VERSION >= (4, 0, 0): # pragma: no-cover-if-gtk3 + + class TogaContainerLayoutManager(Gtk.LayoutManager): + def __init__(self): + super().__init__() + + def do_get_request_mode(self, container): + return Gtk.SizeRequestMode.CONSTANT_SIZE + + def do_measure(self, container, orientation, for_size): + """Return (recomputing if necessary) the preferred size for the container. + + The preferred size of the container is its minimum size. This preference + will be overridden with the layout size when the layout is applied. + + If the container does not yet have content, the minimum size is set to 0x0. + """ + # print("GET PREFERRED SIZE", self._content) + if container._content is None: + return 0, 0, -1, -1 + + # Ensure we have an accurate min layout size + container.recompute() + + # The container will conform to the size of the allocation it is given, + # so the min and preferred size are the same. + if orientation == Gtk.Orientation.HORIZONTAL: + return container.min_width, container.min_width, -1, -1 + elif orientation == Gtk.Orientation.VERTICAL: + return container.min_height, container.min_height, -1, -1 + + def do_allocate(self, container, width, height, baseline): + """Perform the actual layout for the all widget's children. + + The manager will assume whatever size it has been given by GTK - usually + the full space of the window that holds the container (`widget`). The + layout will then be re-computed based on this new available size, and + that new geometry will be applied to all child widgets of the container. + """ + # print(widget._content, f"Container layout {width}x{height} @ 0x0") + + if container._content: + # Re-evaluate the layout using the size as the basis for geometry + # print("REFRESH LAYOUT", width, height) + container._content.interface.style.layout(container) - If the container already has content, the old content will be replaced. The old - root content and all it's children will be removed from the container. + # Ensure the minimum content size from the layout is retained + container.min_width = container._content.interface.layout.min_width + container.min_height = container._content.interface.layout.min_height + + # WARNING! This is the list of children of the *container*, not + # the Toga widget. Toga maintains a tree of children; all nodes + # in that tree are direct children of the container. + child_widget = container.get_last_child() + while child_widget is not None: + if child_widget.get_visible(): + # Set the allocation of the child widget to the computed + # layout size. + # print( + # f" allocate child {child_widget.interface}: " + # "{child_widget.interface.layout}" + # ) + child_widget_allocation = Gdk.Rectangle() + child_widget_allocation.x = ( + child_widget.interface.layout.absolute_content_left + ) + child_widget_allocation.y = ( + child_widget.interface.layout.absolute_content_top + ) + child_widget_allocation.width = ( + child_widget.interface.layout.content_width + ) + child_widget_allocation.height = ( + child_widget.interface.layout.content_height + ) + child_widget.size_allocate(child_widget_allocation, -1) + child_widget = child_widget.get_prev_sibling() + + # The layout has been redrawn + container.needs_redraw = False + + class TogaContainer(Gtk.Box): + """A GTK container widget implementing Toga's layout. + + This is a GTK widget, with no Toga interface manifestation. """ - return self._content - - @content.setter - def content(self, widget): - if self._content: - self._content.container = None - self._content = widget - if widget: - widget.container = self - self.make_dirty(widget) - else: - self.make_dirty() + def __init__(self): + super().__init__() + + # Because we don’t have access to the existing layout manager, we must + # create our custom layout manager class. + layout_manager = TogaContainerLayoutManager() + self.set_layout_manager(layout_manager) + + self._content = None + self.min_width = 100 + self.min_height = 100 + + self.dpi = 96 + self.baseline_dpi = self.dpi + + # The dirty widgets are the set of widgets that are known to need + # re-hinting before any redraw occurs. + self._dirty_widgets = set() + + # A flag that can be used to explicitly flag that a redraw is required. + self.needs_redraw = True + + def refreshed(self): + pass + + def make_dirty(self, widget=None): + """Mark the container (or a specific widget in the container) as dirty. + + :param widget: If provided, rehint this widget before the next layout. + """ + self.needs_redraw = True + if widget is not None: + self._dirty_widgets.add(widget) + self.queue_resize() + + @property + def width(self): + """The display width of the container. + + If the container doesn't have any content yet, the width is 0. + """ + if self._content is None: + return 0 + return self.compute_bounds(self)[1].get_width() + + @property + def height(self): + """The display height of the container. + + If the container doesn't have any content yet, the height is 0. + """ + if self._content is None: + return 0 + return self.compute_bounds(self)[1].get_height() + + @property + def content(self): + """The Toga implementation widget that is the root content of this + container. + + All children of the root content will also be added to the container + as a result of assigning content. + + If the container already has content, the old content will be replaced. The + old root content and all it's children will be removed from the container. + """ + return self._content + + @content.setter + def content(self, widget): + if self._content: + self._content.container = None + + self._content = widget + if widget: + widget.container = self + self.make_dirty(widget) + else: + self.make_dirty() + + def recompute(self): + """Rehint and re-layout the container's content, if necessary. + + Any widgets known to be dirty will be rehinted. The minimum possible + layout size for the container will also be recomputed. + """ + if self._content and self._dirty_widgets: + # If any of the widgets have been marked as dirty, + # recompute their bounds, and re-evaluate the minimum + # allowed size for the layout. + while self._dirty_widgets: + widget = self._dirty_widgets.pop() + widget.rehint() + + # Recompute the layout + self._content.interface.style.layout(self) - def recompute(self): - """Rehint and re-layout the container's content, if necessary. + self.min_width = self._content.interface.layout.min_width + self.min_height = self._content.interface.layout.min_height - Any widgets known to be dirty will be rehinted. The minimum possible layout size - for the container will also be recomputed. - """ - if self._content and self._dirty_widgets: - # If any of the widgets have been marked as dirty, - # recompute their bounds, and re-evaluate the minimum - # allowed size for the layout. - while self._dirty_widgets: - widget = self._dirty_widgets.pop() - widget.rehint() + def do_get_preferred_width(self): + """Return (recomputing if necessary) the preferred width for the container. - # Recompute the layout - self._content.interface.style.layout(self) + The preferred size of the container is its minimum size. This + preference will be overridden with the layout size when the layout is + applied. - self.min_width = self._content.interface.layout.min_width - self.min_height = self._content.interface.layout.min_height + If the container does not yet have content, the minimum width is set to + 0. + """ + pass - def do_get_preferred_width(self): - """Return (recomputing if necessary) the preferred width for the container. + def do_get_preferred_height(self): + """Return (recomputing if necessary) the preferred height for the container. - The preferred size of the container is its minimum size. This - preference will be overridden with the layout size when the layout is - applied. + The preferred size of the container is its minimum size. This preference + will be overridden with the layout size when the layout is applied. - If the container does not yet have content, the minimum width is set to - 0. - """ - # print("GET PREFERRED WIDTH", self._content) - if self._content is None: - return 0, 0 + If the container does not yet have content, the minimum height is set to 0. + """ + pass - # Ensure we have an accurate min layout size - self.recompute() + def do_size_allocate(self, allocation): + """Perform the actual layout for the widget, and all it's children. - # The container will conform to the size of the allocation it is given, - # so the min and preferred size are the same. - return self.min_width, self.min_width + The container will assume whatever size it has been given by GTK - usually + the full space of the window that holds the container. The layout will then + be recomputed based on this new available size, and that new geometry will + be applied to all child widgets of the container. + """ + pass - def do_get_preferred_height(self): - """Return (recomputing if necessary) the preferred height for the container. +else: # pragma: no-cover-if-gtk4 - The preferred size of the container is its minimum size. This preference will be - overridden with the layout size when the layout is applied. + class TogaContainer(Gtk.Fixed): + """A GTK container widget implementing Toga's layout. - If the container does not yet have content, the minimum height is set to 0. + This is a GTK widget, with no Toga interface manifestation. """ - # print("GET PREFERRED HEIGHT", self._content) - if self._content is None: - return 0, 0 - - # Ensure we have an accurate min layout size - self.recompute() - # The container will conform to the size of the allocation it is given, - # so the min and preferred size are the same. - return self.min_height, self.min_height - - def do_size_allocate(self, allocation): - """Perform the actual layout for the widget, and all it's children. - - The container will assume whatever size it has been given by GTK - usually the - full space of the window that holds the container. The layout will then be re- - computed based on this new available size, and that new geometry will be applied - to all child widgets of the container. - """ - # print( - # self._content, - # f"Container layout {allocation.width}x{allocation.height} " - # f"@ {allocation.x}x{allocation.y}", - # ) - - # The container will occupy the full space it has been allocated. - resized = (allocation.width, allocation.height) != (self.width, self.height) - self.set_allocation(allocation) - - if self._content: - # This function may be called in response to irrelevant events like button - # clicks, so only refresh if we really need to. - if resized or self.needs_redraw: - # Re-evaluate the layout using the allocation size as the basis - # for geometry - # print("REFRESH LAYOUT", allocation.width, allocation.height) + def __init__(self): + super().__init__() + self._content = None + self.min_width = 100 + self.min_height = 100 + + self.dpi = 96 + self.baseline_dpi = self.dpi + + # The dirty widgets are the set of widgets that are known to need + # re-hinting before any redraw occurs. + self._dirty_widgets = set() + + # A flag that can be used to explicitly flag that a redraw is required. + self.needs_redraw = True + + def refreshed(self): + pass + + def make_dirty(self, widget=None): + """Mark the container (or a specific widget in the container) as dirty. + + :param widget: If provided, rehint this widget before the next layout. + """ + self.needs_redraw = True + if widget is not None: + self._dirty_widgets.add(widget) + self.queue_resize() + + @property + def width(self): + """The display width of the container. + + If the container doesn't have any content yet, the width is 0. + """ + if self._content is None: + return 0 + return self.get_allocated_width() + + @property + def height(self): + """The display height of the container. + + If the container doesn't have any content yet, the height is 0. + """ + if self._content is None: + return 0 + return self.get_allocated_height() + + @property + def content(self): + """The Toga implementation widget that is the root content of this + container. + + All children of the root content will also be added to the container + as a result of assigning content. + + If the container already has content, the old content will be replaced. + The old root content and all it's children will be removed from the + container. + """ + return self._content + + @content.setter + def content(self, widget): + if self._content: + self._content.container = None + + self._content = widget + if widget: + widget.container = self + self.make_dirty(widget) + else: + self.make_dirty() + + def recompute(self): + """Rehint and re-layout the container's content, if necessary. + + Any widgets known to be dirty will be rehinted. The minimum possible + layout size for the container will also be recomputed. + """ + if self._content and self._dirty_widgets: + # If any of the widgets have been marked as dirty, + # recompute their bounds, and re-evaluate the minimum + # allowed size for the layout. + while self._dirty_widgets: + widget = self._dirty_widgets.pop() + widget.rehint() + + # Recompute the layout self._content.interface.style.layout(self) - # Ensure the minimum content size from the layout is retained self.min_width = self._content.interface.layout.min_width self.min_height = self._content.interface.layout.min_height - # WARNING! This is the list of children of the *container*, not - # the Toga widget. Toga maintains a tree of children; all nodes - # in that tree are direct children of the container. - for widget in self.get_children(): - if widget.get_visible(): - # Set the size of the child widget to the computed layout size. - # print( - # f" allocate child {widget.interface}: " - # f"{widget.interface.layout}" - # ) - widget_allocation = Gdk.Rectangle() - widget_allocation.x = ( - widget.interface.layout.absolute_content_left + allocation.x - ) - widget_allocation.y = ( - widget.interface.layout.absolute_content_top + allocation.y - ) - widget_allocation.width = widget.interface.layout.content_width - widget_allocation.height = widget.interface.layout.content_height - - widget.size_allocate(widget_allocation) - - # The layout has been redrawn - self.needs_redraw = False + def do_get_preferred_width(self): + """Return (recomputing if necessary) the preferred width for the container. + + The preferred size of the container is its minimum size. This + preference will be overridden with the layout size when the layout is + applied. + + If the container does not yet have content, the minimum width is set to + 0. + """ + # print("GET PREFERRED WIDTH", self._content) + if self._content is None: + return 0, 0 + + # Ensure we have an accurate min layout size + self.recompute() + + # The container will conform to the size of the allocation it is given, + # so the min and preferred size are the same. + return self.min_width, self.min_width + + def do_get_preferred_height(self): + """Return (recomputing if necessary) the preferred height for the container. + + The preferred size of the container is its minimum size. This preference + will be overridden with the layout size when the layout is applied. + + If the container does not yet have content, the minimum height is set to 0. + """ + # print("GET PREFERRED HEIGHT", self._content) + if self._content is None: + return 0, 0 + + # Ensure we have an accurate min layout size + self.recompute() + + # The container will conform to the size of the allocation it is given, + # so the min and preferred size are the same. + return self.min_height, self.min_height + + def do_size_allocate(self, allocation): + """Perform the actual layout for the widget, and all it's children. + + The container will assume whatever size it has been given by GTK - usually + the full space of the window that holds the container. The layout will then + be recomputed based on this new available size, and that new geometry will + be applied to all child widgets of the container. + """ + # print( + # self._content, + # f"Container layout {allocation.width}x{allocation.height} " + # f"@ {allocation.x}x{allocation.y}", + # ) + + # The container will occupy the full space it has been allocated. + resized = (allocation.width, allocation.height) != (self.width, self.height) + self.set_allocation(allocation) + + if self._content: + # This function may be called in response to irrelevant events like + # button clicks, so only refresh if we really need to. + if resized or self.needs_redraw: + # Re-evaluate the layout using the allocation size as the basis + # for geometry + # print("REFRESH LAYOUT", allocation.width, allocation.height) + self._content.interface.style.layout(self) + + # Ensure the minimum content size from the layout is retained + self.min_width = self._content.interface.layout.min_width + self.min_height = self._content.interface.layout.min_height + + # WARNING! This is the list of children of the *container*, not + # the Toga widget. Toga maintains a tree of children; all nodes + # in that tree are direct children of the container. + for widget in self.get_children(): + if widget.get_visible(): + # Set the size of the child widget to the computed + # layout size. + # print( + # f" allocate child {widget.interface}: " + # f"{widget.interface.layout}" + # ) + widget_allocation = Gdk.Rectangle() + widget_allocation.x = ( + widget.interface.layout.absolute_content_left + allocation.x + ) + widget_allocation.y = ( + widget.interface.layout.absolute_content_top + allocation.y + ) + widget_allocation.width = widget.interface.layout.content_width + widget_allocation.height = ( + widget.interface.layout.content_height + ) + + widget.size_allocate(widget_allocation) + + # The layout has been redrawn + self.needs_redraw = False diff --git a/gtk/src/toga_gtk/dialogs.py b/gtk/src/toga_gtk/dialogs.py index 854b14a5c7..37dada4ce2 100644 --- a/gtk/src/toga_gtk/dialogs.py +++ b/gtk/src/toga_gtk/dialogs.py @@ -1,20 +1,25 @@ from pathlib import Path -from .libs import Gtk +import toga + +from .libs import GTK_VERSION, Gtk class BaseDialog: def show(self, host_window, future): - self.future = future + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.future = future - # If this is a modal dialog, set the window as transient to the host window. - if host_window: - self.native.set_transient_for(host_window._impl.native) - else: - self.native.set_transient_for(None) + # If this is a modal dialog, set the window as transient to the host window. + if host_window: + self.native.set_transient_for(host_window._impl.native) + else: + self.native.set_transient_for(None) - # Show the dialog. - self.native.show() + # Show the dialog. + self.native.show() + else: # pragma: no-cover-if-gtk3 + self.interface.factory.not_implemented("BaseDialog.show()") class MessageDialog(BaseDialog): @@ -27,18 +32,22 @@ def __init__( **kwargs, ): super().__init__() - self.success_result = success_result + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.success_result = success_result - self.native = Gtk.MessageDialog( - flags=0, - message_type=message_type, - buttons=buttons, - text=title, - ) - self.native.set_modal(True) - self.build_dialog(**kwargs) + self.native = Gtk.MessageDialog( + flags=0, + message_type=message_type, + buttons=buttons, + text=title, + ) + self.native.set_modal(True) + self.build_dialog(**kwargs) - self.native.connect("response", self.gtk_response) + self.native.connect("response", self.gtk_response) + + else: # pragma: no-cover-if-gtk3 + toga.NotImplementedWarning("Dialog()") def build_dialog(self, message): self.native.format_secondary_text(message) @@ -167,26 +176,29 @@ def __init__( title=title, action=action, ) - self.native.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) - self.native.add_button(ok_icon, Gtk.ResponseType.OK) - self.native.set_modal(True) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.native.add_button("_Cancel", Gtk.ResponseType.CANCEL) + self.native.add_button("_OK", Gtk.ResponseType.OK) + self.native.set_modal(True) - if filename: - self.native.set_current_name(filename) + if filename: + self.native.set_current_name(filename) - if initial_directory: - self.native.set_current_folder(str(initial_directory)) + if initial_directory: + self.native.set_current_folder(str(initial_directory)) - if file_types: - for file_type in file_types: - filter_filetype = Gtk.FileFilter() - filter_filetype.set_name("." + file_type + " files") - filter_filetype.add_pattern("*." + file_type) - self.native.add_filter(filter_filetype) + if file_types: + for file_type in file_types: + filter_filetype = Gtk.FileFilter() + filter_filetype.set_name("." + file_type + " files") + filter_filetype.add_pattern("*." + file_type) + self.native.add_filter(filter_filetype) - self.multiple_select = multiple_select - if self.multiple_select: - self.native.set_select_multiple(True) + self.multiple_select = multiple_select + if self.multiple_select: + self.native.set_select_multiple(True) + else: # pragma: no cover-if-gtk3 + pass self.native.connect("response", self.gtk_response) @@ -220,6 +232,7 @@ def __init__( initial_directory, file_types=None, ): + save_icon = "_Save" super().__init__( title=title, filename=filename, @@ -227,9 +240,12 @@ def __init__( file_types=file_types, multiple_select=False, action=Gtk.FileChooserAction.SAVE, - ok_icon=Gtk.STOCK_SAVE, + ok_icon=save_icon, ) - self.native.set_do_overwrite_confirmation(True) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.native.set_do_overwrite_confirmation(True) + else: # pragma: no-cover-if-gtk3 + pass class OpenFileDialog(FileDialog): @@ -240,6 +256,7 @@ def __init__( file_types, multiple_select, ): + open_icon = "_OK" super().__init__( title=title, filename=None, @@ -247,7 +264,7 @@ def __init__( file_types=file_types, multiple_select=multiple_select, action=Gtk.FileChooserAction.OPEN, - ok_icon=Gtk.STOCK_OPEN, + ok_icon=open_icon, ) @@ -258,6 +275,7 @@ def __init__( initial_directory, multiple_select, ): + open_icon = "_Open" super().__init__( title=title, filename=None, @@ -265,5 +283,5 @@ def __init__( file_types=None, multiple_select=multiple_select, action=Gtk.FileChooserAction.SELECT_FOLDER, - ok_icon=Gtk.STOCK_OPEN, + ok_icon=open_icon, ) diff --git a/gtk/src/toga_gtk/icons.py b/gtk/src/toga_gtk/icons.py index c0382cedb1..7542a30096 100644 --- a/gtk/src/toga_gtk/icons.py +++ b/gtk/src/toga_gtk/icons.py @@ -3,7 +3,7 @@ import toga -from .libs import GdkPixbuf, GLib +from .libs import GTK_VERSION, Gdk, GdkPixbuf, GLib, Gtk class Icon: @@ -32,9 +32,14 @@ def __init__(self, interface, path): # Preload all the required icon sizes try: for size, path in self.paths.items(): - native = GdkPixbuf.Pixbuf.new_from_file(str(path)).scale_simple( - size, size, GdkPixbuf.InterpType.BILINEAR - ) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + native = GdkPixbuf.Pixbuf.new_from_file(str(path)).scale_simple( + size, size, GdkPixbuf.InterpType.BILINEAR + ) + else: # pragma: no-cover-if-gtk3 + native = Gtk.Image.new_from_paintable( + Gdk.Texture.new_from_filename(str(path)) + ) self._native[size] = native except GLib.GError: raise ValueError(f"Unable to load icon from {path}") @@ -43,11 +48,14 @@ def native(self, size): try: return self._native[size] except KeyError: - # self._native will have at least one entry, and it will have been populated - # in reverse size order, so the first value returned will be the largest - # size discovered. - native = self._native[next(iter(self._native))].scale_simple( - size, size, GdkPixbuf.InterpType.BILINEAR - ) - self._native[size] = native - return native + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + # self._native will have at least one entry, and it will have been + # populated in reverse size order, so the first value returned will + # be the largest size discovered. + native = self._native[next(iter(self._native))].scale_simple( + size, size, GdkPixbuf.InterpType.BILINEAR + ) + self._native[size] = native + return native + else: # pragma: no-cover-if-gtk3 + return None diff --git a/gtk/src/toga_gtk/libs/gtk.py b/gtk/src/toga_gtk/libs/gtk.py index 65dcbce4ce..9768fbe597 100644 --- a/gtk/src/toga_gtk/libs/gtk.py +++ b/gtk/src/toga_gtk/libs/gtk.py @@ -1,7 +1,10 @@ +import os + import gi -gi.require_version("Gdk", "3.0") -gi.require_version("Gtk", "3.0") +gtk_version = "4.0" if os.getenv("TOGA_GTK") == "4" else "3.0" +gi.require_version("Gdk", gtk_version) +gi.require_version("Gtk", gtk_version) from gi.events import GLibEventLoopPolicy # noqa: E402, F401 from gi.repository import ( # noqa: E402, F401 @@ -14,7 +17,17 @@ Gtk, ) -if Gdk.Screen.get_default() is None: # pragma: no cover +GTK_VERSION: tuple[int, int, int] = ( + Gtk.get_major_version(), + Gtk.get_minor_version(), + Gtk.get_micro_version(), +) + +if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + default_display = Gdk.Screen.get_default() +else: # pragma: no-cover-if-gtk3 + default_display = Gdk.Display.get_default() +if default_display is None: # pragma: no cover raise RuntimeError( "Cannot identify an active display. Is the `DISPLAY` " "environment variable set correctly?" @@ -25,14 +38,21 @@ # The following imports will fail if the underlying libraries or their API # wrappers aren't installed; handle failure gracefully (see # https://github.com/beeware/toga/issues/26) -try: +if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 try: - gi.require_version("WebKit2", "4.1") - except ValueError: # pragma: no cover - gi.require_version("WebKit2", "4.0") - from gi.repository import WebKit2 # noqa: F401 -except (ImportError, ValueError): # pragma: no cover - WebKit2 = None + try: + gi.require_version("WebKit2", "4.1") + except ValueError: # pragma: no cover + gi.require_version("WebKit2", "4.0") + from gi.repository import WebKit2 # noqa: F401 + except (ImportError, ValueError): # pragma: no cover + WebKit2 = None +else: # pragma: no-cover-if-gtk3 + try: + gi.require_version("WebKit", "6.0") + from gi.repository import WebKit as WebKit2 # noqa: F401 + except (ImportError, ValueError): # pragma: no cover + WebKit2 = None try: gi.require_version("Pango", "1.0") diff --git a/gtk/src/toga_gtk/libs/styles.py b/gtk/src/toga_gtk/libs/styles.py index 686bb3d1b3..583bd523d6 100644 --- a/gtk/src/toga_gtk/libs/styles.py +++ b/gtk/src/toga_gtk/libs/styles.py @@ -1,17 +1,32 @@ from toga.colors import TRANSPARENT from toga.fonts import SYSTEM_DEFAULT_FONT_SIZE -TOGA_DEFAULT_STYLES = b""" -.toga-detailed-list-floating-buttons { - min-width: 24px; - min-height: 24px; - color: white; - background: #000000; - border-style: none; - border-radius: 0; - opacity: 0.60; -} -""" +from ..libs import GTK_VERSION + +if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + TOGA_DEFAULT_STYLES = b""" + .toga-detailed-list-floating-buttons { + min-width: 24px; + min-height: 24px; + color: white; + background: #000000; + border-style: none; + border-radius: 0; + opacity: 0.60; + } + """ +else: # pragma: no-cover-if-gtk3 + TOGA_DEFAULT_STYLES = """ + .toga-detailed-list-floating-buttons { + min-width: 24px; + min-height: 24px; + color: white; + background: #000000; + border-style: none; + border-radius: 0; + opacity: 0.60; + } + """ def get_color_css(value): diff --git a/gtk/src/toga_gtk/screens.py b/gtk/src/toga_gtk/screens.py index a951ceb67f..05c4b7dd21 100644 --- a/gtk/src/toga_gtk/screens.py +++ b/gtk/src/toga_gtk/screens.py @@ -1,7 +1,7 @@ from toga.screens import Screen as ScreenInterface from toga.types import Position, Size -from .libs import IS_WAYLAND, Gdk +from .libs import GTK_VERSION, IS_WAYLAND, Gdk class Screen: @@ -21,8 +21,14 @@ def get_name(self): return self.native.get_model() def get_origin(self) -> Position: - geometry = self.native.get_geometry() - return Position(geometry.x, geometry.y) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + geometry = self.native.get_geometry() + return Position(geometry.x, geometry.y) + else: # pragma: no-cover-if-gtk3 + self.interface.factory.not_implemented( + "Screen get_origin is not possible with GTK4" + ) + return Position(0, 0) def get_size(self) -> Size: geometry = self.native.get_geometry() diff --git a/gtk/src/toga_gtk/statusicons.py b/gtk/src/toga_gtk/statusicons.py index 7ecda53f9b..bbcb13edc6 100644 --- a/gtk/src/toga_gtk/statusicons.py +++ b/gtk/src/toga_gtk/statusicons.py @@ -1,7 +1,7 @@ import toga from toga.command import Group, Separator -from .libs import Gtk, XApp +from .libs import GTK_VERSION, Gtk, XApp class StatusIcon: @@ -17,16 +17,19 @@ def set_icon(self, icon): self.native.set_icon_name(path) def create(self): - if XApp is None: # pragma: no cover - # Can't replicate this in testbed - raise RuntimeError( - "Unable to import XApp. Ensure that the system package " - "providing libxapp and its GTK bindings have been installed." - ) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + if XApp is None: # pragma: no cover + # Can't replicate this in testbed + raise RuntimeError( + "Unable to import XApp. Ensure that the system package " + "providing libxapp and its GTK bindings have been installed." + ) - self.native = XApp.StatusIcon.new() - self.native.set_tooltip_text(self.interface.text) - self.set_icon(self.interface.icon) + self.native = XApp.StatusIcon.new() + self.native.set_tooltip_text(self.interface.text) + self.set_icon(self.interface.icon) + else: # pragma: no-cover-if-gtk3 + self.interface.factory.not_implemented("StatusIcon") def remove(self): del self.native @@ -36,7 +39,10 @@ def remove(self): class SimpleStatusIcon(StatusIcon): def create(self): super().create() - self.native.connect("activate", self.gtk_activate) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.native.connect("activate", self.gtk_activate) + else: # pragma: no-cover-if-gtk3 + pass def gtk_activate(self, icon, button, time): self.interface.on_press() @@ -71,42 +77,46 @@ def _submenu(self, group, group_cache): return submenu def create(self): - # Menu status icons are the only icons that have extra construction needs. - # Clear existing menus - for item in self.interface._menu_status_icons: - submenu = Gtk.Menu.new() - item._impl.native.set_primary_menu(submenu) - - # Determine the primary status icon. - primary_group = self.interface._primary_menu_status_icon - if primary_group is None: # pragma: no cover - # If there isn't at least one menu status icon, then there aren't any menus - # to populate. This can't be replicated in the testbed. - return - - # Add the menu status items to the cache - group_cache = { - item: item._impl.native.get_primary_menu() - for item in self.interface._menu_status_icons - } - # Map the COMMANDS group to the primary status icon's menu. - group_cache[Group.COMMANDS] = primary_group._impl.native.get_primary_menu() - self._menu_items = {} - - for cmd in self.interface.commands: - try: - submenu = self._submenu(cmd.group, group_cache) - except ValueError: - raise ValueError( - f"Command {cmd.text!r} does not belong to " - "a current status icon group." - ) - else: - if isinstance(cmd, Separator): - menu_item = Gtk.SeparatorMenuItem.new() + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + # Menu status icons are the only icons that have extra construction needs. + # Clear existing menus + for item in self.interface._menu_status_icons: + submenu = Gtk.Menu.new() + item._impl.native.set_primary_menu(submenu) + + # Determine the primary status icon. + primary_group = self.interface._primary_menu_status_icon + if primary_group is None: # pragma: no cover + # If there isn't at least one menu status icon, then there aren't any + # menus to populate. This can't be replicated in the testbed. + return + + # Add the menu status items to the cache + group_cache = { + item: item._impl.native.get_primary_menu() + for item in self.interface._menu_status_icons + } + # Map the COMMANDS group to the primary status icon's menu. + group_cache[Group.COMMANDS] = primary_group._impl.native.get_primary_menu() + self._menu_items = {} + + for cmd in self.interface.commands: + try: + submenu = self._submenu(cmd.group, group_cache) + except ValueError: + raise ValueError( + f"Command {cmd.text!r} does not belong to " + "a current status icon group." + ) else: - menu_item = Gtk.MenuItem.new_with_label(cmd.text) - menu_item.connect("activate", cmd._impl.gtk_activate) + if isinstance(cmd, Separator): + menu_item = Gtk.SeparatorMenuItem.new() + else: + menu_item = Gtk.MenuItem.new_with_label(cmd.text) + menu_item.connect("activate", cmd._impl.gtk_activate) + + submenu.append(menu_item) + submenu.show_all() - submenu.append(menu_item) - submenu.show_all() + else: # pragma: no-cover-if-gtk3 + self.interface.factory.not_implemented("StatusIconSet.create()") diff --git a/gtk/src/toga_gtk/widgets/activityindicator.py b/gtk/src/toga_gtk/widgets/activityindicator.py index 523f7d2d0f..5020a5caae 100644 --- a/gtk/src/toga_gtk/widgets/activityindicator.py +++ b/gtk/src/toga_gtk/widgets/activityindicator.py @@ -1,4 +1,4 @@ -from ..libs import Gtk +from ..libs import GTK_VERSION, Gtk from .base import Widget @@ -16,14 +16,17 @@ def stop(self): self.native.stop() def rehint(self): - # print( - # "REHINT", - # self, - # self.native.get_preferred_width(), - # self.native.get_preferred_height(), - # ) - width = self.native.get_preferred_width() - height = self.native.get_preferred_height() + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + # print( + # "REHINT", + # self, + # self.native.get_preferred_width(), + # self.native.get_preferred_height(), + # ) + width = self.native.get_preferred_width() + height = self.native.get_preferred_height() - self.interface.intrinsic.width = width[0] - self.interface.intrinsic.height = height[0] + self.interface.intrinsic.width = width[0] + self.interface.intrinsic.height = height[0] + else: # pragma: no-cover-if-gtk3 + pass diff --git a/gtk/src/toga_gtk/widgets/base.py b/gtk/src/toga_gtk/widgets/base.py index cb82809a87..f88e25e3bb 100644 --- a/gtk/src/toga_gtk/widgets/base.py +++ b/gtk/src/toga_gtk/widgets/base.py @@ -2,7 +2,13 @@ from travertino.size import at_least -from ..libs import Gtk, get_background_color_css, get_color_css, get_font_css +from ..libs import ( + GTK_VERSION, + Gtk, + get_background_color_css, + get_color_css, + get_font_css, +) class Widget: @@ -21,7 +27,10 @@ def __init__(self, interface): # Ensure the native widget has GTK CSS style attributes; create() should # ensure any other widgets are also styled appropriately. self.native.set_name(f"toga-{self.interface.id}") - self.native.get_style_context().add_class("toga") + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.native.get_style_context().add_class("toga") + else: # pragma: no-cover-if-gtk3 + self.native.add_css_class("toga") @abstractmethod def create(self): ... @@ -52,8 +61,11 @@ def container(self, container): elif container: # setting container, adding self to container.native self._container = container - self._container.add(self.native) - self.native.show_all() + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self._container.add(self.native) + self.native.show_all() + else: # pragma: no-cover-if-gtk3 + self._container.append(self.native) for child in self.interface.children: child._impl.container = container @@ -68,7 +80,18 @@ def set_enabled(self, value): @property def has_focus(self): - return self.native.has_focus() + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + return self.native.has_focus() + else: # pragma: no-cover-if-gtk3 + root = self.native.get_root() + focus_widget = root.get_focus() + if focus_widget: + if focus_widget == self.native: + return self.native.has_focus() + else: + return focus_widget.is_ancestor(self.native) + else: + return False def focus(self): if not self.has_focus: @@ -181,14 +204,20 @@ def refresh(self): def rehint(self): # Perform the actual GTK rehint. - # print( - # "REHINT", - # self, - # self.native.get_preferred_width(), - # self.native.get_preferred_height(), - # ) - width = self.native.get_preferred_width() - height = self.native.get_preferred_height() - - self.interface.intrinsic.width = at_least(width[0]) - self.interface.intrinsic.height = at_least(height[0]) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + # print( + # "REHINT", + # self, + # self.native.get_preferred_width(), + # self.native.get_preferred_height(), + # ) + width = self.native.get_preferred_width() + height = self.native.get_preferred_height() + + self.interface.intrinsic.width = at_least(width[0]) + self.interface.intrinsic.height = at_least(height[0]) + else: # pragma: no-cover-if-gtk3 + min_size, _ = self.native.get_preferred_size() + # print("REHINT", self, f"{width_info[0]}x{height_info[0]}") + self.interface.intrinsic.width = at_least(min_size.width) + self.interface.intrinsic.height = at_least(min_size.height) diff --git a/gtk/src/toga_gtk/widgets/button.py b/gtk/src/toga_gtk/widgets/button.py index a09f83c577..63026de082 100644 --- a/gtk/src/toga_gtk/widgets/button.py +++ b/gtk/src/toga_gtk/widgets/button.py @@ -2,7 +2,7 @@ from toga.colors import TRANSPARENT -from ..libs import Gtk +from ..libs import GTK_VERSION, Gtk from .base import Widget @@ -14,7 +14,10 @@ def create(self): self._icon = None def get_text(self): - return self.native.get_label() + text = self.native.get_label() + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + return text + return text if text else "" # pragma: no-cover-if-gtk3 def set_text(self, text): self.native.set_label(text) @@ -24,12 +27,22 @@ def get_icon(self): def set_icon(self, icon): self._icon = icon - if icon: - self.native.set_image(Gtk.Image.new_from_pixbuf(icon._impl.native(32))) - self.native.set_always_show_image(True) - else: - self.native.set_image(None) - self.native.set_always_show_image(False) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + if icon: + self.native.set_image(Gtk.Image.new_from_pixbuf(icon._impl.native(32))) + self.native.set_always_show_image(True) + else: + self.native.set_image(None) + self.native.set_always_show_image(False) + else: # pragma: no-cover-if-gtk3 + if icon: + icon._impl.native.set_icon_size(Gtk.IconSize.LARGE) + self.native.set_child(icon._impl.native) + else: + text = self.native.get_label() + if text: + self.native.set_label(text) + self.native.set_child(None) def set_enabled(self, value): self.native.set_sensitive(value) @@ -39,17 +52,20 @@ def set_background_color(self, color): super().set_background_color(None if color is TRANSPARENT else color) def rehint(self): - # print( - # "REHINT", - # self, - # self.native.get_preferred_width(), - # self.native.get_preferred_height(), - # ) - width = self.native.get_preferred_width() - height = self.native.get_preferred_height() - - self.interface.intrinsic.width = at_least(width[0]) - self.interface.intrinsic.height = height[1] + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + # print( + # "REHINT", + # self, + # self.native.get_preferred_width(), + # self.native.get_preferred_height(), + # ) + width = self.native.get_preferred_width() + height = self.native.get_preferred_height() + + self.interface.intrinsic.width = at_least(width[0]) + self.interface.intrinsic.height = height[1] + else: # pragma: no-cover-if-gtk3 + pass def gtk_clicked(self, event): self.interface.on_press() diff --git a/gtk/src/toga_gtk/widgets/canvas.py b/gtk/src/toga_gtk/widgets/canvas.py index fe05c28e13..95be1912ef 100644 --- a/gtk/src/toga_gtk/widgets/canvas.py +++ b/gtk/src/toga_gtk/widgets/canvas.py @@ -8,7 +8,7 @@ from toga.constants import Baseline, FillRule from toga.fonts import SYSTEM_DEFAULT_FONT_SIZE from toga_gtk.colors import native_color -from toga_gtk.libs import Gdk, Gtk, Pango, PangoCairo, cairo +from toga_gtk.libs import GTK_VERSION, Gdk, Gtk, Pango, PangoCairo, cairo from .base import Widget @@ -23,16 +23,19 @@ def create(self): self.native = Gtk.DrawingArea() - self.native.connect("draw", self.gtk_draw_callback) - self.native.connect("size-allocate", self.gtk_on_size_allocate) - self.native.connect("button-press-event", self.mouse_down) - self.native.connect("button-release-event", self.mouse_up) - self.native.connect("motion-notify-event", self.mouse_move) - self.native.set_events( - Gdk.EventMask.BUTTON_PRESS_MASK - | Gdk.EventMask.BUTTON_RELEASE_MASK - | Gdk.EventMask.BUTTON_MOTION_MASK - ) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.native.connect("draw", self.gtk_draw_callback) + self.native.connect("size-allocate", self.gtk_on_size_allocate) + self.native.connect("button-press-event", self.mouse_down) + self.native.connect("button-release-event", self.mouse_up) + self.native.connect("motion-notify-event", self.mouse_move) + self.native.set_events( + Gdk.EventMask.BUTTON_PRESS_MASK + | Gdk.EventMask.BUTTON_RELEASE_MASK + | Gdk.EventMask.BUTTON_MOTION_MASK + ) + else: # pragma: no-cover-if-gtk3 + pass def gtk_draw_callback(self, widget, cairo_context): """Creates a draw callback. diff --git a/gtk/src/toga_gtk/widgets/detailedlist.py b/gtk/src/toga_gtk/widgets/detailedlist.py index 80854f238e..b9d8cfb930 100644 --- a/gtk/src/toga_gtk/widgets/detailedlist.py +++ b/gtk/src/toga_gtk/widgets/detailedlist.py @@ -2,7 +2,7 @@ from travertino.size import at_least -from toga_gtk.libs import Gdk, Gio, Gtk, Pango +from toga_gtk.libs import GTK_VERSION, Gdk, Gio, Gtk, Pango from .base import Widget @@ -18,8 +18,12 @@ def __init__(self, dl, row): # 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) + + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.stack.set_homogeneous(True) + self.add(self.stack) + else: # pragma: no-cover-if-gtk3 + pass self.content = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) @@ -28,20 +32,23 @@ def __init__(self, dl, row): self.text = Gtk.Label(xalign=0) - # 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) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + # 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.content.pack_end(self.text, True, True, 5) + self.content.pack_end(self.text, True, True, 5) - # Update the content for the row. - self.update(dl, row) + # Update the content for the row. + self.update(dl, row) - self.stack.add_named(self.content, "content") + self.stack.add_named(self.content, "content") - # Make sure the widgets have been made visible. - self.show_all() + # Make sure the widgets have been made visible. + self.show_all() + else: # pragma: no-cover-if-gtk3 + pass def update(self, dl, row): """Update the contents of the rendered row, using data from `row`, @@ -108,99 +115,125 @@ def create(self): # 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) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.native_detailedlist.set_selection_mode(Gtk.SelectionMode.SINGLE) + self.native_detailedlist.connect("row-selected", self.gtk_on_row_selected) + else: # pragma: no-cover-if-gtk3 + pass 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) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + # 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) + else: # pragma: no-cover-if-gtk3 + pass # 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) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + 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) + else: # pragma: no-cover-if-gtk3 + pass self.native_vadj = scrolled_window.get_vadjustment() - self.native_vadj.connect("value-changed", self.gtk_on_value_changed) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.native_vadj.connect("value-changed", self.gtk_on_value_changed) + else: # pragma: no-cover-if-gtk3 + pass # 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) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + 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") + 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") - # Add the refresh button to the revealer - self.native_revealer.add(self.native_refresh_button) + # Add the refresh button to the revealer + self.native_revealer.add(self.native_refresh_button) + else: # pragma: no-cover-if-gtk3 + pass # 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 reused - # 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) + + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + 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 reused + # 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) + else: # pragma: no-cover-if-gtk3 + pass 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 - ) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + 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 - # ) + # 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 + # ) + else: # pragma: no-cover-if-gtk3 + pass 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 - ) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + 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) + 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() + self.native_action_buttons.pack_start(action_buttons_hbox, True, False, 0) + self.native_action_buttons.show_all() + else: # pragma: no-cover-if-gtk3 + pass def row_factory(self, item): return DetailedListRow(self.interface, item) @@ -326,5 +359,8 @@ def update_refresh_button(self): self.native_revealer.set_reveal_child(show_refresh) def rehint(self): - self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) + else: # pragma: no-cover-if-gtk3 + pass diff --git a/gtk/src/toga_gtk/widgets/divider.py b/gtk/src/toga_gtk/widgets/divider.py index 09f6fa491d..9c1e17909d 100644 --- a/gtk/src/toga_gtk/widgets/divider.py +++ b/gtk/src/toga_gtk/widgets/divider.py @@ -1,6 +1,6 @@ from travertino.size import at_least -from ..libs import Gtk +from ..libs import GTK_VERSION, Gtk from .base import Widget @@ -9,15 +9,18 @@ def create(self): self.native = Gtk.Separator() def rehint(self): - width = self.native.get_preferred_width() - height = self.native.get_preferred_height() + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + width = self.native.get_preferred_width() + height = self.native.get_preferred_height() - if self.get_direction() == self.interface.VERTICAL: - self.interface.intrinsic.width = width[0] - self.interface.intrinsic.height = at_least(height[1]) - else: - self.interface.intrinsic.width = at_least(width[0]) - self.interface.intrinsic.height = height[1] + if self.get_direction() == self.interface.VERTICAL: + self.interface.intrinsic.width = width[0] + self.interface.intrinsic.height = at_least(height[1]) + else: + self.interface.intrinsic.width = at_least(width[0]) + self.interface.intrinsic.height = height[1] + else: # pragma: no-cover-if-gtk3 + pass def get_direction(self): return ( diff --git a/gtk/src/toga_gtk/widgets/imageview.py b/gtk/src/toga_gtk/widgets/imageview.py index 6ca7047e2b..99806af607 100644 --- a/gtk/src/toga_gtk/widgets/imageview.py +++ b/gtk/src/toga_gtk/widgets/imageview.py @@ -1,20 +1,26 @@ from toga.widgets.imageview import rehint_imageview -from ..libs import GdkPixbuf, Gtk +from ..libs import GTK_VERSION, GdkPixbuf, Gtk from .base import Widget class ImageView(Widget): def create(self): self.native = Gtk.Image() - self.native.connect("size-allocate", self.gtk_size_allocate) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.native.connect("size-allocate", self.gtk_size_allocate) + else: # pragma: no-cover-if-gtk3 + pass self._aspect_ratio = None def set_image(self, image): - if image: - self.set_scaled_pixbuf(image._impl.native, self.native.get_allocation()) - else: - self.native.set_from_pixbuf(None) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + if image: + self.set_scaled_pixbuf(image._impl.native, self.native.get_allocation()) + else: + self.native.set_from_pixbuf(None) + else: # pragma: no-cover-if-gtk3 + self.native.set_from_paintable() def gtk_size_allocate(self, widget, allocation): # GTK doesn't have any native image resizing; so, when the Gtk.Image @@ -54,9 +60,12 @@ def set_scaled_pixbuf(self, image, allocation): self.native.set_from_pixbuf(scaled) def rehint(self): - width, height, self._aspect_ratio = rehint_imageview( - image=self.interface.image, - style=self.interface.style, - ) - self.interface.intrinsic.width = width - self.interface.intrinsic.height = height + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + width, height, self._aspect_ratio = rehint_imageview( + image=self.interface.image, + style=self.interface.style, + ) + self.interface.intrinsic.width = width + self.interface.intrinsic.height = height + else: # pragma: no-cover-if-gtk3 + pass diff --git a/gtk/src/toga_gtk/widgets/label.py b/gtk/src/toga_gtk/widgets/label.py index be2a9a88ea..3fabe75a8f 100644 --- a/gtk/src/toga_gtk/widgets/label.py +++ b/gtk/src/toga_gtk/widgets/label.py @@ -1,13 +1,16 @@ from travertino.size import at_least -from ..libs import Gtk, gtk_text_align +from ..libs import GTK_VERSION, Gtk, gtk_text_align from .base import Widget class Label(Widget): def create(self): self.native = Gtk.Label() - self.native.set_line_wrap(False) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.native.set_line_wrap(False) + else: # pragma: no-cover-if-gtk3 + self.native.set_wrap(False) def set_text_align(self, value): xalign, justify = gtk_text_align(value) @@ -24,16 +27,28 @@ def set_text(self, value): self.native.set_text(value) def rehint(self): - # print( - # "REHINT", - # self, - # self.native.get_preferred_width(), - # self.native.get_preferred_height(), - # getattr(self, "_fixed_height", False), - # getattr(self, "_fixed_width", False), - # ) - width = self.native.get_preferred_width() - height = self.native.get_preferred_height() - - self.interface.intrinsic.width = at_least(width[0]) - self.interface.intrinsic.height = height[1] + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + # print( + # "REHINT", + # self, + # self.native.get_preferred_width(), + # self.native.get_preferred_height(), + # getattr(self, "_fixed_height", False), + # getattr(self, "_fixed_width", False), + # ) + width = self.native.get_preferred_width() + height = self.native.get_preferred_height() + + self.interface.intrinsic.width = at_least(width[0]) + self.interface.intrinsic.height = height[1] + else: # pragma: no-cover-if-gtk3 + # print( + # "REHINT", + # self, + # self.native.get_preferred_size()[0].width, + # self.native.get_preferred_size()[0].height, + # ) + min_size, size = self.native.get_preferred_size() + + self.interface.intrinsic.width = at_least(min_size.width) + self.interface.intrinsic.height = size.height diff --git a/gtk/src/toga_gtk/widgets/mapview.py b/gtk/src/toga_gtk/widgets/mapview.py index 9b77c18076..7ee0943a14 100644 --- a/gtk/src/toga_gtk/widgets/mapview.py +++ b/gtk/src/toga_gtk/widgets/mapview.py @@ -4,7 +4,7 @@ from toga.types import LatLng -from ..libs import Gtk, WebKit2 +from ..libs import GTK_VERSION, Gtk, WebKit2 from .base import Widget MAPVIEW_HTML_CONTENT = """ @@ -68,6 +68,9 @@ class MapView(Widget): SUPPORTS_ON_SELECT = False def create(self): + if GTK_VERSION >= (4, 0, 0): # pragma: no-cover-if-gtk3 + raise RuntimeError("MapView isn't supported on GTK4 (yet!)") + if WebKit2 is None: # pragma: no cover raise RuntimeError( "Unable to import WebKit2. Ensure that the system package providing " diff --git a/gtk/src/toga_gtk/widgets/multilinetextinput.py b/gtk/src/toga_gtk/widgets/multilinetextinput.py index b833baf051..ddae89858f 100644 --- a/gtk/src/toga_gtk/widgets/multilinetextinput.py +++ b/gtk/src/toga_gtk/widgets/multilinetextinput.py @@ -1,6 +1,7 @@ from travertino.size import at_least from ..libs import ( + GTK_VERSION, Gtk, get_background_color_css, get_color_css, @@ -34,15 +35,18 @@ def create(self): self.native_textview = Gtk.TextView() self.native_textview.set_name(f"toga-{self.interface.id}-textview") - self.native_textview.get_style_context().add_class("toga") + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.native_textview.get_style_context().add_class("toga") - self.native_textview.set_buffer(self.placeholder) - self.native_textview.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) - self.native_textview.connect("focus-in-event", self.gtk_on_focus_in) - self.native_textview.connect("focus-out-event", self.gtk_on_focus_out) - self.native_textview.connect("key-press-event", self.gtk_on_key_press) + self.native_textview.set_buffer(self.placeholder) + self.native_textview.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + self.native_textview.connect("focus-in-event", self.gtk_on_focus_in) + self.native_textview.connect("focus-out-event", self.gtk_on_focus_out) + self.native_textview.connect("key-press-event", self.gtk_on_key_press) - self.native.add(self.native_textview) + self.native.add(self.native_textview) + else: # pragma: no-cover-if-gtk3 + pass def set_color(self, color): self.apply_css( @@ -146,8 +150,11 @@ def gtk_on_key_press(self, *args): return False def rehint(self): - self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) + else: # pragma: no-cover-if-gtk3 + pass def scroll_to_bottom(self): self.buffer.place_cursor(self.buffer.get_end_iter()) diff --git a/gtk/src/toga_gtk/widgets/numberinput.py b/gtk/src/toga_gtk/widgets/numberinput.py index 59fc4a3217..ad4f9729c5 100644 --- a/gtk/src/toga_gtk/widgets/numberinput.py +++ b/gtk/src/toga_gtk/widgets/numberinput.py @@ -5,7 +5,7 @@ from toga.widgets.numberinput import _clean_decimal -from ..libs import Gtk, gtk_text_align +from ..libs import GTK_VERSION, Gtk, gtk_text_align from .base import Widget @@ -64,10 +64,13 @@ def set_text_align(self, value): self.native.set_alignment(xalign) def rehint(self): - width = self.native.get_preferred_width() - height = self.native.get_preferred_height() - - self.interface.intrinsic.width = at_least( - max(self.interface._MIN_WIDTH, width[1]) - ) - self.interface.intrinsic.height = height[1] + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + width = self.native.get_preferred_width() + height = self.native.get_preferred_height() + + self.interface.intrinsic.width = at_least( + max(self.interface._MIN_WIDTH, width[1]) + ) + self.interface.intrinsic.height = height[1] + else: # pragma: no-cover-if-gtk3 + pass diff --git a/gtk/src/toga_gtk/widgets/optioncontainer.py b/gtk/src/toga_gtk/widgets/optioncontainer.py index 807c6e7c94..803eb78b81 100644 --- a/gtk/src/toga_gtk/widgets/optioncontainer.py +++ b/gtk/src/toga_gtk/widgets/optioncontainer.py @@ -1,7 +1,7 @@ import asyncio from ..container import TogaContainer -from ..libs import Gtk +from ..libs import GTK_VERSION, Gtk from .base import Widget @@ -10,7 +10,10 @@ class OptionContainer(Widget): def create(self): self.native = Gtk.Notebook() - self.native.connect("switch-page", self.gtk_on_switch_page) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.native.connect("switch-page", self.gtk_on_switch_page) + else: # pragma: no-cover-if-gtk3 + pass self.sub_containers = [] def gtk_on_switch_page(self, widget, page, page_num): @@ -24,14 +27,17 @@ def gtk_on_switch_page(self, widget, page, page_num): asyncio.get_event_loop().call_soon(self.interface.on_select) def add_option(self, index, text, widget, icon): - sub_container = TogaContainer() - sub_container.content = widget - - self.sub_containers.insert(index, sub_container) - self.native.insert_page(sub_container, Gtk.Label(label=text), index) - # Tabs aren't visible by default; - # tell the notebook to show all content. - self.native.show_all() + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + sub_container = TogaContainer() + sub_container.content = widget + + self.sub_containers.insert(index, sub_container) + self.native.insert_page(sub_container, Gtk.Label(label=text), index) + # Tabs aren't visible by default; + # tell the notebook to show all content. + self.native.show_all() + else: # pragma: no-cover-if-gtk3 + pass def remove_option(self, index): self.native.remove_page(index) @@ -39,7 +45,10 @@ def remove_option(self, index): del self.sub_containers[index] def set_option_enabled(self, index, enabled): - self.sub_containers[index].set_visible(enabled) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.sub_containers[index].set_visible(enabled) + else: # pragma: no-cover-if-gtk3 + pass def is_option_enabled(self, index): return self.sub_containers[index].get_visible() diff --git a/gtk/src/toga_gtk/widgets/progressbar.py b/gtk/src/toga_gtk/widgets/progressbar.py index a44fa71db2..91d5421666 100644 --- a/gtk/src/toga_gtk/widgets/progressbar.py +++ b/gtk/src/toga_gtk/widgets/progressbar.py @@ -2,7 +2,7 @@ from travertino.size import at_least -from ..libs import Gtk +from ..libs import GTK_VERSION, Gtk from .base import Widget # Implementation notes @@ -91,14 +91,17 @@ def stop(self): self._stop_indeterminate() def rehint(self): - # print( - # "REHINT", - # self, - # self.native.get_preferred_width(), - # self.native.get_preferred_height(), - # ) - width = self.native.get_preferred_width() - height = self.native.get_preferred_height() - - self.interface.intrinsic.width = at_least(width[0]) - self.interface.intrinsic.height = height[0] + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + # print( + # "REHINT", + # self, + # self.native.get_preferred_width(), + # self.native.get_preferred_height(), + # ) + width = self.native.get_preferred_width() + height = self.native.get_preferred_height() + + self.interface.intrinsic.width = at_least(width[0]) + self.interface.intrinsic.height = height[0] + else: # pragma: no-cover-if-gtk3 + pass diff --git a/gtk/src/toga_gtk/widgets/scrollcontainer.py b/gtk/src/toga_gtk/widgets/scrollcontainer.py index 33cebbb1fc..fe5a654e8f 100644 --- a/gtk/src/toga_gtk/widgets/scrollcontainer.py +++ b/gtk/src/toga_gtk/widgets/scrollcontainer.py @@ -1,7 +1,7 @@ from travertino.size import at_least from ..container import TogaContainer -from ..libs import Gtk +from ..libs import GTK_VERSION, Gtk from .base import Widget @@ -21,7 +21,10 @@ def create(self): self.native.set_overlay_scrolling(True) self.document_container = TogaContainer() - self.native.add(self.document_container) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.native.add(self.document_container) + else: # pragma: no-cover-if-gtk3 + pass def gtk_on_changed(self, *args): self.interface.on_scroll() @@ -29,8 +32,11 @@ def gtk_on_changed(self, *args): def set_content(self, widget): self.document_container.content = widget - # Force the display of the new content - self.native.show_all() + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + # Force the display of the new content + self.native.show_all() + else: # pragma: no-cover-if-gtk3 + pass def set_app(self, app): self.interface.content.app = app @@ -39,8 +45,11 @@ def set_window(self, window): self.interface.content.window = window def rehint(self): - self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) + else: # pragma: no-cover-if-gtk3 + pass def get_horizontal(self): return self.native.get_policy()[0] == Gtk.PolicyType.AUTOMATIC diff --git a/gtk/src/toga_gtk/widgets/selection.py b/gtk/src/toga_gtk/widgets/selection.py index 7ff6ab2330..20d00c3b75 100644 --- a/gtk/src/toga_gtk/widgets/selection.py +++ b/gtk/src/toga_gtk/widgets/selection.py @@ -2,15 +2,18 @@ from travertino.size import at_least -from ..libs import Gtk +from ..libs import GTK_VERSION, Gtk from .base import Widget class Selection(Widget): def create(self): - self.native = Gtk.ComboBoxText.new() - self.native.connect("changed", self.gtk_on_changed) - self._send_notifications = True + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.native = Gtk.ComboBoxText.new() + self.native.connect("changed", self.gtk_on_changed) + self._send_notifications = True + else: # pragma: no-cover-if-gtk3 + self.native = Gtk.DropDown() @contextmanager def suspend_notifications(self): @@ -57,12 +60,15 @@ def change(self, item): self.interface.refresh() def insert(self, index, item): - with self.suspend_notifications(): - self.native.insert_text(index, self.interface._title_for_item(item)) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + with self.suspend_notifications(): + self.native.insert_text(index, self.interface._title_for_item(item)) - # If you're inserting the first item, make sure it's selected - if self.native.get_active() == -1: - self.native.set_active(0) + # If you're inserting the first item, make sure it's selected + if self.native.get_active() == -1: + self.native.set_active(0) + else: # pragma: no-cover-if-gtk3 + pass def remove(self, index, item): selection = self.native.get_active() @@ -75,9 +81,12 @@ def remove(self, index, item): self.native.set_active(0) def clear(self): - with self.suspend_notifications(): - self.native.remove_all() - self.interface.on_change() + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + with self.suspend_notifications(): + self.native.remove_all() + self.interface.on_change() + else: # pragma: no-cover-if-gtk3 + pass def select_item(self, index, item): self.native.set_active(index) @@ -89,17 +98,20 @@ def get_selected_index(self): return index def rehint(self): - width = self.native.get_preferred_width() - height = self.native.get_preferred_height() - - # FIXME: 2023-05-31 This will always provide a size that is big enough, - # but sometimes it will be *too* big. For example, if you set the font size - # large, then reduce it again, the widget *could* reduce in size. However, - # I can't find any way to prod GTK to perform a resize that will reduce - # it's minimum size. This is the reason the test probe has a `shrink_on_resize` - # property; if we can fix this resize issue, `shrink_on_resize` may not - # be necessary. - self.interface.intrinsic.width = at_least( - max(self.interface._MIN_WIDTH, width[1]) - ) - self.interface.intrinsic.height = height[1] + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + width = self.native.get_preferred_width() + height = self.native.get_preferred_height() + + # FIXME: 2023-05-31 This will always provide a size that is big enough, + # but sometimes it will be *too* big. For example, if you set the font size + # large, then reduce it again, the widget *could* reduce in size. However, + # I can't find any way to prod GTK to perform a resize that will reduce + # it's minimum size. This is the reason the test probe has a + # `shrink_on_resize` property; if we can fix this resize issue, + # `shrink_on_resize` may not be necessary. + self.interface.intrinsic.width = at_least( + max(self.interface._MIN_WIDTH, width[1]) + ) + self.interface.intrinsic.height = height[1] + else: # pragma: no-cover-if-gtk3 + pass diff --git a/gtk/src/toga_gtk/widgets/slider.py b/gtk/src/toga_gtk/widgets/slider.py index 33e78b5037..efc0ff82fc 100644 --- a/gtk/src/toga_gtk/widgets/slider.py +++ b/gtk/src/toga_gtk/widgets/slider.py @@ -2,7 +2,7 @@ from toga.widgets.slider import SliderImpl -from ..libs import Gtk +from ..libs import GTK_VERSION, Gtk from .base import Widget # Implementation notes @@ -21,25 +21,30 @@ def create(self): self.adj = Gtk.Adjustment() self.native = Gtk.Scale.new(Gtk.Orientation.HORIZONTAL, self.adj) - self.native.connect( - "value-changed", - lambda native: self.interface.on_change(), - ) - self.native.connect( - "button-press-event", - lambda native, event: self.interface.on_press(), - ) - self.native.connect( - "button-release-event", - lambda native, event: self.interface.on_release(), - ) - - # Despite what the set_digits documentation says, set_round_digits has no effect - # when set_draw_value is False, so we have to round the value manually. Disable - # automatic rounding anyway, in case this changes in the future. - self.native.set_round_digits(-1) - self.native.set_draw_value(False) - self.native.connect("change-value", self.gtk_change_value) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.native.connect( + "value-changed", + lambda native: self.interface.on_change(), + ) + self.native.connect( + "button-press-event", + lambda native, event: self.interface.on_press(), + ) + self.native.connect( + "button-release-event", + lambda native, event: self.interface.on_release(), + ) + + # Despite what the set_digits documentation says, set_round_digits has no + # effect when set_draw_value is False, so we have to round the value + # manually. Disable automatic rounding anyway, in case this changes in the + # future. + self.native.set_round_digits(-1) + self.native.set_draw_value(False) + self.native.connect("change-value", self.gtk_change_value) + + else: # pragma: no-cover-if-gtk3 + pass # Dummy values used during initialization. self.tick_count = None @@ -82,15 +87,18 @@ def get_tick_count(self): return self.tick_count def rehint(self): - # print( - # "REHINT", - # self, - # self.native.get_preferred_width(), - # self.native.get_preferred_height(), - # ) - height = self.native.get_preferred_height() - - # Set intrinsic width to at least the minimum width - self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) - # Set intrinsic height to the natural height - self.interface.intrinsic.height = height[1] + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + # print( + # "REHINT", + # self, + # self.native.get_preferred_width(), + # self.native.get_preferred_height(), + # ) + height = self.native.get_preferred_height() + + # Set intrinsic width to at least the minimum width + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + # Set intrinsic height to the natural height + self.interface.intrinsic.height = height[1] + else: # pragma: no-cover-if-gtk3 + pass diff --git a/gtk/src/toga_gtk/widgets/splitcontainer.py b/gtk/src/toga_gtk/widgets/splitcontainer.py index fc9b2c5314..aa490d2e12 100644 --- a/gtk/src/toga_gtk/widgets/splitcontainer.py +++ b/gtk/src/toga_gtk/widgets/splitcontainer.py @@ -1,7 +1,7 @@ from travertino.size import at_least from ..container import TogaContainer -from ..libs import Gtk +from ..libs import GTK_VERSION, Gtk from .base import Widget @@ -11,8 +11,12 @@ def create(self): self.native.set_wide_handle(True) self.sub_containers = [TogaContainer(), TogaContainer()] - self.native.pack1(self.sub_containers[0], True, False) - self.native.pack2(self.sub_containers[1], True, False) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.native.pack1(self.sub_containers[0], True, False) + self.native.pack2(self.sub_containers[1], True, False) + else: # pragma: no-cover-if-gtk3 + self.native.set_start_child(self.sub_containers[0]) + self.native.set_end_child(self.sub_containers[1]) self._split_proportion = 0.5 @@ -97,5 +101,8 @@ def rehint(self): min_width = max(min_width, self.interface._MIN_WIDTH) + SPLITTER_WIDTH - self.interface.intrinsic.width = at_least(min_width) - self.interface.intrinsic.height = at_least(min_height) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.interface.intrinsic.width = at_least(min_width) + self.interface.intrinsic.height = at_least(min_height) + else: # pragma: no-cover-if-gtk3 + pass diff --git a/gtk/src/toga_gtk/widgets/switch.py b/gtk/src/toga_gtk/widgets/switch.py index 6518f4ae1d..6b1adf75cd 100644 --- a/gtk/src/toga_gtk/widgets/switch.py +++ b/gtk/src/toga_gtk/widgets/switch.py @@ -1,6 +1,6 @@ from travertino.size import at_least -from ..libs import Gtk, get_color_css, get_font_css +from ..libs import GTK_VERSION, Gtk, get_color_css, get_font_css from .base import Widget @@ -11,17 +11,23 @@ def create(self): self.native = Gtk.Box(spacing=self.SPACING) self.native_label = Gtk.Label(xalign=0) - self.native_label.set_name(f"toga-{self.interface.id}-label") - self.native_label.get_style_context().add_class("toga") - self.native_label.set_line_wrap(False) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.native_label.set_name(f"toga-{self.interface.id}-label") + self.native_label.get_style_context().add_class("toga") + self.native_label.set_line_wrap(False) + else: # pragma: no-cover-if-gtk3 + pass self.native_switch = Gtk.Switch() - self.native_switch.set_name(f"toga-{self.interface.id}-switch") - self.native_switch.get_style_context().add_class("toga") - self.native_switch.connect("notify::active", self.gtk_notify_active) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.native_switch.set_name(f"toga-{self.interface.id}-switch") + self.native_switch.get_style_context().add_class("toga") + self.native_switch.connect("notify::active", self.gtk_notify_active) - self.native.pack_start(self.native_label, True, True, 0) - self.native.pack_start(self.native_switch, False, False, 0) + self.native.pack_start(self.native_label, True, True, 0) + self.native.pack_start(self.native_switch, False, False, 0) + else: # pragma: no-cover-if-gtk3 + pass def gtk_notify_active(self, widget, state): self.interface.on_change() @@ -52,21 +58,24 @@ def set_font(self, font): self.apply_css("font", get_font_css(font), native=self.native_label) def rehint(self): - # print( - # "REHINT", - # self, - # self.native.get_preferred_width(), - # self.native.get_preferred_height(), - # ) - label_width = self.native_label.get_preferred_width() - label_height = self.native_label.get_preferred_height() - - switch_width = self.native_switch.get_preferred_width() - switch_height = self.native_switch.get_preferred_height() - - # Set intrinsic width to at least the minimum width - self.interface.intrinsic.width = at_least( - label_width[0] + self.SPACING + switch_width[0] - ) - # Set intrinsic height to the natural height - self.interface.intrinsic.height = max(label_height[1], switch_height[1]) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + # print( + # "REHINT", + # self, + # self.native.get_preferred_width(), + # self.native.get_preferred_height(), + # ) + label_width = self.native_label.get_preferred_width() + label_height = self.native_label.get_preferred_height() + + switch_width = self.native_switch.get_preferred_width() + switch_height = self.native_switch.get_preferred_height() + + # Set intrinsic width to at least the minimum width + self.interface.intrinsic.width = at_least( + label_width[0] + self.SPACING + switch_width[0] + ) + # Set intrinsic height to the natural height + self.interface.intrinsic.height = max(label_height[1], switch_height[1]) + else: # pragma: no-cover-if-gtk3 + pass diff --git a/gtk/src/toga_gtk/widgets/table.py b/gtk/src/toga_gtk/widgets/table.py index b50b2ac95d..a49c4f6de7 100644 --- a/gtk/src/toga_gtk/widgets/table.py +++ b/gtk/src/toga_gtk/widgets/table.py @@ -4,7 +4,7 @@ import toga -from ..libs import GdkPixbuf, GObject, Gtk +from ..libs import GTK_VERSION, GdkPixbuf, GObject, Gtk from .base import Widget @@ -45,23 +45,29 @@ def create(self): self.store = None # Create a tree view, and put it in a scroll view. # The scroll view is the native, because it's the outer container. - self.native_table = Gtk.TreeView(model=self.store) - self.native_table.connect("row-activated", self.gtk_on_row_activated) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.native_table = Gtk.TreeView(model=self.store) + self.native_table.connect("row-activated", self.gtk_on_row_activated) - self.selection = self.native_table.get_selection() - if self.interface.multiple_select: - self.selection.set_mode(Gtk.SelectionMode.MULTIPLE) - else: - self.selection.set_mode(Gtk.SelectionMode.SINGLE) - self.selection.connect("changed", self.gtk_on_select) + self.selection = self.native_table.get_selection() + if self.interface.multiple_select: + self.selection.set_mode(Gtk.SelectionMode.MULTIPLE) + else: + self.selection.set_mode(Gtk.SelectionMode.SINGLE) + self.selection.connect("changed", self.gtk_on_select) - self._create_columns() + self._create_columns() + else: # pragma: no-cover-if-gtk3 + pass self.native = Gtk.ScrolledWindow() - self.native.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - self.native.add(self.native_table) - self.native.set_min_content_width(200) - self.native.set_min_content_height(200) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.native.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + self.native.add(self.native_table) + self.native.set_min_content_width(200) + self.native.set_min_content_height(200) + else: # pragma: no-cover-if-gtk3 + pass def _create_columns(self): if self.interface.headings: @@ -96,24 +102,27 @@ def gtk_on_select(self, selection): self.interface.on_select() def change_source(self, source): - # Temporarily disconnecting the TreeStore improves performance for large - # updates by deferring row rendering until the update is complete. - self.native_table.set_model(None) - - for column in self.native_table.get_columns(): - self.native_table.remove_column(column) - self._create_columns() - - types = [TogaRow] - for accessor in self.interface._accessors: - types.extend([GdkPixbuf.Pixbuf, str]) - self.store = Gtk.ListStore(*types) - - for i, row in enumerate(self.interface.data): - self.insert(i, row) - - self.native_table.set_model(self.store) - self.refresh() + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + # Temporarily disconnecting the TreeStore improves performance for large + # updates by deferring row rendering until the update is complete. + self.native_table.set_model(None) + + for column in self.native_table.get_columns(): + self.native_table.remove_column(column) + self._create_columns() + + types = [TogaRow] + for accessor in self.interface._accessors: + types.extend([GdkPixbuf.Pixbuf, str]) + self.store = Gtk.ListStore(*types) + + for i, row in enumerate(self.interface.data): + self.insert(i, row) + + self.native_table.set_model(self.store) + self.refresh() + else: # pragma: no-cover-if-gtk3 + pass def insert(self, index, item): row = TogaRow(item) diff --git a/gtk/src/toga_gtk/widgets/textinput.py b/gtk/src/toga_gtk/widgets/textinput.py index 141ec5de54..cb87cb97c9 100644 --- a/gtk/src/toga_gtk/widgets/textinput.py +++ b/gtk/src/toga_gtk/widgets/textinput.py @@ -3,29 +3,39 @@ from toga.keys import Key from toga_gtk.keys import toga_key -from ..libs import Gtk, gtk_text_align +from ..libs import GTK_VERSION, Gtk, gtk_text_align from .base import Widget class TextInput(Widget): def create(self): self.native = Gtk.Entry() - self.native.connect("changed", self.gtk_on_change) - self.native.connect("focus-in-event", self.gtk_focus_in_event) - self.native.connect("focus-out-event", self.gtk_focus_out_event) - self.native.connect("key-press-event", self.gtk_key_press_event) - def gtk_on_change(self, entry): - self.interface._value_changed() - - def gtk_focus_in_event(self, entry, user_data): - self.interface.on_gain_focus() - - def gtk_focus_out_event(self, entry, user_data): + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.native.connect("changed", self.gtk_on_change) + self.native.connect("focus-in-event", self.gtk_focus_in_event) + self.native.connect("focus-out-event", self.gtk_focus_out_event) + self.native.connect("key-press-event", self.gtk_key_press_event) + else: # pragma: no-cover-if-gtk3 + pass + + def gtk_on_change(self, *_args): + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.interface._value_changed() + else: # pragma: no-cover-if-gtk3 + self.interface._value_changed(self.interface) + + def gtk_focus_in_event(self, *_args): + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.interface.on_gain_focus() + else: # pragma: no-cover-if-gtk3 + self.interface.on_gain_focus(self.interface) + + def gtk_focus_out_event(self, *_args): self.interface.on_lose_focus() - def gtk_key_press_event(self, entry, user_data): - key_pressed = toga_key(user_data) + def gtk_key_press_event(self, _, key_val, *_args): + key_pressed = toga_key(key_val) if key_pressed and key_pressed["key"] in {Key.ENTER, Key.NUMPAD_ENTER}: self.interface.on_confirm() @@ -54,21 +64,24 @@ def set_value(self, value): self.native.set_text(value) def rehint(self): - # print( - # "REHINT", - # self, - # self._impl.get_preferred_width(), - # self._impl.get_preferred_height(), - # getattr(self, "_fixed_height", False), - # getattr(self, "_fixed_width", False), - # ) - width = self.native.get_preferred_width() - height = self.native.get_preferred_height() - - self.interface.intrinsic.width = at_least( - max(self.interface._MIN_WIDTH, width[1]) - ) - self.interface.intrinsic.height = height[1] + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + # print( + # "REHINT", + # self, + # self._impl.get_preferred_width(), + # self._impl.get_preferred_height(), + # getattr(self, "_fixed_height", False), + # getattr(self, "_fixed_width", False), + # ) + width = self.native.get_preferred_width() + height = self.native.get_preferred_height() + + self.interface.intrinsic.width = at_least( + max(self.interface._MIN_WIDTH, width[1]) + ) + self.interface.intrinsic.height = height[1] + else: # pragma: no-cover-if-gtk3 + pass def set_error(self, error_message): self.native.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, "error") diff --git a/gtk/src/toga_gtk/widgets/tree.py b/gtk/src/toga_gtk/widgets/tree.py index 9a4a645dcd..7fba18ae69 100644 --- a/gtk/src/toga_gtk/widgets/tree.py +++ b/gtk/src/toga_gtk/widgets/tree.py @@ -1,6 +1,6 @@ from travertino.size import at_least -from ..libs import GdkPixbuf, Gtk +from ..libs import GTK_VERSION, GdkPixbuf, Gtk from .base import Widget from .table import TogaRow @@ -14,20 +14,26 @@ def create(self): self.native_tree = Gtk.TreeView(model=self.store) self.native_tree.connect("row-activated", self.gtk_on_row_activated) - self.selection = self.native_tree.get_selection() - if self.interface.multiple_select: - self.selection.set_mode(Gtk.SelectionMode.MULTIPLE) - else: - self.selection.set_mode(Gtk.SelectionMode.SINGLE) - self.selection.connect("changed", self.gtk_on_select) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.selection = self.native_tree.get_selection() + if self.interface.multiple_select: + self.selection.set_mode(Gtk.SelectionMode.MULTIPLE) + else: + self.selection.set_mode(Gtk.SelectionMode.SINGLE) + self.selection.connect("changed", self.gtk_on_select) - self._create_columns() + self._create_columns() + else: # pragma: no-cover-if-gtk3 + pass self.native = Gtk.ScrolledWindow() - self.native.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - self.native.add(self.native_tree) - self.native.set_min_content_width(200) - self.native.set_min_content_height(200) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.native.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + self.native.add(self.native_tree) + self.native.set_min_content_width(200) + self.native.set_min_content_height(200) + else: # pragma: no-cover-if-gtk3 + pass def _create_columns(self): if self.interface.headings: @@ -62,24 +68,27 @@ def gtk_on_row_activated(self, widget, path, column): self.interface.on_activate(node=node) def change_source(self, source): - # Temporarily disconnecting the TreeStore improves performance for large - # updates by deferring row rendering until the update is complete. - self.native_tree.set_model(None) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + # Temporarily disconnecting the TreeStore improves performance for large + # updates by deferring row rendering until the update is complete. + self.native_tree.set_model(None) - for column in self.native_tree.get_columns(): - self.native_tree.remove_column(column) - self._create_columns() + for column in self.native_tree.get_columns(): + self.native_tree.remove_column(column) + self._create_columns() - types = [TogaRow] - for accessor in self.interface._accessors: - types.extend([GdkPixbuf.Pixbuf, str]) - self.store = Gtk.TreeStore(*types) + types = [TogaRow] + for accessor in self.interface._accessors: + types.extend([GdkPixbuf.Pixbuf, str]) + self.store = Gtk.TreeStore(*types) - for i, row in enumerate(self.interface.data): - self.insert(None, i, row) + for i, row in enumerate(self.interface.data): + self.insert(None, i, row) - self.native_tree.set_model(self.store) - self.refresh() + self.native_tree.set_model(self.store) + self.refresh() + else: # pragma: no-cover-if-gtk3 + pass def insert(self, parent, index, item): row = TogaRow(item) @@ -147,5 +156,8 @@ def remove_column(self, accessor): self.change_source(self.interface.data) def rehint(self): - self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) + else: # pragma: no-cover-if-gtk3 + pass diff --git a/gtk/src/toga_gtk/widgets/webview.py b/gtk/src/toga_gtk/widgets/webview.py index 94c066621e..6e135795d5 100644 --- a/gtk/src/toga_gtk/widgets/webview.py +++ b/gtk/src/toga_gtk/widgets/webview.py @@ -4,7 +4,7 @@ from toga.widgets.webview import CookiesResult, JavaScriptResult -from ..libs import GLib, WebKit2 +from ..libs import GTK_VERSION, GLib, WebKit2 from .base import Widget @@ -12,6 +12,9 @@ class WebView(Widget): """GTK WebView implementation.""" def create(self): + if GTK_VERSION >= (4, 0, 0): # pragma: no-cover-if-gtk3 + raise RuntimeError("WebView isn't supported on GTK4 (yet!)") + if WebKit2 is None: # pragma: no cover raise RuntimeError( "Unable to import WebKit2. Ensure that the system package providing " @@ -125,5 +128,8 @@ def gtk_js_finished(webview, task, *user_data): return result def rehint(self): - self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) + else: # pragma: no-cover-if-gtk3 + pass diff --git a/gtk/src/toga_gtk/window.py b/gtk/src/toga_gtk/window.py index 661adac207..6c72890f4c 100644 --- a/gtk/src/toga_gtk/window.py +++ b/gtk/src/toga_gtk/window.py @@ -9,7 +9,7 @@ from toga.window import _initial_position from .container import TogaContainer -from .libs import IS_WAYLAND, Gdk, GLib, Gtk +from .libs import GTK_VERSION, IS_WAYLAND, Gdk, GLib, Gtk from .screens import Screen as ScreenImpl if TYPE_CHECKING: # pragma: no cover @@ -26,15 +26,26 @@ def __init__(self, interface, title, position, size): self.create() self.native._impl = self - self._delete_handler = self.native.connect( - "delete-event", - self.gtk_delete_event, - ) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self._delete_handler = self.native.connect( + "delete-event", + self.gtk_delete_event, + ) + else: # pragma: no-cover-if-gtk3 + self._delete_handler = self.native.connect( + "close-request", self.gtk_delete_event + ) + self.native.connect("show", self.gtk_show) self.native.connect("hide", self.gtk_hide) - self.native.connect("window-state-event", self.gtk_window_state_event) - self.native.connect("focus-in-event", self.gtk_focus_in_event) - self.native.connect("focus-out-event", self.gtk_focus_out_event) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.native.connect("window-state-event", self.gtk_window_state_event) + self.native.connect("focus-in-event", self.gtk_focus_in_event) + self.native.connect("focus-out-event", self.gtk_focus_out_event) + else: # pragma: no-cover-if-gtk3 + self.native.connect("notify::fullscreened", self.gtk_window_state_event) + self.native.connect("notify::maximized", self.gtk_window_state_event) + self.native.connect("notify::minimized", self.gtk_window_state_event) self._window_state_flags = None self._in_presentation = False @@ -61,9 +72,14 @@ def __init__(self, interface, title, position, size): # Because expand and fill are True, the container will fill the available # space, and will get a size_allocate callback if the window is resized. self.container = TogaContainer() - self.layout.pack_end(self.container, expand=True, fill=True, padding=0) - - self.native.add(self.layout) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.layout.pack_end(self.container, expand=True, fill=True, padding=0) + self.native.add(self.layout) + else: # pragma: no-cover-if-gtk3 + self.container.set_valign(Gtk.Align.FILL) + self.container.set_vexpand(True) + self.layout.append(self.container) + self.native.set_child(self.layout) def create(self): self.native = Gtk.Window() @@ -78,26 +94,34 @@ def gtk_hide(self, widget): self.interface.on_hide() def gtk_window_state_event(self, widget, event): - previous_window_state_flags = self._window_state_flags - previous_state = self.get_window_state() - # Get the window state flags - self._window_state_flags = event.new_window_state + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + previous_window_state_flags = self._window_state_flags + previous_state = self.get_window_state() + # Get the window state flags + self._window_state_flags = event.new_window_state + else: # pragma: no-cover-if-gtk3 + previous_window_state_flags = None + previous_state = self.get_window_state() + self._window_state_flags = None current_state = self.get_window_state() # Window state flags are unreliable when window is hidden, # so cache the previous window state flag on to the new # window state flag, so that get_window_state() would work # correctly. - if not self.get_visible(): - restore_flags = { - Gdk.WindowState.MAXIMIZED, - Gdk.WindowState.ICONIFIED, - Gdk.WindowState.FULLSCREEN, - } - for flag in restore_flags: - if previous_window_state_flags & flag: - self._window_state_flags |= flag - break + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + if not self.get_visible(): + restore_flags = { + Gdk.WindowState.MAXIMIZED, + Gdk.WindowState.ICONIFIED, + Gdk.WindowState.FULLSCREEN, + } + for flag in restore_flags: + if previous_window_state_flags & flag: + self._window_state_flags |= flag + break + else: # pragma: no-cover-if-gtk3 + pass # Trigger the appropriate visibility events # Wayland doesn't allow for the detection of MINIMIZED, so the @@ -144,7 +168,7 @@ def gtk_window_state_event(self, widget, event): else: # pragma: no-cover-if-linux-wayland self._apply_state(self._pending_state_transition) - def gtk_delete_event(self, widget, data): + def gtk_delete_event(self, *_): # Return value of the GTK on_close handler indicates whether the event has been # fully handled. Returning True indicates the event has been handled, so further # handling (including actually closing the window) shouldn't be performed. This @@ -179,10 +203,16 @@ def close(self): def set_app(self, app): app.native.add_window(self.native) - self.native.set_icon(app.interface.icon._impl.native(72)) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.native.set_icon(app.interface.icon._impl.native(72)) + else: # pragma: no-cover-if-gtk3 + self.interface.factory.not_implemented("Window.set_app() icon") def show(self): - self.native.show_all() + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.native.show_all() + else: # pragma: no-cover-if-gtk3 + self.native.present() ###################################################################### # Window content and resources @@ -197,27 +227,48 @@ def set_content(self, widget): ###################################################################### def get_size(self) -> Size: - size = self.native.get_size() - return Size(size.width, size.height) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + width, height = self.native.get_default_size() + size = self.native.get_size() + return Size(size.width, size.height) + else: # pragma: no-cover-if-gtk3 + width, height = self.native.get_default_size() + return Size(width, height) def set_size(self, size: SizeT): - self.native.resize(size[0], size[1]) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.native.resize(size[0], size[1]) + else: # pragma: no-cover-if-gtk3 + self.native.set_default_size(size[0], size[1]) ###################################################################### # Window position ###################################################################### def get_current_screen(self): - display = Gdk.Display.get_default() - monitor_native = display.get_monitor_at_window(self.native.get_window()) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + display = Gdk.Display.get_default() + monitor_native = display.get_monitor_at_window(self.native.get_window()) + else: # pragma: no-cover-if-gtk3 + monitor_native = self.native.props.display return ScreenImpl(monitor_native) def get_position(self) -> Position: - pos = self.native.get_position() - return Position(pos.root_x, pos.root_y) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + pos = self.native.get_position() + return Position(pos.root_x, pos.root_y) + else: # pragma: no-cover-if-gtk3 + # GTK4 no longer has an API to get position + # since it isn't supported by Wayland + return Position(0, 0) def set_position(self, position: PositionT): - self.native.move(position[0], position[1]) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.native.move(position[0], position[1]) + else: # pragma: no-cover-if-gtk3 + # GTK4 no longer has an API to set position + # since it isn't supported by Wayland + pass ###################################################################### # Window visibility @@ -227,7 +278,10 @@ def get_visible(self): return self.native.get_property("visible") def hide(self): - self.native.hide() + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.native.hide() + else: # pragma: no-cover-if-gtk3 + self.native.set_visible(False) ###################################################################### # Window state @@ -236,18 +290,35 @@ def hide(self): def get_window_state(self, in_progress_state=False): if in_progress_state and self._pending_state_transition: return self._pending_state_transition - window_state_flags = self._window_state_flags - if window_state_flags: # pragma: no branch - if window_state_flags & Gdk.WindowState.MAXIMIZED: + + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + if not self._window_state_flags: + return WindowState.NORMAL + + flags = self._window_state_flags + if flags & Gdk.WindowState.MAXIMIZED: return WindowState.MAXIMIZED - elif window_state_flags & Gdk.WindowState.ICONIFIED: + elif flags & Gdk.WindowState.ICONIFIED: return WindowState.MINIMIZED # pragma: no-cover-if-linux-wayland - elif window_state_flags & Gdk.WindowState.FULLSCREEN: + elif flags & Gdk.WindowState.FULLSCREEN: + return ( + WindowState.PRESENTATION + if self._in_presentation + else WindowState.FULLSCREEN + ) + + else: # pragma: no-cover-if-gtk3 + if self.native.is_maximized(): + return WindowState.MAXIMIZED + elif GTK_VERSION >= (4, 12) and self.native.is_suspended(): + return WindowState.MINIMIZED + elif self.native.is_fullscreen(): return ( WindowState.PRESENTATION if self._in_presentation else WindowState.FULLSCREEN ) + return WindowState.NORMAL def set_window_state(self, state): @@ -293,7 +364,10 @@ def _apply_state(self, target_state): self.native.maximize() elif target_state == WindowState.MINIMIZED: # pragma: no-cover-if-linux-wayland - self.native.iconify() + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.native.iconify() + else: # pragma: no-cover-if-gtk3 + self.native.minimize() elif target_state == WindowState.FULLSCREEN: self.native.fullscreen() @@ -335,105 +409,115 @@ def _apply_state(self, target_state): ###################################################################### def get_image_data(self): - display = self.native.get_display() - display.flush() - - # For some reason, converting the *window* to a pixbuf fails. But if you extract - # a *part* of the overall screen, that works. So - work out the origin of the - # window, then the allocation for the container relative to that window, and - # capture that rectangle. - window = self.native.get_window() - origin = window.get_origin() - allocation = self.container.get_allocation() - - screen = display.get_default_screen() - root_window = screen.get_root_window() - screenshot = Gdk.pixbuf_get_from_window( - root_window, - origin.x + allocation.x, - origin.y + allocation.y, - allocation.width, - allocation.height, - ) - - success, buffer = screenshot.save_to_bufferv("png") - if success: - return buffer - else: # pragma: nocover - # This shouldn't ever happen, and it's difficult to manufacture - # in test conditions - raise ValueError(f"Unable to generate screenshot of {self}") + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + display = self.native.get_display() + display.flush() + + # For some reason, converting the *window* to a pixbuf fails. But if you + # extract a *part* of the overall screen, that works. So - work out the + # origin of the window, then the allocation for the container relative to + # that window, and capture that rectangle. + window = self.native.get_window() + origin = window.get_origin() + allocation = self.container.get_allocation() + + screen = display.get_default_screen() + root_window = screen.get_root_window() + screenshot = Gdk.pixbuf_get_from_window( + root_window, + origin.x + allocation.x, + origin.y + allocation.y, + allocation.width, + allocation.height, + ) + + success, buffer = screenshot.save_to_bufferv("png") + if success: + return buffer + else: # pragma: nocover + # This shouldn't ever happen, and it's difficult to manufacture + # in test conditions + raise ValueError(f"Unable to generate screenshot of {self}") + else: # pragma: no-cover-if-gtk3 + self.interface.factory.not_implemented("Window.get_image_data()") class MainWindow(Window): def create(self): self.native = Gtk.ApplicationWindow() - self.native.set_role("MainWindow") + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + self.native.set_role("MainWindow") - self.native_toolbar = Gtk.Toolbar() - self.native_toolbar.set_style(Gtk.ToolbarStyle.BOTH) - self.toolbar_items = {} - self.toolbar_separators = set() + self.native_toolbar = Gtk.Toolbar() + self.native_toolbar.set_style(Gtk.ToolbarStyle.BOTH) + self.toolbar_items = {} + self.toolbar_separators = set() + else: # pragma: no-cover-if-gtk3 + pass def create_menus(self): # GTK menus are handled at the app level pass def create_toolbar(self): - # If there's an existing toolbar, hide it until we know we need it. - self.layout.remove(self.native_toolbar) - - # Deregister any toolbar buttons from their commands, and remove them - # from the toolbar - for cmd, item_impl in self.toolbar_items.items(): - self.native_toolbar.remove(item_impl) - cmd._impl.native.remove(item_impl) - - # Remove any toolbar separators - for sep in self.toolbar_separators: - self.native_toolbar.remove(sep) - - # Create the new toolbar items - self.toolbar_items = {} - self.toolbar_separators = set() - prev_group = None - for cmd in self.interface.toolbar: - if isinstance(cmd, Separator): - item_impl = Gtk.SeparatorToolItem() - item_impl.set_draw(False) - self.toolbar_separators.add(item_impl) - prev_group = None - else: - # A change in group requires adding a toolbar separator - if prev_group is not None and prev_group != cmd.group: - group_sep = Gtk.SeparatorToolItem() - group_sep.set_draw(True) - self.toolbar_separators.add(group_sep) - self.native_toolbar.insert(group_sep, -1) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + # If there's an existing toolbar, hide it until we know we need it. + self.layout.remove(self.native_toolbar) + + # Deregister any toolbar buttons from their commands, and remove them + # from the toolbar + for cmd, item_impl in self.toolbar_items.items(): + self.native_toolbar.remove(item_impl) + cmd._impl.native.remove(item_impl) + + # Remove any toolbar separators + for sep in self.toolbar_separators: + self.native_toolbar.remove(sep) + + # Create the new toolbar items + self.toolbar_items = {} + self.toolbar_separators = set() + prev_group = None + for cmd in self.interface.toolbar: + if isinstance(cmd, Separator): + item_impl = Gtk.SeparatorToolItem() + item_impl.set_draw(False) + self.toolbar_separators.add(item_impl) prev_group = None else: - prev_group = cmd.group - - item_impl = Gtk.ToolButton() - if cmd.icon: - item_impl.set_icon_widget( - Gtk.Image.new_from_pixbuf(cmd.icon._impl.native(32)) - ) - item_impl.set_label(cmd.text) - if cmd.tooltip: - item_impl.set_tooltip_text(cmd.tooltip) - item_impl.connect("clicked", cmd._impl.gtk_clicked) - cmd._impl.native.append(item_impl) - self.toolbar_items[cmd] = item_impl - - self.native_toolbar.insert(item_impl, -1) - - if self.toolbar_items: - # We have toolbar items; add the toolbar to the top of the layout. - self.layout.pack_start( - self.native_toolbar, - expand=False, - fill=False, - padding=0, - ) - self.native_toolbar.show_all() + # A change in group requires adding a toolbar separator + if prev_group is not None and prev_group != cmd.group: + group_sep = Gtk.SeparatorToolItem() + group_sep.set_draw(True) + self.toolbar_separators.add(group_sep) + self.native_toolbar.insert(group_sep, -1) + prev_group = None + else: + prev_group = cmd.group + + item_impl = Gtk.ToolButton() + if cmd.icon: + item_impl.set_icon_widget( + Gtk.Image.new_from_pixbuf(cmd.icon._impl.native(32)) + ) + item_impl.set_label(cmd.text) + if cmd.tooltip: + item_impl.set_tooltip_text(cmd.tooltip) + item_impl.connect("clicked", cmd._impl.gtk_clicked) + cmd._impl.native.append(item_impl) + self.toolbar_items[cmd] = item_impl + + self.native_toolbar.insert(item_impl, -1) + + if self.toolbar_items: + # We have toolbar items; add the toolbar to the top of the layout. + self.layout.pack_start( + self.native_toolbar, + expand=False, + fill=False, + padding=0, + ) + self.native_toolbar.show_all() + else: # pragma: no-cover-if-gtk3 + # TODO: Implement toolbar commands in HeaderBar with #1931 + pass diff --git a/gtk/tests_backend/app.py b/gtk/tests_backend/app.py index abfbf34578..ba0a3670fc 100644 --- a/gtk/tests_backend/app.py +++ b/gtk/tests_backend/app.py @@ -6,7 +6,7 @@ import toga from toga_gtk.keys import gtk_accel, toga_key -from toga_gtk.libs import IS_WAYLAND, Gdk, Gtk +from toga_gtk.libs import GTK_VERSION, IS_WAYLAND, Gdk, Gtk from .dialogs import DialogsMixin from .probe import BaseProbe @@ -16,9 +16,7 @@ class AppProbe(BaseProbe, DialogsMixin): supports_key = True supports_key_mod3 = True # Gtk 3.24.41 ships with Ubuntu 24.04 where present() works on Wayland - supports_current_window_assignment = not ( - IS_WAYLAND and BaseProbe.GTK_VERSION < (3, 24, 41) - ) + supports_current_window_assignment = not (IS_WAYLAND and GTK_VERSION < (3, 24, 41)) def __init__(self, app): super().__init__() @@ -47,6 +45,8 @@ def is_cursor_visible(self): pytest.skip("Cursor visibility not implemented on GTK") def assert_app_icon(self, icon): + if GTK_VERSION >= (4, 0, 0): + pytest.skip("Checking app icon not implemented in GTK4") for window in self.app.windows: # We have no real way to check we've got the right icon; use pixel peeping # as a guess. Construct a PIL image from the current icon. @@ -65,6 +65,8 @@ def assert_app_icon(self, icon): assert mid_color == (149, 119, 73, 255) def assert_dialog_in_focus(self, dialog): + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support dialogs") # Gtk.Dialog's methods - is_active(), has_focus() both return False, even # when the dialog is in focus. Hence, they cannot be used to determine focus. assert dialog._impl.native.is_visible(), "The dialog is not in focus" @@ -112,16 +114,24 @@ def _menu_item(self, path): return item, action def _activate_menu_item(self, path): + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support system menus") _, action = self._menu_item(path) action.emit("activate", None) def activate_menu_exit(self): + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support system menus") self._activate_menu_item(["*", "Quit"]) def activate_menu_about(self): + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support system menus") self._activate_menu_item(["Help", "About Toga Testbed"]) async def close_about_dialog(self): + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support system menus") self.app._impl._close_about(self.app._impl.native_about_dialog) def activate_menu_visit_homepage(self): @@ -129,6 +139,8 @@ def activate_menu_visit_homepage(self): pytest.xfail("GTK doesn't have a visit homepage menu item") def assert_system_menus(self): + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support system menus") self.assert_menu_item(["*", "Preferences"], enabled=False) self.assert_menu_item(["*", "Quit"], enabled=True) @@ -152,10 +164,14 @@ def activate_menu_minimize(self): pytest.xfail("GTK doesn't have a window management menu items") def assert_menu_item(self, path, enabled): + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support menu items") _, action = self._menu_item(path) assert action.get_enabled() == enabled def assert_menu_order(self, path, expected): + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support menu items") item, action = self._menu_item(path) menu = item[0].get_item_link(item[1], "submenu") @@ -181,6 +197,8 @@ def assert_menu_order(self, path, expected): assert actual == expected def keystroke(self, combination): + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support keystroke") accel = gtk_accel(combination) state = 0 @@ -226,9 +244,13 @@ def open_document_by_drag(self, document_path): pytest.xfail("GTK doesn't support opening documents by drag") def has_status_icon(self, status_icon): + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support status icons") return status_icon._impl.native is not None def status_menu_items(self, status_icon): + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support status menu items") menu = status_icon._impl.native.get_primary_menu() if menu: return [ @@ -244,9 +266,13 @@ def status_menu_items(self, status_icon): return None def activate_status_icon_button(self, item_id): + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support status icons") self.app.status_icons[item_id]._impl.native.emit("activate", 0, 0) def activate_status_menu_item(self, item_id, title): + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support status menu items") menu = self.app.status_icons[item_id]._impl.native.get_primary_menu() item = {child.get_label(): child for child in menu.get_children()}[title] diff --git a/gtk/tests_backend/dialogs.py b/gtk/tests_backend/dialogs.py index 3d90501dde..8dee248b8b 100644 --- a/gtk/tests_backend/dialogs.py +++ b/gtk/tests_backend/dialogs.py @@ -3,7 +3,9 @@ from pathlib import Path from unittest.mock import Mock -from toga_gtk.libs import Gtk +import pytest + +from toga_gtk.libs import GTK_VERSION, Gtk class DialogsMixin: @@ -25,6 +27,9 @@ def _default_close_handler(self, dialog, gtk_result): def _setup_dialog_result( self, dialog, gtk_result, close_handler=None, pre_close_test_method=None ): + if GTK_VERSION >= (4, 0, 0): + pytest.xfail("Setting up Dialogs not yet supported on GTK4") + # Install an overridden show method that invokes the original, # but then closes the open dialog. orig_show = dialog._impl.show @@ -191,4 +196,6 @@ def close_handler(dialog, gtk_result): ) def is_modal_dialog(self, dialog): + if GTK_VERSION >= (4, 0, 0): + pytest.xfail("Getting the modal of a dialog is not yet supported on GTK4") return dialog._impl.native.get_modal() diff --git a/gtk/tests_backend/icons.py b/gtk/tests_backend/icons.py index cda812b23c..4c559c9a72 100644 --- a/gtk/tests_backend/icons.py +++ b/gtk/tests_backend/icons.py @@ -5,7 +5,7 @@ import toga import toga_gtk -from toga_gtk.libs import GdkPixbuf +from toga_gtk.libs import GTK_VERSION, GdkPixbuf from .probe import BaseProbe @@ -13,6 +13,9 @@ class IconProbe(BaseProbe): alternate_resource = "resources/icons/orange" + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support icons yet") + def __init__(self, app, icon): super().__init__() self.app = app diff --git a/gtk/tests_backend/probe.py b/gtk/tests_backend/probe.py index d43bc875a2..ef5b0c49a5 100644 --- a/gtk/tests_backend/probe.py +++ b/gtk/tests_backend/probe.py @@ -1,20 +1,25 @@ import asyncio import toga -from toga_gtk.libs import Gtk +from toga_gtk.libs import GTK_VERSION, GLib, Gtk class BaseProbe: - GTK_VERSION = Gtk.MAJOR_VERSION, Gtk.MINOR_VERSION, Gtk.MICRO_VERSION def repaint_needed(self): - return Gtk.events_pending() + if GTK_VERSION < (4, 0, 0): + return Gtk.events_pending() + else: + return GLib.main_context_default().pending() async def redraw(self, message=None, delay=0): """Request a redraw of the app, waiting until that redraw has completed.""" # Force a repaint while self.repaint_needed(): - Gtk.main_iteration_do(blocking=False) + if GTK_VERSION < (4, 0, 0): + Gtk.main_iteration_do(blocking=False) + else: + GLib.main_context_default().iteration(may_block=False) # If we're running slow, wait for a second if toga.App.app.run_slow: diff --git a/gtk/tests_backend/widgets/base.py b/gtk/tests_backend/widgets/base.py index 2070762bae..0988c75c84 100644 --- a/gtk/tests_backend/widgets/base.py +++ b/gtk/tests_backend/widgets/base.py @@ -3,7 +3,7 @@ import pytest -from toga_gtk.libs import Gdk, Gtk +from toga_gtk.libs import GTK_VERSION, Gdk, Gtk from ..fonts import FontMixin from ..probe import BaseProbe @@ -22,6 +22,9 @@ def __init__(self, widget): # Set the target for keypress events self._keypress_target = self.native + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 only has minimal container support") + # Ensure that the theme isn't using animations for the widget. settings = Gtk.Settings.get_for_screen(self.native.get_screen()) settings.set_property("gtk-enable-animations", False) diff --git a/gtk/tests_backend/widgets/canvas.py b/gtk/tests_backend/widgets/canvas.py index fd6f639201..31feff9232 100644 --- a/gtk/tests_backend/widgets/canvas.py +++ b/gtk/tests_backend/widgets/canvas.py @@ -1,8 +1,9 @@ from io import BytesIO +import pytest from PIL import Image -from toga_gtk.libs import IS_WAYLAND, Gdk, Gtk +from toga_gtk.libs import GTK_VERSION, IS_WAYLAND, Gdk, Gtk from .base import SimpleProbe @@ -10,6 +11,9 @@ class CanvasProbe(SimpleProbe): native_class = Gtk.DrawingArea + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support a canvas yet") + def reference_variant(self, reference): if reference == "multiline_text": if IS_WAYLAND: diff --git a/gtk/tests_backend/widgets/detailedlist.py b/gtk/tests_backend/widgets/detailedlist.py index e1f11500e3..0bcf924a1f 100644 --- a/gtk/tests_backend/widgets/detailedlist.py +++ b/gtk/tests_backend/widgets/detailedlist.py @@ -1,7 +1,9 @@ import asyncio import html -from toga_gtk.libs import GLib, Gtk +import pytest + +from toga_gtk.libs import GTK_VERSION, GLib, Gtk from .base import SimpleProbe @@ -17,6 +19,9 @@ def __init__(self, widget): self.native_vadj = widget._impl.native_vadj assert isinstance(self.native_detailedlist, Gtk.ListBox) + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support a detailed list yet") + @property def row_count(self): return len(self.impl.store) diff --git a/gtk/tests_backend/widgets/divider.py b/gtk/tests_backend/widgets/divider.py index b8dbea842b..7738e9fe26 100644 --- a/gtk/tests_backend/widgets/divider.py +++ b/gtk/tests_backend/widgets/divider.py @@ -1,7 +1,12 @@ -from toga_gtk.libs import Gtk +import pytest + +from toga_gtk.libs import GTK_VERSION, Gtk from .base import SimpleProbe class DividerProbe(SimpleProbe): native_class = Gtk.Separator + + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support a divider yet") diff --git a/gtk/tests_backend/widgets/imageview.py b/gtk/tests_backend/widgets/imageview.py index cc0e3e1ad1..383ed2358a 100644 --- a/gtk/tests_backend/widgets/imageview.py +++ b/gtk/tests_backend/widgets/imageview.py @@ -1,4 +1,6 @@ -from toga_gtk.libs import Gtk +import pytest + +from toga_gtk.libs import GTK_VERSION, Gtk from .base import SimpleProbe @@ -6,6 +8,9 @@ class ImageViewProbe(SimpleProbe): native_class = Gtk.Image + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support image view yet") + @property def preserve_aspect_ratio(self): return self.impl._aspect_ratio is not None diff --git a/gtk/tests_backend/widgets/label.py b/gtk/tests_backend/widgets/label.py index 55b47914c6..49395c8609 100644 --- a/gtk/tests_backend/widgets/label.py +++ b/gtk/tests_backend/widgets/label.py @@ -1,10 +1,14 @@ -from toga_gtk.libs import Gtk +import pytest + +from toga_gtk.libs import GTK_VERSION, Gtk from .base import SimpleProbe from .properties import toga_x_text_align, toga_y_text_align class LabelProbe(SimpleProbe): + if GTK_VERSION >= (4, 0, 0): + pytest.xfail("Labels are not yet supported on GTK4") native_class = Gtk.Label @property diff --git a/gtk/tests_backend/widgets/mapview.py b/gtk/tests_backend/widgets/mapview.py index bc9b16904f..9d39e309e1 100644 --- a/gtk/tests_backend/widgets/mapview.py +++ b/gtk/tests_backend/widgets/mapview.py @@ -2,7 +2,7 @@ import pytest -from toga_gtk.libs import WebKit2 +from toga_gtk.libs import GTK_VERSION, WebKit2 from .base import SimpleProbe @@ -19,6 +19,9 @@ def region_eq(r1, r2): class MapViewProbe(SimpleProbe): native_class = WebKit2.WebView + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support map view yet") + @property def scale_height(self): return self.height diff --git a/gtk/tests_backend/widgets/multilinetextinput.py b/gtk/tests_backend/widgets/multilinetextinput.py index 0acf22fd42..e68321e2dd 100644 --- a/gtk/tests_backend/widgets/multilinetextinput.py +++ b/gtk/tests_backend/widgets/multilinetextinput.py @@ -1,6 +1,6 @@ import pytest -from toga_gtk.libs import Gtk +from toga_gtk.libs import GTK_VERSION, Gtk from .base import SimpleProbe from .properties import toga_color, toga_text_align_from_justification @@ -9,6 +9,9 @@ class MultilineTextInputProbe(SimpleProbe): native_class = Gtk.ScrolledWindow + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support multiline text input yet") + def __init__(self, widget): super().__init__(widget) self.native_textview = self.impl.native_textview diff --git a/gtk/tests_backend/widgets/numberinput.py b/gtk/tests_backend/widgets/numberinput.py index 5029d509d8..687d98bd1d 100644 --- a/gtk/tests_backend/widgets/numberinput.py +++ b/gtk/tests_backend/widgets/numberinput.py @@ -1,7 +1,7 @@ import pytest from toga.constants import JUSTIFY, LEFT -from toga_gtk.libs import Gtk +from toga_gtk.libs import GTK_VERSION, Gtk from .base import SimpleProbe from .properties import toga_x_text_align @@ -13,6 +13,9 @@ class NumberInputProbe(SimpleProbe): allows_empty_value = False allows_extra_digits = False + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support number input yet") + def clear_input(self): self.native.set_text("") diff --git a/gtk/tests_backend/widgets/optioncontainer.py b/gtk/tests_backend/widgets/optioncontainer.py index e410006057..c8748bece1 100644 --- a/gtk/tests_backend/widgets/optioncontainer.py +++ b/gtk/tests_backend/widgets/optioncontainer.py @@ -1,4 +1,6 @@ -from toga_gtk.libs import Gtk +import pytest + +from toga_gtk.libs import GTK_VERSION, Gtk from .base import SimpleProbe @@ -8,6 +10,9 @@ class OptionContainerProbe(SimpleProbe): max_tabs = None disabled_tab_selectable = False + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support option containers yet") + def repaint_needed(self): return ( self.impl.sub_containers[self.native.get_current_page()].needs_redraw diff --git a/gtk/tests_backend/widgets/progressbar.py b/gtk/tests_backend/widgets/progressbar.py index beec388c5d..e52e745fba 100644 --- a/gtk/tests_backend/widgets/progressbar.py +++ b/gtk/tests_backend/widgets/progressbar.py @@ -1,6 +1,8 @@ import asyncio -from toga_gtk.libs import Gtk +import pytest + +from toga_gtk.libs import GTK_VERSION, Gtk from .base import SimpleProbe @@ -8,6 +10,9 @@ class ProgressBarProbe(SimpleProbe): native_class = Gtk.ProgressBar + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support progress bars yet") + @property def is_determinate(self): return self.widget._impl._max is not None diff --git a/gtk/tests_backend/widgets/scrollcontainer.py b/gtk/tests_backend/widgets/scrollcontainer.py index 364f17c9e9..55a4c766d3 100644 --- a/gtk/tests_backend/widgets/scrollcontainer.py +++ b/gtk/tests_backend/widgets/scrollcontainer.py @@ -1,4 +1,6 @@ -from toga_gtk.libs import Gtk +import pytest + +from toga_gtk.libs import GTK_VERSION, Gtk from .base import SimpleProbe @@ -7,6 +9,9 @@ class ScrollContainerProbe(SimpleProbe): native_class = Gtk.ScrolledWindow scrollbar_inset = 0 + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support progress bars yet") + @property def has_content(self): return self.impl.document_container.content is not None diff --git a/gtk/tests_backend/widgets/selection.py b/gtk/tests_backend/widgets/selection.py index 3c19cc188f..90b7621719 100644 --- a/gtk/tests_backend/widgets/selection.py +++ b/gtk/tests_backend/widgets/selection.py @@ -1,12 +1,17 @@ +import pytest from pytest import skip, xfail -from toga_gtk.libs import Gtk +from toga_gtk.libs import GTK_VERSION, Gtk from .base import SimpleProbe class SelectionProbe(SimpleProbe): - native_class = Gtk.ComboBoxText + if GTK_VERSION < (4, 0, 0): + native_class = Gtk.ComboBoxText + else: + native_class = Gtk.DropDown + pytest.skip("GTK4 doesn't support selection probes yet") def assert_resizes_on_content_change(self): pass diff --git a/gtk/tests_backend/widgets/slider.py b/gtk/tests_backend/widgets/slider.py index dfbd05a50b..c8e8788d64 100644 --- a/gtk/tests_backend/widgets/slider.py +++ b/gtk/tests_backend/widgets/slider.py @@ -1,4 +1,6 @@ -from toga_gtk.libs import Gdk, Gtk +import pytest + +from toga_gtk.libs import GTK_VERSION, Gdk, Gtk from .base import SimpleProbe @@ -6,6 +8,9 @@ class SliderProbe(SimpleProbe): native_class = Gtk.Scale + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support sliders yet") + @property def position(self): assert self.native.get_draw_value() is False diff --git a/gtk/tests_backend/widgets/splitcontainer.py b/gtk/tests_backend/widgets/splitcontainer.py index 597a0941c1..403a26bf13 100644 --- a/gtk/tests_backend/widgets/splitcontainer.py +++ b/gtk/tests_backend/widgets/splitcontainer.py @@ -1,6 +1,8 @@ import asyncio -from toga_gtk.libs import Gtk +import pytest + +from toga_gtk.libs import GTK_VERSION, Gtk from .base import SimpleProbe @@ -10,6 +12,9 @@ class SplitContainerProbe(SimpleProbe): border_size = 0 direction_change_preserves_position = False + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't split containers yet") + def move_split(self, position): self.native.set_position(position) diff --git a/gtk/tests_backend/widgets/switch.py b/gtk/tests_backend/widgets/switch.py index 5254ccf87f..b1a4a8ea53 100644 --- a/gtk/tests_backend/widgets/switch.py +++ b/gtk/tests_backend/widgets/switch.py @@ -1,4 +1,6 @@ -from toga_gtk.libs import Gtk +import pytest + +from toga_gtk.libs import GTK_VERSION, Gtk from .base import SimpleProbe from .properties import toga_color @@ -7,6 +9,9 @@ class SwitchProbe(SimpleProbe): native_class = Gtk.Box + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support switches yet") + def __init__(self, widget): super().__init__(widget) self.native_label = widget._impl.native_label diff --git a/gtk/tests_backend/widgets/table.py b/gtk/tests_backend/widgets/table.py index 4ab4fe5f1d..ca6f57cbef 100644 --- a/gtk/tests_backend/widgets/table.py +++ b/gtk/tests_backend/widgets/table.py @@ -1,6 +1,6 @@ import pytest -from toga_gtk.libs import Gtk +from toga_gtk.libs import GTK_VERSION, Gtk from .base import SimpleProbe @@ -11,6 +11,9 @@ class TableProbe(SimpleProbe): supports_keyboard_shortcuts = False supports_widgets = False + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support tables yet") + def __init__(self, widget): super().__init__(widget) self.native_table = widget._impl.native_table diff --git a/gtk/tests_backend/widgets/textinput.py b/gtk/tests_backend/widgets/textinput.py index 988e2baaae..0c34b4514f 100644 --- a/gtk/tests_backend/widgets/textinput.py +++ b/gtk/tests_backend/widgets/textinput.py @@ -1,7 +1,7 @@ import pytest from toga.constants import JUSTIFY, LEFT -from toga_gtk.libs import Gtk +from toga_gtk.libs import GTK_VERSION, Gtk from .base import SimpleProbe from .properties import toga_x_text_align @@ -10,6 +10,9 @@ class TextInputProbe(SimpleProbe): native_class = Gtk.Entry + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support text input yet") + @property def value(self): return ( diff --git a/gtk/tests_backend/widgets/tree.py b/gtk/tests_backend/widgets/tree.py index facac73af1..7678ee4eab 100644 --- a/gtk/tests_backend/widgets/tree.py +++ b/gtk/tests_backend/widgets/tree.py @@ -2,7 +2,7 @@ import pytest -from toga_gtk.libs import Gtk +from toga_gtk.libs import GTK_VERSION, Gtk from .base import SimpleProbe @@ -12,6 +12,9 @@ class TreeProbe(SimpleProbe): supports_keyboard_shortcuts = False supports_widgets = False + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support trees yet") + def __init__(self, widget): super().__init__(widget) self.native_tree = widget._impl.native_tree diff --git a/gtk/tests_backend/widgets/webview.py b/gtk/tests_backend/widgets/webview.py index a9f8aea021..4161e338d7 100644 --- a/gtk/tests_backend/widgets/webview.py +++ b/gtk/tests_backend/widgets/webview.py @@ -1,8 +1,9 @@ from http.cookiejar import CookieJar +import pytest from pytest import skip -from toga_gtk.libs import WebKit2 +from toga_gtk.libs import GTK_VERSION, WebKit2 from .base import SimpleProbe @@ -13,6 +14,9 @@ class WebViewProbe(SimpleProbe): javascript_supports_exception = True supports_on_load = True + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GTK4 doesn't support trees yet") + def extract_cookie(self, cookie_jar, name): assert isinstance(cookie_jar, CookieJar) skip("Cookie retrieval not implemented on GTK") diff --git a/gtk/tests_backend/window.py b/gtk/tests_backend/window.py index 00665006f2..7048ac78e4 100644 --- a/gtk/tests_backend/window.py +++ b/gtk/tests_backend/window.py @@ -1,7 +1,9 @@ import asyncio +import pytest + from toga.constants import WindowState -from toga_gtk.libs import IS_WAYLAND, Gdk, Gtk +from toga_gtk.libs import GTK_VERSION, IS_WAYLAND, Gdk, Gtk from .dialogs import DialogsMixin from .probe import BaseProbe @@ -11,13 +13,25 @@ class WindowProbe(BaseProbe, DialogsMixin): # GTK defers a lot of window behavior to the window manager, which means some # features either don't exist, or we can't guarantee they behave the way Toga would # like. - supports_closable = True + if GTK_VERSION < (4, 0, 0): + supports_closable = True + supports_as_image = True + supports_focus = True + else: + supports_closable = False + supports_as_image = False + supports_focus = False supports_minimizable = False supports_move_while_hidden = False supports_unminimize = False - # Wayland mostly prohibits interaction with the larger windowing environment - supports_minimize = not IS_WAYLAND - supports_placement = not IS_WAYLAND + + if GTK_VERSION < (4, 0, 0): + # Wayland mostly prohibits interaction with the larger windowing environment + supports_minimize = not IS_WAYLAND + supports_placement = not IS_WAYLAND + else: + supports_minimize = False + supports_placement = False def __init__(self, app, window): super().__init__() @@ -64,12 +78,20 @@ async def cleanup(self): def close(self): if self.is_closable: # Trigger the OS-level window close event. - self.native.emit("delete-event", None) + if GTK_VERSION < (4, 0, 0): + self.native.emit("delete-event", None) + else: + self.native.emit("close-request") @property def content_size(self): - content_allocation = self.impl.container.get_allocation() - return (content_allocation.width, content_allocation.height) + if GTK_VERSION < (4, 0, 0): + content_allocation = self.impl.container.get_allocation() + return content_allocation.width, content_allocation.height + else: + pytest.skip("Content size in GTK4 is not implemented") + content = self.impl.container + return content.width, content.height @property def is_resizable(self): @@ -84,17 +106,25 @@ def is_minimized(self): return self.impl._window_state_flags & Gdk.WindowState.ICONIFIED def minimize(self): - self.native.iconify() + if GTK_VERSION < (4, 0, 0): + self.native.iconify() + else: + self.native.minimize() def unminimize(self): - self.native.deiconify() + if GTK_VERSION < (4, 0, 0): + self.native.deiconify() + else: + self.native.present() @property def instantaneous_state(self): return self.impl.get_window_state(in_progress_state=False) def has_toolbar(self): - return self.impl.native_toolbar.get_n_items() > 0 + if GTK_VERSION < (4, 0, 0): + return self.impl.native_toolbar.get_n_items() > 0 + pytest.skip("Toolbars not implemented on GTK4") def assert_is_toolbar_separator(self, index, section=False): item = self.impl.native_toolbar.get_nth_item(index) diff --git a/iOS/tests_backend/window.py b/iOS/tests_backend/window.py index 31c09b76d0..5ed05275b6 100644 --- a/iOS/tests_backend/window.py +++ b/iOS/tests_backend/window.py @@ -11,6 +11,8 @@ class WindowProbe(BaseProbe, DialogsMixin): supports_fullscreen = False supports_presentation = False + supports_as_image = True + supports_focus = True def __init__(self, app, window): super().__init__() diff --git a/pyproject.toml b/pyproject.toml index 5dba9590d2..9082940905 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ exclude_lines = [ ] [tool.coverage.coverage_conditional_plugin.rules] +# Additional testbed rules are configured in the testbed module no-cover-if-missing-setuptools_scm = "not is_installed('setuptools_scm')" no-cover-if-missing-PIL = "not is_installed('PIL')" no-cover-if-PIL-installed = "is_installed('PIL')" diff --git a/testbed/tests/testbed.py b/testbed/tests/testbed.py index 857eaaf05a..d18344a790 100644 --- a/testbed/tests/testbed.py +++ b/testbed/tests/testbed.py @@ -105,7 +105,14 @@ def run_tests(app, cov, args, report_coverage, run_slow, running_in_ci): show_missing=True, ) if total < 100.0: - print("Test coverage is incomplete") + if os.getenv("TOGA_GTK", None) == "4": + print("Incomplete test coverage is expected on GTK4 (for now!)") + else: + print("Test coverage is incomplete") + app.returncode = 1 + elif os.getenv("TOGA_GTK", None) == "4": + print("Test coverage for GTK4 is unexpectedly complete!") + print("Can we remove the special case in the testbed?") app.returncode = 1 except BaseException: @@ -155,6 +162,8 @@ def run_tests(app, cov, args, report_coverage, run_slow, running_in_ci): "no-cover-if-linux-x": ( "os_environ.get('WAYLAND_DISPLAY', 'not-set') == 'not-set'" ), + "no-cover-if-gtk4": "os_environ.get('TOGA_GTK', '') == '4'", + "no-cover-if-gtk3": "os_environ.get('TOGA_GTK', '3') == '3'", }, ) cov.start() diff --git a/testbed/tests/widgets/conftest.py b/testbed/tests/widgets/conftest.py index 36464af728..1c4a37626a 100644 --- a/testbed/tests/widgets/conftest.py +++ b/testbed/tests/widgets/conftest.py @@ -1,8 +1,10 @@ import gc +import os import weakref +from contextlib import contextmanager from unittest.mock import Mock -from pytest import fixture +import pytest import toga from toga.style.pack import TOP @@ -11,12 +13,12 @@ from .probe import get_probe -@fixture +@pytest.fixture async def widget(): raise NotImplementedError("test modules must define a `widget` fixture") -@fixture +@pytest.fixture async def probe(main_window, widget): old_content = main_window.content @@ -30,12 +32,12 @@ async def probe(main_window, widget): main_window.content = old_content -@fixture +@pytest.fixture async def container_probe(widget): return get_probe(widget.parent) -@fixture +@pytest.fixture async def other(widget): """A separate widget that can take focus""" other = toga.TextInput() @@ -43,12 +45,12 @@ async def other(widget): return other -@fixture +@pytest.fixture async def other_probe(other): return get_probe(other) -@fixture(params=[True, False]) +@pytest.fixture(params=[True, False]) async def focused(request, widget, other): if request.param: widget.focus() @@ -57,7 +59,7 @@ async def focused(request, widget, other): return request.param -@fixture +@pytest.fixture async def on_change(widget): on_change = Mock() widget.on_change = on_change @@ -65,26 +67,59 @@ async def on_change(widget): return on_change -@fixture +@pytest.fixture def verify_font_sizes(): """Whether the widget's width and height are affected by font size""" return True, True -@fixture +@pytest.fixture def verify_focus_handlers(): """Whether the widget has on_gain_focus and on_lose_focus handlers""" return False -@fixture +@pytest.fixture def verify_vertical_text_align(): """The widget's default vertical text alignment""" return TOP +@contextmanager +def safe_create(): + """A context manager to protect against widgets that can't be instantiated. + + Catches RuntimeErrors, and: + * skips if the exception message contains the content "isn't supported on" + (e.g., "WebView isn't supported on GTK4"); + * xfails if running outside a CI environment (this likely indicates a + missing system requirement) + * re-raises if in CI environment (since the CI environment *should* have + all system requirements installed) + """ + try: + yield + except RuntimeError as e: + msg = str(e) + if " isn't supported on " in msg: + # If the widget fails because the platform doesn't support it, we + # can skip the test. + pytest.skip(msg) + elif os.getenv("CI", None) is None: + # If we're on the user's machine (i.e., *not* in a CI environment), + # they might not have the required dependencies installed - in which + # case that's an expected failure. + pytest.xfail(msg) + else: + raise + + def build_cleanup_test( - widget_constructor, args=None, kwargs=None, skip_platforms=(), xfail_platforms=() + widget_constructor, + args=None, + kwargs=None, + skip_platforms=(), + xfail_platforms=(), ): async def test_cleanup(): nonlocal args, kwargs @@ -98,7 +133,9 @@ async def test_cleanup(): if kwargs is None: kwargs = {} - widget = widget_constructor(*args, **kwargs) + with safe_create(): + widget = widget_constructor(*args, **kwargs) + ref = weakref.ref(widget) # Args or kwargs may hold a backref to the widget itself, for example if they diff --git a/testbed/tests/widgets/test_mapview.py b/testbed/tests/widgets/test_mapview.py index 0a15c93040..5882f9d065 100644 --- a/testbed/tests/widgets/test_mapview.py +++ b/testbed/tests/widgets/test_mapview.py @@ -8,7 +8,7 @@ import toga from toga.style import Pack -from .conftest import build_cleanup_test +from .conftest import build_cleanup_test, safe_create from .properties import ( # noqa: F401 test_flex_widget_size, ) @@ -32,7 +32,8 @@ async def on_select(): @pytest.fixture async def widget(on_select): - widget = toga.MapView(style=Pack(flex=1), on_select=on_select) + with safe_create(): + widget = toga.MapView(style=Pack(flex=1), on_select=on_select) # Some implementations of MapView are a WebView wearing a trenchcoat. # Ensure that the webview is fully configured before proceeding. diff --git a/testbed/tests/widgets/test_optioncontainer.py b/testbed/tests/widgets/test_optioncontainer.py index 8e0c8cbd93..7e863a05fd 100644 --- a/testbed/tests/widgets/test_optioncontainer.py +++ b/testbed/tests/widgets/test_optioncontainer.py @@ -6,7 +6,7 @@ from toga.colors import CORNFLOWERBLUE, GOLDENROD, REBECCAPURPLE, SEAGREEN from toga.style.pack import Pack -from .conftest import build_cleanup_test +from .conftest import build_cleanup_test, safe_create from .probe import get_probe from .properties import ( # noqa: F401 test_enable_noop, @@ -61,15 +61,20 @@ async def on_select_handler(): @pytest.fixture async def widget(content1, content2, content3, on_select_handler): - return toga.OptionContainer( - content=[ - ("Tab 1", content1, "resources/tab-icon-1"), - toga.OptionItem("Tab 2", content2, icon=toga.Icon("resources/tab-icon-2")), - ("Tab 3", content3), - ], - style=Pack(flex=1), - on_select=on_select_handler, - ) + with safe_create(): + return toga.OptionContainer( + content=[ + ("Tab 1", content1, "resources/tab-icon-1"), + toga.OptionItem( + "Tab 2", + content2, + icon=toga.Icon("resources/tab-icon-2"), + ), + ("Tab 3", content3), + ], + style=Pack(flex=1), + on_select=on_select_handler, + ) test_cleanup = build_cleanup_test( diff --git a/testbed/tests/widgets/test_webview.py b/testbed/tests/widgets/test_webview.py index f29b76b9f6..a8f746ac4d 100644 --- a/testbed/tests/widgets/test_webview.py +++ b/testbed/tests/widgets/test_webview.py @@ -10,7 +10,7 @@ import toga from toga.style import Pack -from .conftest import build_cleanup_test +from .conftest import build_cleanup_test, safe_create from .properties import ( # noqa: F401 test_flex_widget_size, test_focus, @@ -80,7 +80,9 @@ async def on_load(): @pytest.fixture async def widget(on_load): - widget = toga.WebView(style=Pack(flex=1), on_webview_load=on_load) + with safe_create(): + widget = toga.WebView(style=Pack(flex=1), on_webview_load=on_load) + # We shouldn't be able to get a callback until at least one tick of the event loop # has completed. on_load.assert_not_called() diff --git a/testbed/tests/window/test_window.py b/testbed/tests/window/test_window.py index 64f67fc6e2..82b833685a 100644 --- a/testbed/tests/window/test_window.py +++ b/testbed/tests/window/test_window.py @@ -342,7 +342,8 @@ async def test_secondary_window(app, second_window, second_window_probe): assert second_window.size == (640, 480) # Position should be cascaded; the exact position depends on the platform, # and how many windows have been created. As long as it's not at (100,100). - assert second_window.position != (100, 100) + if second_window_probe.supports_placement: + assert second_window.position != (100, 100) assert second_window_probe.is_resizable if second_window_probe.supports_closable: @@ -1011,6 +1012,9 @@ async def test_focus_events( ): """The window can trigger on_gain_focus() and on_lose_focus() event handlers, when the window gains or loses input focus.""" + if not main_window_probe.supports_focus: + pytest.skip("GTK4 doesn't yet support gain and lose focus.") + main_window.on_gain_focus = Mock() main_window.on_lose_focus = Mock() second_window.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) @@ -1070,19 +1074,19 @@ async def test_screen(second_window, second_window_probe): """The window can be relocated to another screen, using both absolute and relative screen positions.""" + if not second_window_probe.supports_placement: + pytest.xfail("This backend doesn't support window placement.") initial_position = second_window.position # Move the window using absolute position. second_window.position = (200, 200) await second_window_probe.wait_for_window("Secondary window has been moved") - if second_window_probe.supports_placement: - assert second_window.position != initial_position + assert second_window.position != initial_position # `position` and `screen_position` will be same as the window will be in # primary screen. - if second_window_probe.supports_placement: - assert second_window.position == (200, 200) - assert second_window.screen_position == (200, 200) + assert second_window.position == (200, 200) + assert second_window.screen_position == (200, 200) # Move the window between available screens and assert its `screen_position` for screen in second_window.app.screens: @@ -1100,9 +1104,10 @@ async def test_screen(second_window, second_window_probe): async def test_as_image(main_window, main_window_probe): """The window can be captured as a screenshot""" - screenshot = main_window.as_image() - main_window_probe.assert_image_size( - screenshot.size, - main_window_probe.content_size, - screen=main_window.screen, - ) + if main_window_probe.supports_as_image: + screenshot = main_window.as_image() + main_window_probe.assert_image_size( + screenshot.size, + main_window_probe.content_size, + screen=main_window.screen, + ) diff --git a/winforms/tests_backend/window.py b/winforms/tests_backend/window.py index 8ff41476de..8acc618e89 100644 --- a/winforms/tests_backend/window.py +++ b/winforms/tests_backend/window.py @@ -27,6 +27,8 @@ class WindowProbe(BaseProbe, DialogsMixin): supports_unminimize = True supports_minimize = True supports_placement = True + supports_as_image = True + supports_focus = True def __init__(self, app, window): self.app = app