Kivy: swap from the tab carousel to navigation bar (#4930)

* implement tabs as NavigationBar

* update the underline bar with the screen manager

* remove some unneeded kv

* remove the underline in favor of a full tab highlight

* fix insert transitions

* use on_release instead of on_press

* minor cleanup

* add remove_client_tab and add a caller to the NavigationBar for back compat

* unused imports

* Update kvui.py

---------

Co-authored-by: Silvris <58583688+Silvris@users.noreply.github.com>
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
This commit is contained in:
Aaron Wagener
2025-06-19 06:39:26 -05:00
committed by GitHub
parent 211456242e
commit 4eefd9c3ce
2 changed files with 113 additions and 80 deletions

View File

@@ -24,9 +24,20 @@
<BaseButton>: <BaseButton>:
ripple_color: app.theme_cls.primaryColor ripple_color: app.theme_cls.primaryColor
ripple_duration_in_fast: 0.2 ripple_duration_in_fast: 0.2
<MDTabsItemBase>: <MDNavigationItemBase>:
ripple_color: app.theme_cls.primaryColor on_release: app.screens.switch_screens(self)
ripple_duration_in_fast: 0.2
MDNavigationItemLabel:
text: root.text
theme_text_color: "Custom"
text_color_active: self.theme_cls.primaryColor
text_color_normal: 1, 1, 1, 1
# indicator is on icon only for some reason
canvas.before:
Color:
rgba: self.theme_cls.secondaryContainerColor if root.active else self.theme_cls.transparentColor
Rectangle:
size: root.size
<TooltipLabel>: <TooltipLabel>:
adaptive_height: True adaptive_height: True
theme_font_size: "Custom" theme_font_size: "Custom"

176
kvui.py
View File

@@ -60,7 +60,10 @@ from kivymd.uix.dialog import MDDialog, MDDialogHeadlineText, MDDialogSupporting
from kivymd.uix.gridlayout import MDGridLayout from kivymd.uix.gridlayout import MDGridLayout
from kivymd.uix.floatlayout import MDFloatLayout from kivymd.uix.floatlayout import MDFloatLayout
from kivymd.uix.boxlayout import MDBoxLayout from kivymd.uix.boxlayout import MDBoxLayout
from kivymd.uix.tab.tab import MDTabsSecondary, MDTabsItem, MDTabsItemText, MDTabsCarousel from kivymd.uix.navigationbar import MDNavigationBar, MDNavigationItem
from kivymd.uix.screen import MDScreen
from kivymd.uix.screenmanager import MDScreenManager
from kivymd.uix.menu import MDDropdownMenu from kivymd.uix.menu import MDDropdownMenu
from kivymd.uix.menu.menu import MDDropdownTextItem from kivymd.uix.menu.menu import MDDropdownTextItem
from kivymd.uix.dropdownitem import MDDropDownItem, MDDropDownItemText from kivymd.uix.dropdownitem import MDDropDownItem, MDDropDownItemText
@@ -726,6 +729,10 @@ class MessageBox(Popup):
self.height += max(0, label.height - 18) self.height += max(0, label.height - 18)
class MDNavigationItemBase(MDNavigationItem):
text = StringProperty(None)
class ButtonsPrompt(MDDialog): class ButtonsPrompt(MDDialog):
def __init__(self, title: str, text: str, response: typing.Callable[[str], None], def __init__(self, title: str, text: str, response: typing.Callable[[str], None],
*prompts: str, **kwargs) -> None: *prompts: str, **kwargs) -> None:
@@ -766,58 +773,34 @@ class ButtonsPrompt(MDDialog):
) )
class ClientTabs(MDTabsSecondary): class MDScreenManagerBase(MDScreenManager):
carousel: MDTabsCarousel current_tab: MDNavigationItemBase
lock_swiping = True local_screen_names: list[str]
def __init__(self, *args, **kwargs): def __init__(self, **kwargs):
self.carousel = MDTabsCarousel(lock_swiping=True, anim_move_duration=0.2) super().__init__(**kwargs)
super().__init__(*args, MDDivider(size_hint_y=None, height=dp(1)), self.carousel, **kwargs) self.local_screen_names = []
self.size_hint_y = 1
def _check_panel_height(self, *args): def add_widget(self, widget: Widget, *args, **kwargs) -> None:
self.ids.tab_scroll.height = dp(38) super().add_widget(widget, *args, **kwargs)
if "index" in kwargs:
def update_indicator( self.local_screen_names.insert(kwargs["index"], widget.name)
self, x: float = 0.0, w: float = 0.0, instance: MDTabsItem = None
) -> None:
def update_indicator(*args):
indicator_pos = (0, 0)
indicator_size = (0, 0)
item_text_object = self._get_tab_item_text_icon_object()
if item_text_object:
indicator_pos = (
instance.x + dp(12),
self.indicator.pos[1]
if not self._tabs_carousel
else self._tabs_carousel.height,
)
indicator_size = (
instance.width - dp(24),
self.indicator_height,
)
Animation(
pos=indicator_pos,
size=indicator_size,
d=0 if not self.indicator_anim else self.indicator_duration,
t=self.indicator_transition,
).start(self.indicator)
if not instance:
self.indicator.pos = (x, self.indicator.pos[1])
self.indicator.size = (w, self.indicator_height)
else: else:
Clock.schedule_once(update_indicator) self.local_screen_names.append(widget.name)
def remove_tab(self, tab, content=None): def switch_screens(self, new_tab: MDNavigationItemBase) -> None:
if content is None: """
content = tab.content Called whenever the user clicks a tab to switch to a different screen.
self.ids.container.remove_widget(tab)
self.carousel.remove_widget(content) :param new_tab: The new screen to switch to's tab.
self.on_size(self, self.size) """
name = new_tab.text
if self.local_screen_names.index(name) > self.local_screen_names.index(self.current_screen.name):
self.transition.direction = "left"
else:
self.transition.direction = "right"
self.current = name
self.current_tab = new_tab
class CommandButton(MDButton, MDTooltip): class CommandButton(MDButton, MDTooltip):
@@ -845,6 +828,9 @@ class GameManager(ThemedApp):
main_area_container: MDGridLayout main_area_container: MDGridLayout
""" subclasses can add more columns beside the tabs """ """ subclasses can add more columns beside the tabs """
tabs: MDNavigationBar
screens: MDScreenManagerBase
def __init__(self, ctx: context_type): def __init__(self, ctx: context_type):
self.title = self.base_title self.title = self.base_title
self.ctx = ctx self.ctx = ctx
@@ -874,7 +860,7 @@ class GameManager(ThemedApp):
@property @property
def tab_count(self): def tab_count(self):
if hasattr(self, "tabs"): if hasattr(self, "tabs"):
return max(1, len(self.tabs.tab_list)) return max(1, len(self.tabs.children))
return 1 return 1
def on_start(self): def on_start(self):
@@ -914,30 +900,30 @@ class GameManager(ThemedApp):
self.grid.add_widget(self.progressbar) self.grid.add_widget(self.progressbar)
# middle part # middle part
self.tabs = ClientTabs(pos_hint={"center_x": 0.5, "center_y": 0.5}) self.screens = MDScreenManagerBase(pos_hint={"center_x": 0.5})
self.tabs.add_widget(MDTabsItem(MDTabsItemText(text="All" if len(self.logging_pairs) > 1 else "Archipelago"))) self.tabs = MDNavigationBar(orientation="horizontal", size_hint_y=None, height=dp(40), set_bars_color=True)
self.log_panels["All"] = self.tabs.default_tab_content = UILog(*(logging.getLogger(logger_name) # bind the method to the bar for back compatibility
for logger_name, name in self.tabs.remove_tab = self.remove_client_tab
self.logging_pairs)) self.screens.current_tab = self.add_client_tab(
self.tabs.carousel.add_widget(self.tabs.default_tab_content) "All" if len(self.logging_pairs) > 1 else "Archipelago",
UILog(*(logging.getLogger(logger_name) for logger_name, name in self.logging_pairs)),
)
self.log_panels["All"] = self.screens.current_tab.content
self.screens.current_tab.active = True
for logger_name, display_name in self.logging_pairs: for logger_name, display_name in self.logging_pairs:
bridge_logger = logging.getLogger(logger_name) bridge_logger = logging.getLogger(logger_name)
self.log_panels[display_name] = UILog(bridge_logger) self.log_panels[display_name] = UILog(bridge_logger)
if len(self.logging_pairs) > 1: if len(self.logging_pairs) > 1:
panel = MDTabsItem(MDTabsItemText(text=display_name)) self.add_client_tab(display_name, self.log_panels[display_name])
panel.content = self.log_panels[display_name]
# show Archipelago tab if other logging is present
self.tabs.carousel.add_widget(panel.content)
self.tabs.add_widget(panel)
hint_panel = self.add_client_tab("Hints", HintLayout())
self.hint_log = HintLog(self.json_to_kivy_parser) self.hint_log = HintLog(self.json_to_kivy_parser)
hint_panel = self.add_client_tab("Hints", HintLayout(self.hint_log))
self.log_panels["Hints"] = hint_panel.content self.log_panels["Hints"] = hint_panel.content
hint_panel.content.add_widget(self.hint_log)
self.main_area_container = MDGridLayout(size_hint_y=1, rows=1) self.main_area_container = MDGridLayout(size_hint_y=1, cols=1)
self.main_area_container.add_widget(self.tabs) self.main_area_container.add_widget(self.tabs)
self.main_area_container.add_widget(self.screens)
self.grid.add_widget(self.main_area_container) self.grid.add_widget(self.main_area_container)
@@ -974,25 +960,61 @@ class GameManager(ThemedApp):
return self.container return self.container
def add_client_tab(self, title: str, content: Widget, index: int = -1) -> Widget: def add_client_tab(self, title: str, content: Widget, index: int = -1) -> MDNavigationItemBase:
"""Adds a new tab to the client window with a given title, and provides a given Widget as its content. """
Returns the new tab widget, with the provided content being placed on the tab as content.""" Adds a new tab to the client window with a given title, and provides a given Widget as its content.
new_tab = MDTabsItem(MDTabsItemText(text=title)) Returns the new tab widget, with the provided content being placed on the tab as content.
:param title: The title of the tab.
:param content: The Widget to be added as content for this tab's new MDScreen. Will also be added to the
returned tab as tab.content.
:param index: The index to insert the tab at. Defaults to -1, meaning the tab will be appended to the end.
:return: The new tab.
"""
if self.tabs.children:
self.tabs.add_widget(MDDivider(orientation="vertical"))
new_tab = MDNavigationItemBase(text=title)
new_tab.content = content new_tab.content = content
if -1 < index <= len(self.tabs.carousel.slides): new_screen = MDScreen(name=title)
new_tab.bind(on_release=self.tabs.set_active_item) new_screen.add_widget(content)
new_tab._tabs = self.tabs if -1 < index <= len(self.tabs.children):
self.tabs.ids.container.add_widget(new_tab, index=index) remapped_index = len(self.tabs.children) - index
self.tabs.carousel.add_widget(new_tab.content, index=len(self.tabs.carousel.slides) - index) self.tabs.add_widget(new_tab, index=remapped_index)
self.screens.add_widget(new_screen, index=index)
else: else:
self.tabs.add_widget(new_tab) self.tabs.add_widget(new_tab)
self.tabs.carousel.add_widget(new_tab.content) self.screens.add_widget(new_screen)
return new_tab return new_tab
def remove_client_tab(self, tab: MDNavigationItemBase) -> None:
"""
Called to remove a tab and its screen.
:param tab: The tab to remove.
"""
tab_index = self.tabs.children.index(tab)
# if the tab is currently active we need to swap before removing it
if tab == self.screens.current_tab:
if not tab_index:
# account for the divider
swap_index = tab_index + 2
else:
swap_index = tab_index - 2
self.tabs.children[swap_index].on_release()
# self.screens.switch_screens(self.tabs.children[swap_index])
# get the divider to the left if we can
if not tab_index:
divider_index = tab_index + 1
else:
divider_index = tab_index - 1
self.tabs.remove_widget(self.tabs.children[divider_index])
self.tabs.remove_widget(tab)
self.screens.remove_widget(self.screens.get_screen(tab.text))
def update_texts(self, dt): def update_texts(self, dt):
for slide in self.tabs.carousel.slides: if hasattr(self.screens.current_tab.content, "fix_heights"):
if hasattr(slide, "fix_heights"): getattr(self.screens.current_tab.content, "fix_heights")()
slide.fix_heights() # TODO: remove this when Kivy fixes this upstream
if self.ctx.server: if self.ctx.server:
self.title = self.base_title + " " + Utils.__version__ + \ self.title = self.base_title + " " + Utils.__version__ + \
f" | Connected to: {self.ctx.server_address} " \ f" | Connected to: {self.ctx.server_address} " \