Improve TabsManager for adaptive tab visibility and overflow handling

- Updated the constructor to set the resize event handler for dynamic tab visibility.
- Refactored tab visibility logic to adjust based on available page width, moving excess tabs to an overflow menu.
- Enhanced tab addition and removal methods to ensure proper overflow management.
- Updated unit tests to verify adaptive behavior of the overflow menu based on page width changes.
This commit is contained in:
2025-09-28 20:26:03 -05:00
parent e77faa5105
commit a32a542c54
2 changed files with 94 additions and 79 deletions

View File

@@ -18,7 +18,7 @@ class TabsManager:
Handles tab creation, switching, closing, and content rendering. Handles tab creation, switching, closing, and content rendering.
""" """
def __init__(self, page: ft.Page): def __init__(self, page: ft.Page) -> None:
"""Initialize the tab manager. """Initialize the tab manager.
Args: Args:
@@ -28,15 +28,14 @@ class TabsManager:
import ren_browser.app as app_module import ren_browser.app as app_module
self.page = page self.page = page
self.page.on_resize = self._on_resize
self.manager = SimpleNamespace(tabs=[], index=0) self.manager = SimpleNamespace(tabs=[], index=0)
self.tab_bar = ft.Row( self.tab_bar = ft.Row(
spacing=4, spacing=4,
scroll=ft.ScrollMode.AUTO
) )
self.overflow_menu = None self.overflow_menu = None
self.max_visible_tabs = 8
self.content_container = ft.Container( self.content_container = ft.Container(
expand=True, bgcolor=ft.Colors.BLACK, padding=ft.padding.all(5) expand=True, bgcolor=ft.Colors.BLACK, padding=ft.padding.all(5),
) )
default_content = ( default_content = (
@@ -46,45 +45,75 @@ class TabsManager:
) )
self._add_tab_internal("Home", default_content) self._add_tab_internal("Home", default_content)
self.add_btn = ft.IconButton( self.add_btn = ft.IconButton(
ft.Icons.ADD, tooltip="New Tab", on_click=self._on_add_click ft.Icons.ADD, tooltip="New Tab", on_click=self._on_add_click,
) )
self.close_btn = ft.IconButton( self.close_btn = ft.IconButton(
ft.Icons.CLOSE, tooltip="Close Tab", on_click=self._on_close_click ft.Icons.CLOSE, tooltip="Close Tab", on_click=self._on_close_click,
) )
self.tab_bar.controls.append(self.add_btn) self.tab_bar.controls.append(self.add_btn)
self.tab_bar.controls.append(self.close_btn) self.tab_bar.controls.append(self.close_btn)
self.select_tab(0) self.select_tab(0)
self._update_overflow() self._update_tab_visibility()
def _update_overflow(self): def _on_resize(self, e) -> None: # type: ignore
"""Update overflow menu based on number of tabs.""" """Handle page resize event and update tab visibility."""
tab_count = len(self.manager.tabs) self._update_tab_visibility()
def _update_tab_visibility(self) -> None:
"""Dynamically adjust tab visibility based on page width.
Hides tabs that do not fit and moves them to an overflow menu.
"""
if not self.page.width or self.page.width == 0:
return
if self.overflow_menu and self.overflow_menu in self.tab_bar.controls: if self.overflow_menu and self.overflow_menu in self.tab_bar.controls:
self.tab_bar.controls.remove(self.overflow_menu) self.tab_bar.controls.remove(self.overflow_menu)
self.overflow_menu = None self.overflow_menu = None
if tab_count > self.max_visible_tabs: """Estimate available width for tabs (Page width - buttons - padding)."""
available_width = self.page.width - 100
cumulative_width = 0
visible_tabs_count = 0
tab_containers = [c for c in self.tab_bar.controls if isinstance(c, ft.Container)]
for i, tab in enumerate(self.manager.tabs):
"""Estimate tab width: (char count * avg char width) + padding + spacing."""
estimated_width = len(tab["title"]) * 10 + 32 + self.tab_bar.spacing
"""Always show at least one tab."""
if cumulative_width + estimated_width <= available_width or i == 0:
cumulative_width += estimated_width
if i < len(tab_containers):
tab_containers[i].visible = True
visible_tabs_count += 1
elif i < len(tab_containers):
tab_containers[i].visible = False
if len(self.manager.tabs) > visible_tabs_count:
"""Move extra tabs to overflow menu."""
overflow_items = [] overflow_items = []
for i in range(self.max_visible_tabs, tab_count): for i in range(visible_tabs_count, len(self.manager.tabs)):
tab_data = self.manager.tabs[i] tab_data = self.manager.tabs[i]
overflow_items.append( overflow_items.append(
ft.PopupMenuItem( ft.PopupMenuItem(
text=tab_data["title"], text=tab_data["title"],
on_click=lambda e, idx=i: self.select_tab(idx) on_click=lambda e, idx=i: self.select_tab(idx), # type: ignore
) ),
) )
self.overflow_menu = ft.PopupMenuButton( self.overflow_menu = ft.PopupMenuButton(
icon=ft.Icons.MORE_HORIZ, icon=ft.Icons.MORE_HORIZ,
tooltip=f"{tab_count - self.max_visible_tabs} more tabs", tooltip=f"{len(self.manager.tabs) - visible_tabs_count} more tabs",
items=overflow_items items=overflow_items,
) )
insert_pos = len(self.tab_bar.controls) - 2 self.tab_bar.controls.insert(visible_tabs_count, self.overflow_menu)
self.tab_bar.controls.insert(insert_pos, self.overflow_menu)
def _add_tab_internal(self, title: str, content: ft.Control): def _add_tab_internal(self, title: str, content: ft.Control) -> None:
"""Add a new tab to the manager with the given title and content."""
idx = len(self.manager.tabs) idx = len(self.manager.tabs)
url_field = ft.TextField( url_field = ft.TextField(
value=title, value=title,
@@ -111,20 +140,22 @@ class TabsManager:
"go_btn": go_btn, "go_btn": go_btn,
"content_control": content_control, "content_control": content_control,
"content": tab_content, "content": tab_content,
} },
) )
tab_container = ft.Container( tab_container = ft.Container(
content=ft.Text(title), content=ft.Text(title),
on_click=lambda e, i=idx: self.select_tab(i), on_click=lambda e, i=idx: self.select_tab(i), # type: ignore
padding=ft.padding.symmetric(horizontal=12, vertical=6), padding=ft.padding.symmetric(horizontal=12, vertical=6),
border_radius=5, border_radius=5,
bgcolor=ft.Colors.SURFACE_CONTAINER_HIGHEST, bgcolor=ft.Colors.SURFACE_CONTAINER_HIGHEST,
) )
"""Insert the new tab before the add/close buttons."""
insert_pos = max(0, len(self.tab_bar.controls) - 2) insert_pos = max(0, len(self.tab_bar.controls) - 2)
self.tab_bar.controls.insert(insert_pos, tab_container) self.tab_bar.controls.insert(insert_pos, tab_container)
self._update_overflow() self._update_tab_visibility()
def _on_add_click(self, e): def _on_add_click(self, e) -> None: # type: ignore
"""Handle the add tab button click event."""
title = f"Tab {len(self.manager.tabs) + 1}" title = f"Tab {len(self.manager.tabs) + 1}"
content_text = f"Content for {title}" content_text = f"Content for {title}"
import ren_browser.app as app_module import ren_browser.app as app_module
@@ -136,23 +167,30 @@ class TabsManager:
) )
self._add_tab_internal(title, content) self._add_tab_internal(title, content)
self.select_tab(len(self.manager.tabs) - 1) self.select_tab(len(self.manager.tabs) - 1)
self._update_overflow()
self.page.update() self.page.update()
def _on_close_click(self, e): def _on_close_click(self, e) -> None: # type: ignore
"""Handle the close tab button click event."""
if len(self.manager.tabs) <= 1: if len(self.manager.tabs) <= 1:
return return
idx = self.manager.index idx = self.manager.index
tab_containers = [c for c in self.tab_bar.controls if isinstance(c, ft.Container)]
control_to_remove = tab_containers[idx]
self.manager.tabs.pop(idx) self.manager.tabs.pop(idx)
self.tab_bar.controls.pop(idx) self.tab_bar.controls.remove(control_to_remove)
for i, control in enumerate(self.tab_bar.controls[:-2]):
control.on_click = lambda e, i=i: self.select_tab(i) updated_tab_containers = [c for c in self.tab_bar.controls if isinstance(c, ft.Container)]
for i, control in enumerate(updated_tab_containers):
control.on_click = lambda e, i=i: self.select_tab(i) # type: ignore
new_idx = min(idx, len(self.manager.tabs) - 1) new_idx = min(idx, len(self.manager.tabs) - 1)
self.select_tab(new_idx) self.select_tab(new_idx)
self._update_overflow() self._update_tab_visibility()
self.page.update() self.page.update()
def select_tab(self, idx: int): def select_tab(self, idx: int) -> None:
"""Select and display the tab at the given index. """Select and display the tab at the given index.
Args: Args:
@@ -160,15 +198,19 @@ class TabsManager:
""" """
self.manager.index = idx self.manager.index = idx
for i, control in enumerate(self.tab_bar.controls[:-2]):
tab_containers = [c for c in self.tab_bar.controls if isinstance(c, ft.Container)]
for i, control in enumerate(tab_containers):
if i == idx: if i == idx:
control.bgcolor = ft.Colors.PRIMARY_CONTAINER control.bgcolor = ft.Colors.PRIMARY_CONTAINER
else: else:
control.bgcolor = ft.Colors.SURFACE_CONTAINER_HIGHEST control.bgcolor = ft.Colors.SURFACE_CONTAINER_HIGHEST
self.content_container.content = self.manager.tabs[idx]["content"] self.content_container.content = self.manager.tabs[idx]["content"]
self.page.update() self.page.update()
def _on_tab_go(self, e, idx: int): def _on_tab_go(self, e, idx: int) -> None: # type: ignore
"""Handle the go button click event for a tab, loading new content."""
tab = self.manager.tabs[idx] tab = self.manager.tabs[idx]
url = tab["url_field"].value.strip() url = tab["url_field"].value.strip()
if not url: if not url:

