From e77faa51053b8068d447899170ed2a06f0ec2f6b Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 28 Sep 2025 19:50:21 -0500 Subject: [PATCH 1/2] Update TabsManager with overflow menu functionality - Added support for an overflow menu in the tab bar when the number of tabs exceeds the maximum visible limit. - Updated tab bar initialization to include scroll mode and maximum visible tabs. - Refactored tab addition logic to ensure proper overflow handling. --- ren_browser/tabs/tabs.py | 46 ++++++++++++++++++++++++++++++--- tests/unit/test_tabs.py | 55 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/ren_browser/tabs/tabs.py b/ren_browser/tabs/tabs.py index 67300cb..c4df837 100644 --- a/ren_browser/tabs/tabs.py +++ b/ren_browser/tabs/tabs.py @@ -29,7 +29,12 @@ class TabsManager: self.page = page self.manager = SimpleNamespace(tabs=[], index=0) - self.tab_bar = ft.Row(spacing=4) + self.tab_bar = ft.Row( + spacing=4, + scroll=ft.ScrollMode.AUTO + ) + self.overflow_menu = None + self.max_visible_tabs = 8 self.content_container = ft.Container( expand=True, bgcolor=ft.Colors.BLACK, padding=ft.padding.all(5) ) @@ -46,8 +51,38 @@ class TabsManager: self.close_btn = ft.IconButton( ft.Icons.CLOSE, tooltip="Close Tab", on_click=self._on_close_click ) - self.tab_bar.controls.extend([self.add_btn, self.close_btn]) + self.tab_bar.controls.append(self.add_btn) + self.tab_bar.controls.append(self.close_btn) self.select_tab(0) + self._update_overflow() + + def _update_overflow(self): + """Update overflow menu based on number of tabs.""" + tab_count = len(self.manager.tabs) + + if self.overflow_menu and self.overflow_menu in self.tab_bar.controls: + self.tab_bar.controls.remove(self.overflow_menu) + self.overflow_menu = None + + if tab_count > self.max_visible_tabs: + overflow_items = [] + for i in range(self.max_visible_tabs, tab_count): + tab_data = self.manager.tabs[i] + overflow_items.append( + ft.PopupMenuItem( + text=tab_data["title"], + on_click=lambda e, idx=i: self.select_tab(idx) + ) + ) + + self.overflow_menu = ft.PopupMenuButton( + icon=ft.Icons.MORE_HORIZ, + tooltip=f"{tab_count - self.max_visible_tabs} more tabs", + items=overflow_items + ) + + insert_pos = len(self.tab_bar.controls) - 2 + self.tab_bar.controls.insert(insert_pos, self.overflow_menu) def _add_tab_internal(self, title: str, content: ft.Control): idx = len(self.manager.tabs) @@ -78,7 +113,7 @@ class TabsManager: "content": tab_content, } ) - btn = ft.Container( + tab_container = ft.Container( content=ft.Text(title), on_click=lambda e, i=idx: self.select_tab(i), padding=ft.padding.symmetric(horizontal=12, vertical=6), @@ -86,7 +121,8 @@ class TabsManager: bgcolor=ft.Colors.SURFACE_CONTAINER_HIGHEST, ) insert_pos = max(0, len(self.tab_bar.controls) - 2) - self.tab_bar.controls.insert(insert_pos, btn) + self.tab_bar.controls.insert(insert_pos, tab_container) + self._update_overflow() def _on_add_click(self, e): title = f"Tab {len(self.manager.tabs) + 1}" @@ -100,6 +136,7 @@ class TabsManager: ) self._add_tab_internal(title, content) self.select_tab(len(self.manager.tabs) - 1) + self._update_overflow() self.page.update() def _on_close_click(self, e): @@ -112,6 +149,7 @@ class TabsManager: control.on_click = lambda e, i=i: self.select_tab(i) new_idx = min(idx, len(self.manager.tabs) - 1) self.select_tab(new_idx) + self._update_overflow() self.page.update() def select_tab(self, idx: int): diff --git a/tests/unit/test_tabs.py b/tests/unit/test_tabs.py index 13dcedf..4f8bc68 100644 --- a/tests/unit/test_tabs.py +++ b/tests/unit/test_tabs.py @@ -34,6 +34,9 @@ class TestTabsManager: assert len(manager.manager.tabs) == 1 assert manager.manager.index == 0 assert isinstance(manager.tab_bar, ft.Row) + assert manager.tab_bar.scroll == ft.ScrollMode.AUTO + assert manager.overflow_menu is None + assert manager.max_visible_tabs == 8 assert isinstance(manager.content_container, ft.Container) def test_tabs_manager_init_micron_renderer(self, mock_page): @@ -150,7 +153,7 @@ class TestTabsManager: """Test that tab bar has correct controls.""" controls = tabs_manager.tab_bar.controls - # Should have: home tab, add button, close button + # Should have: home tab, add button, close button (and potentially overflow menu) assert len(controls) >= 3 assert isinstance(controls[-2], ft.IconButton) # Add button assert isinstance(controls[-1], ft.IconButton) # Close button @@ -233,3 +236,53 @@ class TestTabsManager: tabs_manager.content_container.content == tabs_manager.manager.tabs[2]["content"] ) + + def test_overflow_menu_creation(self, tabs_manager): + """Test that overflow menu is created when tabs exceed max_visible_tabs.""" + # Add tabs until we exceed max_visible_tabs (8) + for i in range(8): # Total will be 9 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()) + + assert tabs_manager.overflow_menu is not None + + # Remove tabs until we're below the limit + for _ in range(2): + tabs_manager.select_tab(len(tabs_manager.manager.tabs) - 1) + tabs_manager._on_close_click(None) + + assert len(tabs_manager.manager.tabs) == 7 + 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 From a32a542c543de267d730a748a8e6d0e13c0189eb Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 28 Sep 2025 20:26:03 -0500 Subject: [PATCH 2/2] 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. --- ren_browser/tabs/tabs.py | 106 +++++++++++++++++++++++++++------------ tests/unit/test_tabs.py | 67 ++++++++----------------- 2 files changed, 94 insertions(+), 79 deletions(-) diff --git a/ren_browser/tabs/tabs.py b/ren_browser/tabs/tabs.py index c4df837..ccedd6e 100644 --- a/ren_browser/tabs/tabs.py +++ b/ren_browser/tabs/tabs.py @@ -18,7 +18,7 @@ class TabsManager: 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. Args: @@ -28,15 +28,14 @@ class TabsManager: import ren_browser.app as app_module self.page = page + self.page.on_resize = self._on_resize self.manager = SimpleNamespace(tabs=[], index=0) self.tab_bar = ft.Row( spacing=4, - scroll=ft.ScrollMode.AUTO ) self.overflow_menu = None - self.max_visible_tabs = 8 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 = ( @@ -46,45 +45,75 @@ class TabsManager: ) self._add_tab_internal("Home", default_content) 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( - 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.close_btn) self.select_tab(0) - self._update_overflow() + self._update_tab_visibility() - def _update_overflow(self): - """Update overflow menu based on number of tabs.""" - tab_count = len(self.manager.tabs) + def _on_resize(self, e) -> None: # type: ignore + """Handle page resize event and update tab visibility.""" + 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: self.tab_bar.controls.remove(self.overflow_menu) 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 = [] - 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] overflow_items.append( ft.PopupMenuItem( 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( icon=ft.Icons.MORE_HORIZ, - tooltip=f"{tab_count - self.max_visible_tabs} more tabs", - items=overflow_items + tooltip=f"{len(self.manager.tabs) - visible_tabs_count} more tabs", + items=overflow_items, ) - insert_pos = len(self.tab_bar.controls) - 2 - self.tab_bar.controls.insert(insert_pos, self.overflow_menu) + self.tab_bar.controls.insert(visible_tabs_count, 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) url_field = ft.TextField( value=title, @@ -111,20 +140,22 @@ class TabsManager: "go_btn": go_btn, "content_control": content_control, "content": tab_content, - } + }, ) tab_container = ft.Container( 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), border_radius=5, 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) 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}" content_text = f"Content for {title}" import ren_browser.app as app_module @@ -136,23 +167,30 @@ class TabsManager: ) self._add_tab_internal(title, content) self.select_tab(len(self.manager.tabs) - 1) - self._update_overflow() 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: return 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.tab_bar.controls.pop(idx) - for i, control in enumerate(self.tab_bar.controls[:-2]): - control.on_click = lambda e, i=i: self.select_tab(i) + self.tab_bar.controls.remove(control_to_remove) + + 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) self.select_tab(new_idx) - self._update_overflow() + self._update_tab_visibility() 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. Args: @@ -160,15 +198,19 @@ class TabsManager: """ 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: control.bgcolor = ft.Colors.PRIMARY_CONTAINER else: control.bgcolor = ft.Colors.SURFACE_CONTAINER_HIGHEST + self.content_container.content = self.manager.tabs[idx]["content"] 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] url = tab["url_field"].value.strip() if not url: diff --git a/tests/unit/test_tabs.py b/tests/unit/test_tabs.py index 4f8bc68..027e9db 100644 --- a/tests/unit/test_tabs.py +++ b/tests/unit/test_tabs.py @@ -13,6 +13,7 @@ class TestTabsManager: @pytest.fixture def tabs_manager(self, mock_page): """Create a TabsManager instance for testing.""" + mock_page.width = 800 # Simulate page width for adaptive logic with ( patch("ren_browser.app.RENDERER", "plaintext"), patch("ren_browser.renderer.plaintext.render_plaintext") as mock_render, @@ -34,9 +35,8 @@ class TestTabsManager: assert len(manager.manager.tabs) == 1 assert manager.manager.index == 0 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.max_visible_tabs == 8 assert isinstance(manager.content_container, ft.Container) def test_tabs_manager_init_micron_renderer(self, mock_page): @@ -237,52 +237,25 @@ class TestTabsManager: == tabs_manager.manager.tabs[2]["content"] ) - def test_overflow_menu_creation(self, tabs_manager): - """Test that overflow menu is created when tabs exceed max_visible_tabs.""" - # Add tabs until we exceed max_visible_tabs (8) - for i in range(8): # Total will be 9 tabs + def test_adaptive_overflow_behavior(self, tabs_manager): + """Test that the overflow menu adapts to tab changes.""" + # With page width at 800, add enough tabs that some should overflow. + 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 + + # Check that an overflow menu exists 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 + + # Simulate a smaller screen, expecting more tabs to overflow + tabs_manager.page.width = 400 + tabs_manager._update_tab_visibility() + 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 - 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()) - - assert tabs_manager.overflow_menu is not None - - # Remove tabs until we're below the limit - for _ in range(2): - tabs_manager.select_tab(len(tabs_manager.manager.tabs) - 1) - tabs_manager._on_close_click(None) - - 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 - - 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