WIP: Initial AdwTabBar/AdwTabView implementation

This is a work-in-progress implementation of AdwTabBar/AdwTabView.
Current issues:
- Tab bar height is too large
- Large gap on left side of header
- Architecture needs refactoring: AdwTabView should contain per-tab UI widgets

Next step: Properly refactor so each tab has its own complete UI widget tree.
This commit is contained in:
vesp 2025-12-24 01:47:45 +01:00
parent 98e097a7c1
commit 2c4e70fa23
2 changed files with 157 additions and 244 deletions

View File

@ -92,25 +92,29 @@
<object class="GtkBox">
<property name="orientation">vertical</property>
<!-- AdwTabView (hidden - we use shared UI) -->
<child>
<object class="AdwTabView" id="tab_view">
<property name="visible">False</property>
<property name="vexpand">False</property>
<property name="height-request">0</property>
</object>
</child>
<!-- Main Header Bar -->
<child>
<object class="AdwHeaderBar">
<property name="show-title">False</property>
<!-- Tab bar in scrollable container (hidden when only 1 tab) -->
<child type="start">
<object class="GtkScrolledWindow" id="tab_bar_scroll">
<property name="hscrollbar-policy">automatic</property>
<property name="vscrollbar-policy">never</property>
<property name="propagate-natural-width">true</property>
<property name="visible">False</property>
<child>
<object class="GtkBox" id="tab_bar_container">
<property name="orientation">horizontal</property>
<property name="spacing">0</property>
<property name="show-start-title-buttons">False</property>
<property name="show-end-title-buttons">False</property>
<!-- Tab bar as title widget -->
<property name="title-widget">
<object class="AdwTabBar" id="tab_bar">
<property name="view">tab_view</property>
<property name="autohide">True</property>
<property name="expand-tabs">False</property>
</object>
</child>
</object>
</child>
</property>
<!-- Right side buttons -->
<child type="end">
@ -137,6 +141,13 @@
<property name="menu-model">primary_menu</property>
</object>
</child>
<!-- Window Controls -->
<child>
<object class="GtkWindowControls">
<property name="side">end</property>
</object>
</child>
</object>
</child>
</object>

View File