View File

@@ -13,6 +13,7 @@ class TestTabsManager:
@pytest.fixture @pytest.fixture
def tabs_manager(self, mock_page): def tabs_manager(self, mock_page):
"""Create a TabsManager instance for testing.""" """Create a TabsManager instance for testing."""
mock_page.width = 800 # Simulate page width for adaptive logic
with ( with (
patch("ren_browser.app.RENDERER", "plaintext"), patch("ren_browser.app.RENDERER", "plaintext"),
patch("ren_browser.renderer.plaintext.render_plaintext") as mock_render, patch("ren_browser.renderer.plaintext.render_plaintext") as mock_render,
@@ -34,9 +35,8 @@ class TestTabsManager:
assert len(manager.manager.tabs) == 1 assert len(manager.manager.tabs) == 1
assert manager.manager.index == 0 assert manager.manager.index == 0
assert isinstance(manager.tab_bar, ft.Row) assert isinstance(manager.tab_bar, ft.Row)
assert manager.tab_bar.scroll == ft.ScrollMode.AUTO assert manager.tab_bar.scroll is None
assert manager.overflow_menu is None assert manager.overflow_menu is None
assert manager.max_visible_tabs == 8
assert isinstance(manager.content_container, ft.Container) assert isinstance(manager.content_container, ft.Container)
def test_tabs_manager_init_micron_renderer(self, mock_page): def test_tabs_manager_init_micron_renderer(self, mock_page):
@@ -237,52 +237,25 @@ class TestTabsManager:
== tabs_manager.manager.tabs[2]["content"] == tabs_manager.manager.tabs[2]["content"]
) )
def test_overflow_menu_creation(self, tabs_manager): def test_adaptive_overflow_behavior(self, tabs_manager):
"""Test that overflow menu is created when tabs exceed max_visible_tabs.""" """Test that the overflow menu adapts to tab changes."""
# Add tabs until we exceed max_visible_tabs (8) # With page width at 800, add enough tabs that some should overflow.
for i in range(8): # Total will be 9 tabs for i in range(10): # Total 11 tabs
tabs_manager._add_tab_internal(f"Tab {i + 2}", Mock())
assert len(tabs_manager.manager.tabs) == 9
assert tabs_manager.overflow_menu is not None
assert isinstance(tabs_manager.overflow_menu, ft.PopupMenuButton)
assert tabs_manager.overflow_menu.icon == ft.Icons.MORE_HORIZ
def test_overflow_menu_items(self, tabs_manager):
"""Test that overflow menu contains correct items."""
# Add tabs to trigger overflow
for i in range(8): # Total will be 9 tabs
tabs_manager._add_tab_internal(f"Tab {i + 2}", Mock())
overflow_items = tabs_manager.overflow_menu.items
assert len(overflow_items) == 1 # Only the 9th tab should be in overflow
assert overflow_items[0].text == "Tab 9"
def test_overflow_menu_removal(self, tabs_manager):
"""Test that overflow menu is removed when tabs are reduced."""
# Add tabs to trigger overflow
for i in range(8): # Total will be 9 tabs
tabs_manager._add_tab_internal(f"Tab {i + 2}", Mock()) tabs_manager._add_tab_internal(f"Tab {i + 2}", Mock())
# Check that an overflow menu exists
assert tabs_manager.overflow_menu is not None assert tabs_manager.overflow_menu is not None
# Remove tabs until we're below the limit # Simulate a smaller screen, expecting more tabs to overflow
for _ in range(2): tabs_manager.page.width = 400
tabs_manager.select_tab(len(tabs_manager.manager.tabs) - 1) tabs_manager._update_tab_visibility()
tabs_manager._on_close_click(None) visible_tabs_small = sum(1 for c in tabs_manager.tab_bar.controls if isinstance(c, ft.Container) and c.visible)
assert visible_tabs_small < 11
assert len(tabs_manager.manager.tabs) == 7 # Simulate a larger screen, expecting all tabs to be visible
tabs_manager.page.width = 1600
tabs_manager._update_tab_visibility()
visible_tabs_large = sum(1 for c in tabs_manager.tab_bar.controls if isinstance(c, ft.Container) and c.visible)
assert visible_tabs_large == 11
assert tabs_manager.overflow_menu is None assert tabs_manager.overflow_menu is None
def test_overflow_menu_select_tab(self, tabs_manager):
"""Test selecting a tab from overflow menu."""
# Add tabs to trigger overflow
for i in range(8): # Total will be 9 tabs
tabs_manager._add_tab_internal(f"Tab {i + 2}", Mock())
# Simulate clicking overflow menu item for tab at index 8
overflow_item = tabs_manager.overflow_menu.items[0]
# The on_click handler should select tab at index 8
overflow_item.on_click(None) # This should call select_tab(8)
assert tabs_manager.manager.index == 8