Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/3914.feature.7.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The OptionContainer widget is now supported in the Qt backend.
1 change: 0 additions & 1 deletion docs/en/reference/data/apis_by_platform.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,6 @@ Layout widgets:
unsupported:
- web
- textual
- qt

Resources:
path: resources
Expand Down
2 changes: 2 additions & 0 deletions qt/src/toga_qt/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from .widgets.label import Label
from .widgets.multilinetextinput import MultilineTextInput
from .widgets.numberinput import NumberInput
from .widgets.optioncontainer import OptionContainer
from .widgets.passwordinput import PasswordInput
from .widgets.progressbar import ProgressBar
from .widgets.switch import Switch
Expand Down Expand Up @@ -54,6 +55,7 @@
"Label",
"MultilineTextInput",
"NumberInput",
"OptionContainer",
"PasswordInput",
"ProgressBar",
"Switch",
Expand Down
2 changes: 1 addition & 1 deletion qt/src/toga_qt/icons.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,6 @@ def __init__(self, interface, path):
if self.native.isNull():
raise ValueError(f"Unable to load icon from {path}")

IMPL_DICT[self.native] = self
IMPL_DICT[self.native.cacheKey()] = self

self.path = path
94 changes: 94 additions & 0 deletions qt/src/toga_qt/widgets/optioncontainer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QTabWidget
from travertino.size import at_least

from ..container import Container
from ..icons import IMPL_DICT
from .base import Widget


class OptionContainer(Widget):
uses_icons = True

def create(self):
self.native = QTabWidget()
self.native.currentChanged.connect(self.qt_current_changed)

self.sub_containers = []

def qt_current_changed(self, *args):
self.interface.on_select()

def add_option(self, index, text, widget, icon=None):
sub_container = Container(on_refresh=self.content_refreshed)
sub_container.content = widget

self.sub_containers.insert(index, sub_container)
if icon is None:
self.native.insertTab(index, sub_container.native, text)
else:
self.native.insertTab(index, sub_container.native, icon._impl.native, text)

def remove_option(self, index):
self.native.removeTab(index)
self.sub_containers[index].content = None
del self.sub_containers[index]

def set_option_enabled(self, index, enabled):
self.native.setTabEnabled(index, enabled)

def is_option_enabled(self, index):
return self.native.isTabEnabled(index)

def set_option_text(self, index, value):
self.native.setTabText(index, value)

def get_option_text(self, index):
return self.native.tabText(index)

def set_option_icon(self, index, value):
if value is None:
self.native.setTabIcon(index, QIcon())
else:
self.native.setTabIcon(index, value._impl.native)

def get_option_icon(self, index):
impl = IMPL_DICT.get(self.native.tabIcon(index).cacheKey(), None)
if impl is None:
return None
else:
return impl.interface

def get_current_tab_index(self):
return self.native.currentIndex()

def set_current_tab_index(self, current_tab_index):
return self.native.setCurrentIndex(current_tab_index)

def rehint(self):
size = self.native.sizeHint()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be minimumSizeHint to take properly into account the minimum sizes of the child widgets, not just sizeHint.

self.interface.intrinsic.width = at_least(
max(size.width(), self.interface._MIN_WIDTH)
)
self.interface.intrinsic.height = at_least(
max(size.height(), self.interface._MIN_HEIGHT)
)

def set_bounds(self, x, y, width, height):
super().set_bounds(x, y, width, height)
for item in self.interface.content:
item.content.refresh()

def content_refreshed(self, container):
min_width = min(
sub_container.content.interface.layout.min_width
for sub_container in self.sub_containers
)
min_height = min(
sub_container.content.interface.layout.min_height
for sub_container in self.sub_containers
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 -- this should be maximum, not minimum to ensure that all tabs fit; 2 -- is this neccessary? Qt minimumSizeHint hinting already takes the maximum of all minimum sizes, so we could just set the minimum size of each subcontainer to the determined minimum size of that subcontainer only.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After you set the minimum size of the native widget, I'd suggest

    prev_intr_width, prev_intr_height = self.interface.intrinsic.width, self.interface.intrinsic.height
    self.rehint()
    intr_width, intr_height = self.interface.intrinsic.width, self.interface.intrinsic.height
    if (prev_intr_width, prev_intr_height) != (intr_width, intr_height):
        asyncio.get_running_loop().call_soon_threadsafe(self.interface.refresh)

-- this queues up a second refresh if the intrinsic size of the widget ever changes, so we could have appropriate sizing for the OptionContainer. The interface refresh will perform a layout on the entire tree outside the optioncontainer with correct minimum size for the OptionContianer widget, which will enforce the minimum size properly.

One refresh is queued in the interface code for adding/removing tabs; however, a second refresh is needed because the first refresh will use an outdated minimum size hint, but it's neccesary for that refresh to happen so we could refresh the widget's contents as well.

Along with the minimumSizeHint I mentioned and if I change content_refreshed to this with proper asyncio import and linting, size hinting shuold all work:

    def content_refreshed(self, container):
        container.native.setMinimumSize(container.content.interface.layout.min_width, container.content.interface.layout.min_height)
        prev_intr_width, prev_intr_height = self.interface.intrinsic.width, self.interface.intrinsic.height
        self.rehint()
        intr_width, intr_height = self.interface.intrinsic.width, self.interface.intrinsic.height
        if (prev_intr_width, prev_intr_height) != (intr_width, intr_height):
            asyncio.get_running_loop().call_soon_threadsafe(self.interface.refresh)

for sub_container in self.sub_containers:
sub_container.native.setMinimumSize(min_width, min_height)
sub_container.min_width = min_width
sub_container.min_height = min_height
27 changes: 27 additions & 0 deletions qt/tests_backend/widgets/optioncontainer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from PySide6.QtWidgets import QTabWidget

from .base import SimpleProbe


class OptionContainerProbe(SimpleProbe):
native_class = QTabWidget
max_tabs = None
disabled_tab_selectable = True

def select_tab(self, index):
self.native.setCurrentIndex(index)

def tab_enabled(self, index):
return self.native.isTabEnabled(index)

async def wait_for_tab(self, message):
return

def assert_tab_icon(self, index, expected):
actual = self.impl.get_option_icon(index)
if expected is None:
assert actual is None
else:
assert actual is not None
assert actual.path.name == expected
assert actual._impl.path.name == f"{expected}-linux.png"
Binary file added testbed/src/testbed/resources/new-tab-linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.