@ -44,8 +44,8 @@ class RosterWindow(Adw.ApplicationWindow):
url_entry = Gtk.Template.Child()
send_button = Gtk.Template.Child()
new_request_button = Gtk.Template.Child()
tab_bar_scroll = Gtk.Template.Child()
tab_bar_container = Gtk.Template.Child()
tab_view = Gtk.Template.Child()
tab_bar = Gtk.Template.Child()
# Panes
main_pane = Gtk.Template.Child()
@ -75,12 +75,14 @@ class RosterWindow(Adw.ApplicationWindow):
self.project_manager = ProjectManager()
self.tab_manager = TabManager()
# Tab tracking - maps tab_id to UI state
self.tab_notebook = Gtk.Notebook()
self.tab_notebook.set_show_tabs(False) # We'll use custom tab bar
self.tab_notebook.set_show_border(False)
# Map AdwTabPage to RequestTab - stores our tab data
self.page_to_tab = {} # AdwTabPage -> RequestTab
self.current_tab_id = None
# Connect to tab view signals
self.tab_view.connect('close-page', self._on_tab_close_page)
self.tab_view.connect('notify::selected-page', self._on_tab_selected)
# Create window actions
self._create_actions()
@ -163,59 +165,12 @@ class RosterWindow(Adw.ApplicationWindow):
self.destroy()
def _setup_custom_css(self):
"""Setup custom CSS for enhanced tab styling."""
"""Setup custom CSS for UI styling."""
css_provider = Gtk.CssProvider()
css_provider.load_from_data(b"""
/* Document tabs in header bar */
.tab-button {
margin: 0 2px;
padding: 0;
border-radius: 6px;
background: alpha(@window_fg_color, 0.08);
transition: all 200ms ease;
}
.tab-button:hover {
background: alpha(@window_fg_color, 0.12);
}
.tab-button-active {
background: alpha(@accent_bg_color, 0.2);
box-shadow: inset 0 -2px 0 0 @accent_bg_color;
}
.tab-button-active:hover {
background: alpha(@accent_bg_color, 0.25);
}
/* Tab label button */
.tab-label-btn {
padding: 4px 12px;
min-height: 28px;
border-radius: 6px 0 0 6px;
}
.tab-button-active .tab-label-btn {
font-weight: 600;
}
/* Tab close button */
.tab-close-btn {
padding: 4px 8px;
min-width: 24px;
min-height: 24px;
margin: 2px 4px 2px 0;
border-radius: 0 6px 6px 0;
opacity: 0.7;
}
.tab-close-btn:hover {
opacity: 1;
background: alpha(@window_fg_color, 0.1);
}
.tab-button-active .tab-close-btn:hover {
background: alpha(@accent_bg_color, 0.3);
/* Constrain tab bar to normal header height */
.tabbar-box {
min-height: 0;
}
/* Stack switchers styling (Headers/Body tabs) */
@ -231,11 +186,6 @@ class RosterWindow(Adw.ApplicationWindow):
color: @accent_fg_color;
font-weight: 600;
}
/* Add some polish to the tab bar container */
#tab_bar_container {
margin: 0 6px;
}
""")
Gtk.StyleContext.add_provider_for_display(
@ -320,17 +270,87 @@ class RosterWindow(Adw.ApplicationWindow):
body_buffer = self.body_sourceview.get_buffer()
body_buffer.connect("changed", self._on_request_changed)
def _on_tab_selected(self, tab_view, param):
"""Handle tab selection change."""
page = tab_view.get_selected_page()
if not page:
return
# Get the RequestTab for this page
tab = self.page_to_tab.get(page)
if tab:
self.current_tab_id = tab.id
self._load_request_to_ui(tab.request)
if tab.response:
self._display_response(tab.response)
else:
# Clear response display
self.status_label.set_text("Ready")
self.time_label.set_text("")
buffer = self.response_headers_textview.get_buffer()
buffer.set_text("")
buffer = self.response_body_sourceview.get_buffer()
buffer.set_text("")
def _on_tab_close_page(self, tab_view, page):
"""Handle tab close request."""
# Get the RequestTab for this page
tab = self.page_to_tab.get(page)
if not tab:
return True # Allow close
# Save current tab state if this is the active tab
if self.current_tab_id == tab.id:
tab.request = self._build_request_from_ui()
# Check if tab has unsaved changes
if tab.is_modified():
# Show warning dialog
dialog = Adw.AlertDialog()
dialog.set_heading("Close Unsaved Request?")
dialog.set_body(f"'{tab.name}' has unsaved changes. Close anyway?")
dialog.add_response("cancel", "Cancel")
dialog.add_response("close", "Close")
dialog.set_response_appearance("close", Adw.ResponseAppearance.DESTRUCTIVE)
dialog.set_default_response("cancel")
dialog.set_close_response("cancel")
def on_response(dlg, response):
if response == "close":
# Remove from our tracking
self.tab_manager.close_tab(tab.id)
del self.page_to_tab[page]
# Close the page
tab_view.close_page_finish(page, True)
else:
# Cancel the close
tab_view.close_page_finish(page, False)
dialog.connect("response", on_response)
dialog.present(self)
return True # Defer close decision to dialog
else:
# No unsaved changes, allow close
self.tab_manager.close_tab(tab.id)
del self.page_to_tab[page]
# If no tabs left, create a new one
if self.tab_view.get_n_pages() == 1: # This page is still counted
GLib.idle_add(self._create_new_tab)
return False # Allow close
def _on_request_changed(self, widget, *args):
"""Track changes to mark tab as modified."""
if self.current_tab_id:
current_tab = self.tab_manager.get_tab_by_id(self.current_tab_id)
if current_tab:
# Check if modified
current_tab.modified = current_tab.is_modified()
# Update the current request in the tab
current_tab.request = self._build_request_from_ui()
# Update tab bar to show modified indicator
self._update_tab_bar_visibility()
# Check if modified
current_tab.modified = current_tab.is_modified()
# Update tab indicator
self._update_tab_indicator(current_tab)
def _is_empty_new_request_tab(self):
"""Check if current tab is an empty 'New Request' tab."""
@ -378,36 +398,8 @@ class RosterWindow(Adw.ApplicationWindow):
return f"{base_name} (copy {counter})"
def _switch_to_tab(self, tab_id):
"""Switch to a specific tab."""
tab = self.tab_manager.get_tab_by_id(tab_id)
if not tab:
return
# Update tab manager
self.tab_manager.set_active_tab(tab_id)
self.current_tab_id = tab_id
# Load the tab's request into UI
self._load_request_to_ui(tab.request)
# Restore response if available
if tab.response:
self._display_response(tab.response)
else:
# Clear response area
self.status_label.set_text("Ready")
self.time_label.set_text("")
buffer = self.response_headers_textview.get_buffer()
buffer.set_text("")
buffer = self.response_body_sourceview.get_buffer()
buffer.set_text("")
# Update tab bar
self._update_tab_bar_visibility()
def _create_new_tab(self, name="New Request", request=None, saved_request_id=None):
"""Create a new tab (simplified version - just clears current request for now)."""
"""Create a new tab using AdwTabView."""
# Create tab in tab manager
if not request:
request = HttpRequest(method="GET", url="", headers={}, body="", syntax="RAW")
@ -415,140 +407,56 @@ class RosterWindow(Adw.ApplicationWindow):
tab = self.tab_manager.create_tab(name, request, saved_request_id)
self.current_tab_id = tab.id
# For simplified version, just load this request into UI
self._load_request_to_ui(request)
# Create a placeholder widget for the tab page
placeholder = Gtk.Box()
# Update tab bar visibility
self._update_tab_bar_visibility()
# Create AdwTabPage
page = self.tab_view.append(placeholder)
page.set_title(name)
page.set_indicator_activatable(False)
# Store mapping
self.page_to_tab[page] = tab
# Update indicator if modified
if tab.is_modified():
page.set_indicator_icon(Gio.ThemedIcon.new("dot-symbolic"))
# Select this new page
self.tab_view.set_selected_page(page)
# Load request into UI (will be triggered by selection change signal)
return tab.id
def _update_tab_bar_visibility(self):
"""Show/hide tab bar based on number of tabs."""
num_tabs = len(self.tab_manager.tabs)
# Show tab bar only if we have 2 or more tabs
self.tab_bar_scroll.set_visible(num_tabs >= 2)
def _update_tab_indicator(self, tab):
"""Update the indicator for a tab based on its modified state."""
# Find the page for this tab
page = None
for p, t in self.page_to_tab.items():
if t.id == tab.id:
page = p
break
# Update tab bar with buttons
if num_tabs >= 2:
# Clear existing children
child = self.tab_bar_container.get_first_child()
while child:
next_child = child.get_next_sibling()
self.tab_bar_container.remove(child)
child = next_child
# Add tab buttons with close button
for tab in self.tab_manager.tabs:
# Create a box for tab label + close button
tab_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
tab_box.add_css_class("tab-button")
# Add active styling if this is the current tab
if tab.id == self.current_tab_id:
tab_box.add_css_class("tab-button-active")
# Tab label with modified indicator
tab_label = tab.name[:25] # Truncate long names
if page:
if tab.is_modified():
tab_label += "" # Add dot for unsaved changes
# Tab label button
tab_label_btn = Gtk.Button(label=tab_label)
tab_label_btn.add_css_class("flat")
tab_label_btn.add_css_class("tab-label-btn")
tab_label_btn.connect("clicked", lambda btn, tid=tab.id: self._switch_to_tab(tid))
tab_box.append(tab_label_btn)
# Close button
close_btn = Gtk.Button()
close_btn.set_icon_name("window-close-symbolic")
close_btn.add_css_class("flat")
close_btn.add_css_class("circular")
close_btn.add_css_class("tab-close-btn")
close_btn.set_tooltip_text("Close tab")
close_btn.connect("clicked", lambda btn, tid=tab.id: self._close_tab(tid))
tab_box.append(close_btn)
self.tab_bar_container.append(tab_box)
def _close_tab(self, tab_id):
"""Close a tab with unsaved change warning."""
# Update current tab state
if self.current_tab_id == tab_id:
current_tab = self.tab_manager.get_tab_by_id(self.current_tab_id)
if current_tab:
current_tab.request = self._build_request_from_ui()
# Check if tab has unsaved changes
tab = self.tab_manager.get_tab_by_id(tab_id)
if tab and tab.is_modified():
# Show warning dialog
dialog = Adw.AlertDialog()
dialog.set_heading("Close Unsaved Request?")
dialog.set_body(f"'{tab.name}' has unsaved changes. Close anyway?")
dialog.add_response("cancel", "Cancel")
dialog.add_response("close", "Close")
dialog.set_response_appearance("close", Adw.ResponseAppearance.DESTRUCTIVE)
dialog.set_default_response("cancel")
dialog.set_close_response("cancel")
dialog.connect("response", lambda d, r: self._on_close_tab_dialog_response(r, tab_id))
dialog.present(self)
page.set_indicator_icon(Gio.ThemedIcon.new("dot-symbolic"))
else:
# No unsaved changes, close directly
self._do_close_tab(tab_id)
def _on_close_tab_dialog_response(self, response, tab_id):
"""Handle close tab dialog response."""
if response == "close":
self._do_close_tab(tab_id)
def _do_close_tab(self, tab_id):
"""Actually close the tab (after confirmation if needed)."""
# Close the tab
success = self.tab_manager.close_tab(tab_id)
if success:
# If we closed the last tab, create a new one
if len(self.tab_manager.tabs) == 0:
self._create_new_tab()
else:
# Switch to the active tab (tab_manager already updated active_tab_id)
active_tab = self.tab_manager.get_active_tab()
if active_tab:
self.current_tab_id = active_tab.id
self._load_request_to_ui(active_tab.request)
if active_tab.response:
self._display_response(active_tab.response)
else:
# Clear response display
self.status_label.set_text("Ready")
self.time_label.set_text("")
buffer = self.response_headers_textview.get_buffer()
buffer.set_text("")
buffer = self.response_body_sourceview.get_buffer()
buffer.set_text("")
# Update tab bar
self._update_tab_bar_visibility()
page.set_indicator_icon(None)
def _switch_to_tab(self, tab_id):
"""Switch to a different tab."""
# Save current tab state
if self.current_tab_id:
current_tab = self.tab_manager.get_tab_by_id(self.current_tab_id)
if current_tab:
current_tab.request = self._build_request_from_ui()
"""Switch to a tab by its ID."""
# Find the page for this tab
for page, tab in self.page_to_tab.items():
if tab.id == tab_id:
self.tab_view.set_selected_page(page)
return
# Load new tab
tab = self.tab_manager.get_tab_by_id(tab_id)
if tab:
self.current_tab_id = tab_id
self._load_request_to_ui(tab.request)
if tab.response:
self._display_response(tab.response)
self._update_tab_bar_visibility()
def _close_current_tab(self):
"""Close the currently active tab."""
page = self.tab_view.get_selected_page()
if page:
self.tab_view.close_page(page)
def _load_request_to_ui(self, request):
"""Load a request into the UI."""
@ -603,11 +511,6 @@ class RosterWindow(Adw.ApplicationWindow):
self.add_action(action)
self.get_application().set_accels_for_action("win.save-request", ["<Control>s"])
def _close_current_tab(self):
"""Close the currently active tab."""
if self.current_tab_id:
self._close_tab(self.current_tab_id)
def _setup_method_dropdown(self):
"""Populate HTTP method dropdown."""
methods = Gtk.StringList()
@ -1060,7 +963,6 @@ class RosterWindow(Adw.ApplicationWindow):
# Load into UI
self._load_request_to_ui(request)
self._update_tab_bar_visibility()
else:
# Current tab has changes or is not a "New Request"
# Create a new tab
@ -1312,8 +1214,8 @@ class RosterWindow(Adw.ApplicationWindow):
syntax=request.syntax
)
# Update tab bar to remove modified indicator
self._update_tab_bar_visibility()
# Update tab indicator to remove modified marker
self._update_tab_indicator(current_tab)
def _show_overwrite_dialog(self, project, name, existing_request_id, request):
"""Show dialog asking if user wants to overwrite existing request."""
@ -1449,7 +1351,7 @@ class RosterWindow(Adw.ApplicationWindow):
if new_tab:
new_tab.original_request = None
new_tab.modified = True
self._update_tab_bar_visibility()
self._update_tab_indicator(new_tab)
# Switch to headers tab
self.request_stack.set_visible_child_name("headers")