diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc074bb4cb..6e0fdd6a89 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -235,8 +235,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" @@ -261,7 +262,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 @@ -290,7 +291,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 @@ -309,7 +310,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 @@ -317,6 +318,37 @@ 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-webkit2-4.1 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" + briefcase-test-args: -k 'test_window or test_desktop or test_app and not test_app_icon' + 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" @@ -397,7 +429,8 @@ jobs: timeout-minutes: 15 run: | ${{ matrix.briefcase-run-prefix }} \ - briefcase run ${{ matrix.platform }} --log --test ${{ matrix.briefcase-run-args }} -- --ci + briefcase run ${{ matrix.platform }} --log --test ${{ matrix.briefcase-run-args }} \ + -- ${{ matrix.briefcase-test-args }} --ci - name: Upload Logs uses: actions/upload-artifact@v4.6.0 diff --git a/changes/3087.feature.rst b/changes/3087.feature.rst new file mode 100644 index 0000000000..890ec8ff60 --- /dev/null +++ b/changes/3087.feature.rst @@ -0,0 +1 @@ +Toga GTK now supports GTK4 for BeeWare Tutorial 1 by setting `TOGA_GTK=4`. diff --git a/cocoa/tests_backend/window.py b/cocoa/tests_backend/window.py index 5115819381..34a9867d24 100644 --- a/cocoa/tests_backend/window.py +++ b/cocoa/tests_backend/window.py @@ -16,6 +16,7 @@ class WindowProbe(BaseProbe, DialogsMixin): supports_unminimize = True supports_minimize = True supports_placement = True + supports_as_image = True def __init__(self, app, window): super().__init__() diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index cfffc512c9..399f19136c 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,22 @@ 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): + 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 + ) + else: + if Gtk.get_minor_version() >= 12: + css_provider.load_from_string(TOGA_DEFAULT_STYLES) + elif Gtk.get_minor_version() > 8: + css_provider.load_from_data( + TOGA_DEFAULT_STYLES, len(TOGA_DEFAULT_STYLES) + ) + else: + css_provider.load_from_data(TOGA_DEFAULT_STYLES.encode("utf-8")) ###################################################################### # Commands and menus @@ -173,20 +184,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): + 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: + return [ScreenImpl(native=monitor) for monitor in display.get_monitors()] ###################################################################### # App state @@ -201,7 +216,10 @@ def get_dark_mode_state(self): ###################################################################### def beep(self): - Gdk.beep() + if GTK_VERSION < (4, 0, 0): + Gdk.beep() + else: + 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 37ab26dbda..88dd1f7fc4 100644 --- a/gtk/src/toga_gtk/container.py +++ b/gtk/src/toga_gtk/container.py @@ -1,4 +1,4 @@ -from .libs import Gdk, Gtk +from .libs import GTK_VERSION, Gdk, Gtk ####################################################################################### # Implementation notes: @@ -8,8 +8,95 @@ # details. ####################################################################################### +if GTK_VERSION < (4, 0, 0): + LayoutManager = object +else: + LayoutManager = Gtk.LayoutManager -class TogaContainer(Gtk.Fixed): + +class TogaContainerLayoutManager(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._content.interface, 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. @@ -17,6 +104,13 @@ class TogaContainer(Gtk.Fixed): def __init__(self): super().__init__() + + if GTK_VERSION >= (4, 0): + # 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 @@ -52,7 +146,10 @@ def width(self): """ if self._content is None: return 0 - return self.get_allocated_width() + if GTK_VERSION < (4, 0, 0): + return self.get_allocated_width() + else: + return self.compute_bounds(self)[1].get_width() @property def height(self): @@ -62,7 +159,10 @@ def height(self): """ if self._content is None: return 0 - return self.get_allocated_height() + if GTK_VERSION < (4, 0, 0): + return self.get_allocated_height() + else: + return self.compute_bounds(self)[1].get_height() @property def content(self): @@ -118,16 +218,19 @@ def do_get_preferred_width(self): 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 GTK_VERSION < (4, 0, 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() + # 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 + # 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 + else: + pass def do_get_preferred_height(self): """Return (recomputing if necessary) the preferred height for the container. @@ -137,16 +240,19 @@ def do_get_preferred_height(self): 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 + if GTK_VERSION < (4, 0, 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() + # 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 + # 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 + else: + pass def do_size_allocate(self, allocation): """Perform the actual layout for the widget, and all it's children. @@ -156,50 +262,55 @@ def do_size_allocate(self, allocation): 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) - self._content.interface.style.layout(self._content.interface, 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 + if GTK_VERSION < (4, 0, 0): + # 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._content.interface, 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 + else: + pass diff --git a/gtk/src/toga_gtk/dialogs.py b/gtk/src/toga_gtk/dialogs.py index 854b14a5c7..79f7cf1951 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): + 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: + 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): + 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: + toga.NotImplementedWarning("Dialog()") def build_dialog(self, message): self.native.format_secondary_text(message) diff --git a/gtk/src/toga_gtk/icons.py b/gtk/src/toga_gtk/icons.py index c0382cedb1..21b4fe4e05 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): + native = GdkPixbuf.Pixbuf.new_from_file(str(path)).scale_simple( + size, size, GdkPixbuf.InterpType.BILINEAR + ) + else: + 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): + # 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: + return None diff --git a/gtk/src/toga_gtk/libs/gtk.py b/gtk/src/toga_gtk/libs/gtk.py index 65dcbce4ce..2e9462c739 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): + default_display = Gdk.Screen.get_default() +else: + 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?" diff --git a/gtk/src/toga_gtk/libs/styles.py b/gtk/src/toga_gtk/libs/styles.py index 686bb3d1b3..e25a99d2a7 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): + 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: + 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..03ff933622 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,11 @@ 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): + geometry = self.native.get_geometry() + return Position(geometry.x, geometry.y) + else: + 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..6b1947228b 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): + 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: + self.interface.factory.not_implemented("StatusIcon") def remove(self): del self.native @@ -36,7 +39,8 @@ def remove(self): class SimpleStatusIcon(StatusIcon): def create(self): super().create() - self.native.connect("activate", self.gtk_activate) + if GTK_VERSION < (4, 0, 0): + self.native.connect("activate", self.gtk_activate) def gtk_activate(self, icon, button, time): self.interface.on_press() @@ -71,42 +75,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): + # 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: + self.interface.factory.not_implemented("StatusIconSet.create()") diff --git a/gtk/src/toga_gtk/widgets/base.py b/gtk/src/toga_gtk/widgets/base.py index cb82809a87..940051b451 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): + self.native.get_style_context().add_class("toga") + else: + 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): + self._container.add(self.native) + self.native.show_all() + else: + 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): + return self.native.has_focus() + else: + 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): + # 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: + 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 48f9b323ed..561ce71812 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): + return text + return text if text else "" 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) + if GTK_VERSION < (4, 0, 0): + 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: - self.native.set_image(None) - self.native.set_always_show_image(False) + 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) diff --git a/gtk/src/toga_gtk/widgets/label.py b/gtk/src/toga_gtk/widgets/label.py index be2a9a88ea..23dc4b1621 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): + self.native.set_line_wrap(False) + else: + 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): + # 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: + # 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/textinput.py b/gtk/src/toga_gtk/widgets/textinput.py index 141ec5de54..1f588b2428 100644 --- a/gtk/src/toga_gtk/widgets/textinput.py +++ b/gtk/src/toga_gtk/widgets/textinput.py @@ -3,7 +3,7 @@ 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 @@ -11,21 +11,39 @@ 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): + 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: + focus_controller = Gtk.EventControllerFocus() + focus_controller.connect("enter", self.gtk_focus_in_event) + focus_controller.connect("leave", self.gtk_focus_out_event) + + key_press_controller = Gtk.EventControllerKey() + key_press_controller.connect("key-pressed", self.gtk_key_press_event) + + self.native.add_controller(focus_controller) + self.native.add_controller(key_press_controller) + + def gtk_on_change(self, *_args): + if GTK_VERSION < (4, 0, 0): + self.interface._value_changed() + else: + self.interface._value_changed(self.interface) + + def gtk_focus_in_event(self, *_args): + if GTK_VERSION < (4, 0, 0): + self.interface.on_gain_focus() + else: + 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() diff --git a/gtk/src/toga_gtk/window.py b/gtk/src/toga_gtk/window.py index 431fe9bd9b..d2f3ff31e5 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,13 +26,23 @@ 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, - ) - self.native.connect("window-state-event", self.gtk_window_state_event) + if GTK_VERSION < (4, 0, 0): + self._delete_handler = self.native.connect( + "delete-event", + self.gtk_delete_event, + ) + else: + self._delete_handler = self.native.connect( + "close-request", self.gtk_delete_event + ) - self._window_state_flags = None + if GTK_VERSION < (4, 0, 0): + self.native.connect("window-state-event", self.gtk_window_state_event) + self._window_state_flags = None + else: + 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._in_presentation = False # Pending Window state transition variable: self._pending_state_transition = None @@ -57,9 +67,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): + self.layout.pack_end(self.container, expand=True, fill=True, padding=0) + self.native.add(self.layout) + else: + 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() @@ -69,9 +84,10 @@ def create(self): ###################################################################### def gtk_window_state_event(self, widget, event): - previous_window_state_flags = self._window_state_flags - # Get the window state flags - self._window_state_flags = event.new_window_state + if GTK_VERSION < (4, 0, 0): + previous_window_state_flags = self._window_state_flags + # Get the window state flags + self._window_state_flags = event.new_window_state # Window state flags are unreliable when window is hidden, # so cache the previous window state flag on to the new @@ -119,7 +135,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 @@ -148,10 +164,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): + self.native.set_icon(app.interface.icon._impl.native(72)) + else: + self.interface.factory.not_implemented("Window.set_app() icon") def show(self): - self.native.show_all() + if GTK_VERSION < (4, 0, 0): + self.native.show_all() + else: + self.native.present() ###################################################################### # Window content and resources @@ -166,27 +188,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): + width, height = self.native.get_default_size() + size = self.native.get_size() + return Size(size.width, size.height) + else: + 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): + self.native.resize(size[0], size[1]) + else: + 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): + display = Gdk.Display.get_default() + monitor_native = display.get_monitor_at_window(self.native.get_window()) + else: + 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): + pos = self.native.get_position() + return Position(pos.root_x, pos.root_y) + else: + # 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): + self.native.move(position[0], position[1]) + else: + # GTK4 no longer has an API to set position + # since it isn't supported by Wayland + pass ###################################################################### # Window visibility @@ -196,7 +239,10 @@ def get_visible(self): return self.native.get_property("visible") def hide(self): - self.native.hide() + if GTK_VERSION < (4, 0, 0): + self.native.hide() + else: + self.native.set_visible(False) ###################################################################### # Window state @@ -205,13 +251,25 @@ 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): + window_state_flags = self._window_state_flags + if window_state_flags: # pragma: no branch + if window_state_flags & Gdk.WindowState.MAXIMIZED: + return WindowState.MAXIMIZED + elif window_state_flags & Gdk.WindowState.ICONIFIED: + return WindowState.MINIMIZED # pragma: no-cover-if-linux-wayland + elif window_state_flags & Gdk.WindowState.FULLSCREEN: + return ( + WindowState.PRESENTATION + if self._in_presentation + else WindowState.FULLSCREEN + ) + else: + if self.native.is_maximized(): return WindowState.MAXIMIZED - elif window_state_flags & Gdk.WindowState.ICONIFIED: - return WindowState.MINIMIZED # pragma: no-cover-if-linux-wayland - elif window_state_flags & Gdk.WindowState.FULLSCREEN: + if GTK_VERSION >= (4, 12) and self.native.is_suspended(): + return WindowState.MINIMIZED + elif self.native.is_fullscreen(): return ( WindowState.PRESENTATION if self._in_presentation @@ -262,7 +320,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): + self.native.iconify() + else: + self.native.minimize() elif target_state == WindowState.FULLSCREEN: self.native.fullscreen() @@ -304,105 +365,113 @@ 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): + 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: + 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): + 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() 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): + # 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: + # 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..0f6f4a00ca 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. diff --git a/gtk/tests_backend/dialogs.py b/gtk/tests_backend/dialogs.py index 3d90501dde..5d10694e35 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 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/window.py b/gtk/tests_backend/window.py index 00665006f2..914010ca46 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,23 @@ 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 + else: + supports_closable = False + supports_as_image = 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 +76,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 +104,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/testbed/tests/window/test_window.py b/testbed/tests/window/test_window.py index f860ad9326..7af7998385 100644 --- a/testbed/tests/window/test_window.py +++ b/testbed/tests/window/test_window.py @@ -335,7 +335,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: @@ -971,19 +972,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: @@ -1001,9 +1002,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..b14eb3243a 100644 --- a/winforms/tests_backend/window.py +++ b/winforms/tests_backend/window.py @@ -27,6 +27,7 @@ class WindowProbe(BaseProbe, DialogsMixin): supports_unminimize = True supports_minimize = True supports_placement = True + supports_as_image = True def __init__(self, app, window): self.app = app