From 99faf149839e7b09afab7b7860dd73975c51e500 Mon Sep 17 00:00:00 2001 From: Pavel Baksy Date: Wed, 24 Dec 2025 01:51:15 +0100 Subject: [PATCH] Add RequestTabWidget class and simplify main UI - Create RequestTabWidget class with complete per-tab UI - Simplify main-window.ui to just AdwTabBar and AdwTabView - Set autohide=False to always show tabs - Add request_tab_widget.py to meson.build Next step: Refactor window.py to create RequestTabWidget instances for each tab. --- src/main-window.ui | 196 +------------- src/meson.build | 1 + src/request_tab_widget.py | 556 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 565 insertions(+), 188 deletions(-) create mode 100644 src/request_tab_widget.py diff --git a/src/main-window.ui b/src/main-window.ui index f702db5..91bc8d3 100644 --- a/src/main-window.ui +++ b/src/main-window.ui @@ -92,15 +92,6 @@ vertical - - - - False - False - 0 - - - @@ -111,7 +102,7 @@ tab_view - True + False False @@ -153,188 +144,17 @@ - + - - vertical - - - - - 1000 - - - horizontal - 12 - 12 - 12 - 12 - 12 - - - - - False - - - - - - - Enter URL... - True - - - - - - - Send - - - - - - - - - - - - - vertical + True - 600 - False - True - True - True - - - - - horizontal - 600 - False - False - True - True - - - - - vertical - - - - - vertical - True - - - - - - - - - vertical - - - - - vertical - True - - - - - - - horizontal - 12 - 12 - 12 - 6 - 6 - - - - Ready - - - - - - - - - - - - - - True - - - - - - - - - - - - - vertical - - - - - horizontal - 12 - 12 - 12 - 6 - - - - Request History - True - 0 - - - - - - - - - - True - 150 - - - - - - - - - - - + + + + False + diff --git a/src/meson.build b/src/meson.build index 0de6bb2..39ad5ec 100644 --- a/src/meson.build +++ b/src/meson.build @@ -38,6 +38,7 @@ roster_sources = [ 'constants.py', 'icon_picker_dialog.py', 'preferences_dialog.py', + 'request_tab_widget.py', ] install_data(roster_sources, install_dir: moduledir) diff --git a/src/request_tab_widget.py b/src/request_tab_widget.py new file mode 100644 index 0000000..0e8bc00 --- /dev/null +++ b/src/request_tab_widget.py @@ -0,0 +1,556 @@ +# request_tab_widget.py +# +# Copyright 2025 Pavel Baksy +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import gi +gi.require_version('GtkSource', '5') +from gi.repository import Adw, Gtk, GLib, GtkSource +from .models import HttpRequest, HttpResponse +from .widgets.header_row import HeaderRow +import json +import xml.dom.minidom + + +class RequestTabWidget(Gtk.Box): + """Widget representing a single request tab's UI.""" + + def __init__(self, tab_id, request=None, response=None, **kwargs): + super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs) + + self.tab_id = tab_id + self.request = request or HttpRequest(method="GET", url="", headers={}, body="", syntax="RAW") + self.response = response + self.modified = False + self.original_request = None + + # Build the UI + self._build_ui() + + # Load initial request + if request: + self._load_request(request) + + # Setup change tracking + self._setup_change_tracking() + + def _build_ui(self): + """Build the complete UI for this tab.""" + + # URL Input Section + url_clamp = Adw.Clamp(maximum_size=1000) + url_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + url_box.set_margin_start(12) + url_box.set_margin_end(12) + url_box.set_margin_top(12) + url_box.set_margin_bottom(12) + + # Method Dropdown + self.method_dropdown = Gtk.DropDown() + methods = Gtk.StringList() + for method in ["GET", "POST", "PUT", "DELETE"]: + methods.append(method) + self.method_dropdown.set_model(methods) + self.method_dropdown.set_selected(0) + self.method_dropdown.set_enable_search(False) + url_box.append(self.method_dropdown) + + # URL Entry + self.url_entry = Gtk.Entry() + self.url_entry.set_placeholder_text("Enter URL...") + self.url_entry.set_hexpand(True) + url_box.append(self.url_entry) + + # Send Button + self.send_button = Gtk.Button(label="Send") + self.send_button.add_css_class("suggested-action") + url_box.append(self.send_button) + + url_clamp.set_child(url_box) + self.append(url_clamp) + + # Vertical Paned: Main Content | History Panel + vpane = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL) + vpane.set_vexpand(True) + vpane.set_position(600) + vpane.set_shrink_start_child(False) + vpane.set_shrink_end_child(True) + vpane.set_resize_start_child(True) + vpane.set_resize_end_child(True) + + # Horizontal Split: Request | Response + split_pane = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL) + split_pane.set_position(600) + split_pane.set_shrink_start_child(False) + split_pane.set_shrink_end_child(False) + split_pane.set_resize_start_child(True) + split_pane.set_resize_end_child(True) + + # Request Panel + request_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.request_stack = Gtk.Stack() + self.request_stack.set_vexpand(True) + self.request_stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE) + self.request_stack.set_transition_duration(150) + + request_switcher = Gtk.StackSwitcher() + request_switcher.set_stack(self.request_stack) + request_switcher.set_halign(Gtk.Align.CENTER) + request_switcher.set_margin_top(8) + request_switcher.set_margin_bottom(8) + + request_box.append(request_switcher) + request_box.append(self.request_stack) + + # Headers tab + self._build_headers_tab() + + # Body tab + self._build_body_tab() + + split_pane.set_start_child(request_box) + + # Response Panel + response_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.response_stack = Gtk.Stack() + self.response_stack.set_vexpand(True) + self.response_stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE) + self.response_stack.set_transition_duration(150) + + response_switcher = Gtk.StackSwitcher() + response_switcher.set_stack(self.response_stack) + response_switcher.set_halign(Gtk.Align.CENTER) + response_switcher.set_margin_top(8) + response_switcher.set_margin_bottom(8) + + response_box.append(response_switcher) + response_box.append(self.response_stack) + + # Response headers + headers_scroll = Gtk.ScrolledWindow() + headers_scroll.set_vexpand(True) + self.response_headers_textview = Gtk.TextView() + self.response_headers_textview.set_editable(False) + self.response_headers_textview.set_monospace(True) + self.response_headers_textview.set_left_margin(12) + self.response_headers_textview.set_right_margin(12) + self.response_headers_textview.set_top_margin(12) + self.response_headers_textview.set_bottom_margin(12) + headers_scroll.set_child(self.response_headers_textview) + self.response_stack.add_titled(headers_scroll, "headers", "Headers") + + # Response body + body_scroll = Gtk.ScrolledWindow() + body_scroll.set_vexpand(True) + self.response_body_sourceview = GtkSource.View() + self.response_body_sourceview.set_editable(False) + self.response_body_sourceview.set_show_line_numbers(True) + self.response_body_sourceview.set_highlight_current_line(True) + self.response_body_sourceview.set_left_margin(12) + self.response_body_sourceview.set_right_margin(12) + self.response_body_sourceview.set_top_margin(12) + self.response_body_sourceview.set_bottom_margin(12) + + # Set up theme + self._setup_sourceview_theme() + + # Set font + css_provider = Gtk.CssProvider() + css_provider.load_from_data(b""" + textview { + font-family: "Source Code Pro"; + font-size: 12pt; + line-height: 1.2; + } + """) + self.response_body_sourceview.get_style_context().add_provider( + css_provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + + body_scroll.set_child(self.response_body_sourceview) + self.response_stack.add_titled(body_scroll, "body", "Body") + + # Status Bar + status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + status_box.set_margin_start(12) + status_box.set_margin_end(12) + status_box.set_margin_top(6) + status_box.set_margin_bottom(6) + + self.status_label = Gtk.Label(label="Ready") + self.status_label.add_css_class("heading") + status_box.append(self.status_label) + + self.time_label = Gtk.Label(label="") + status_box.append(self.time_label) + + # Spacer + spacer = Gtk.Box() + spacer.set_hexpand(True) + status_box.append(spacer) + + response_box.append(status_box) + + split_pane.set_end_child(response_box) + + vpane.set_start_child(split_pane) + + # History Panel (placeholder for now) + history_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + history_label = Gtk.Label(label="Request History") + history_label.add_css_class("heading") + history_label.set_margin_start(12) + history_label.set_margin_end(12) + history_label.set_margin_top(6) + history_label.set_xalign(0) + history_box.append(history_label) + + vpane.set_end_child(history_box) + + self.append(vpane) + + def _build_headers_tab(self): + """Build the headers tab.""" + headers_scroll = Gtk.ScrolledWindow() + headers_scroll.set_vexpand(True) + headers_scroll.set_min_content_height(150) + + self.headers_listbox = Gtk.ListBox() + self.headers_listbox.add_css_class("boxed-list") + headers_scroll.set_child(self.headers_listbox) + + headers_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + headers_box.set_margin_start(12) + headers_box.set_margin_end(12) + headers_box.set_margin_top(12) + headers_box.set_margin_bottom(12) + headers_box.append(headers_scroll) + + add_header_button = Gtk.Button(label="Add Header") + add_header_button.connect("clicked", self._on_add_header_clicked) + headers_box.append(add_header_button) + + self.request_stack.add_titled(headers_box, "headers", "Headers") + + # Add initial empty header row + self._add_header_row() + + def _build_body_tab(self): + """Build the body tab.""" + body_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + + body_scroll = Gtk.ScrolledWindow() + body_scroll.set_vexpand(True) + + self.body_sourceview = GtkSource.View() + self.body_sourceview.set_editable(True) + self.body_sourceview.set_show_line_numbers(True) + self.body_sourceview.set_highlight_current_line(True) + self.body_sourceview.set_left_margin(12) + self.body_sourceview.set_right_margin(12) + self.body_sourceview.set_top_margin(12) + self.body_sourceview.set_bottom_margin(12) + + # Set font + css_provider = Gtk.CssProvider() + css_provider.load_from_data(b""" + textview { + font-family: "Source Code Pro"; + font-size: 12pt; + line-height: 1.2; + } + """) + self.body_sourceview.get_style_context().add_provider( + css_provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + + # Set up theme + self._setup_request_body_theme() + + body_scroll.set_child(self.body_sourceview) + body_box.append(body_scroll) + + # Bottom toolbar + toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + toolbar.set_margin_start(12) + toolbar.set_margin_end(12) + toolbar.set_margin_top(6) + toolbar.set_margin_bottom(6) + + # Spacer + spacer = Gtk.Box() + spacer.set_hexpand(True) + toolbar.append(spacer) + + # Language selector + lang_label = Gtk.Label(label="Syntax:") + toolbar.append(lang_label) + + lang_list = Gtk.StringList() + for lang in ["RAW", "JSON", "XML"]: + lang_list.append(lang) + + self.body_language_dropdown = Gtk.DropDown(model=lang_list) + self.body_language_dropdown.set_selected(0) + self.body_language_dropdown.connect("notify::selected", self._on_request_body_language_changed) + toolbar.append(self.body_language_dropdown) + + body_box.append(toolbar) + + self.request_stack.add_titled(body_box, "body", "Body") + + def _setup_sourceview_theme(self): + """Set up GtkSourceView theme based on system color scheme.""" + style_manager = Adw.StyleManager.get_default() + is_dark = style_manager.get_dark() + + style_scheme_manager = GtkSource.StyleSchemeManager.get_default() + scheme_name = 'Adwaita-dark' if is_dark else 'Adwaita' + scheme = style_scheme_manager.get_scheme(scheme_name) + + if scheme: + self.response_body_sourceview.get_buffer().set_style_scheme(scheme) + + def _setup_request_body_theme(self): + """Set up GtkSourceView theme for request body.""" + style_manager = Adw.StyleManager.get_default() + is_dark = style_manager.get_dark() + + style_scheme_manager = GtkSource.StyleSchemeManager.get_default() + scheme_name = 'Adwaita-dark' if is_dark else 'Adwaita' + scheme = style_scheme_manager.get_scheme(scheme_name) + + if scheme: + self.body_sourceview.get_buffer().set_style_scheme(scheme) + + def _on_request_body_language_changed(self, dropdown, param): + """Handle request body language selection change.""" + selected = dropdown.get_selected() + language_manager = GtkSource.LanguageManager.get_default() + + buffer = self.body_sourceview.get_buffer() + + if selected == 0: # RAW + buffer.set_language(None) + elif selected == 1: # JSON + language = language_manager.get_language('json') + buffer.set_language(language) + elif selected == 2: # XML + language = language_manager.get_language('xml') + buffer.set_language(language) + + def _on_add_header_clicked(self, button): + """Add new header row.""" + self._add_header_row() + + def _add_header_row(self, key='', value=''): + """Add a header row to the list.""" + row = HeaderRow() + row.set_header(key, value) + row.connect('remove-requested', self._on_header_remove) + row.connect('changed', self._on_request_changed) + self.headers_listbox.append(row) + + if key or value: + self._on_request_changed(None) + + def _on_header_remove(self, header_row): + """Handle header row removal.""" + parent = header_row.get_parent() + if parent: + self.headers_listbox.remove(parent) + self._on_request_changed(None) + + def _setup_change_tracking(self): + """Setup change tracking for this tab.""" + self.url_entry.connect("changed", self._on_request_changed) + self.method_dropdown.connect("notify::selected", self._on_request_changed) + self.body_language_dropdown.connect("notify::selected", self._on_request_changed) + + body_buffer = self.body_sourceview.get_buffer() + body_buffer.connect("changed", self._on_request_changed) + + def _on_request_changed(self, widget, *args): + """Mark this tab as modified.""" + if not self.original_request: + return + + current = self.get_request() + self.modified = self._is_different_from_original(current) + + def _is_different_from_original(self, request): + """Check if current request differs from original.""" + if not self.original_request: + return False + + return ( + request.method != self.original_request.method or + request.url != self.original_request.url or + request.body != self.original_request.body or + request.headers != self.original_request.headers or + request.syntax != self.original_request.syntax + ) + + def _load_request(self, request): + """Load a request into this tab's UI.""" + # Set method + methods = ["GET", "POST", "PUT", "DELETE"] + if request.method in methods: + self.method_dropdown.set_selected(methods.index(request.method)) + + # Set URL + self.url_entry.set_text(request.url) + + # Clear and set headers + while child := self.headers_listbox.get_first_child(): + self.headers_listbox.remove(child) + + for key, value in request.headers.items(): + self._add_header_row(key, value) + + if not request.headers: + self._add_header_row() + + # Set body + buffer = self.body_sourceview.get_buffer() + buffer.set_text(request.body) + + # Set syntax + syntax_options = ["RAW", "JSON", "XML"] + if request.syntax in syntax_options: + self.body_language_dropdown.set_selected(syntax_options.index(request.syntax)) + + def get_request(self): + """Build and return HttpRequest from current UI state.""" + method = self.method_dropdown.get_selected_item().get_string() + url = self.url_entry.get_text().strip() + + # Collect headers + headers = {} + child = self.headers_listbox.get_first_child() + while child is not None: + if isinstance(child, Gtk.ListBoxRow): + header_row = child.get_child() + if isinstance(header_row, HeaderRow): + key, value = header_row.get_header() + if key and value: + headers[key] = value + child = child.get_next_sibling() + + # Get body + buffer = self.body_sourceview.get_buffer() + body = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), False) + + # Get syntax + syntax_index = self.body_language_dropdown.get_selected() + syntax_options = ["RAW", "JSON", "XML"] + syntax = syntax_options[syntax_index] + + return HttpRequest(method=method, url=url, headers=headers, body=body, syntax=syntax) + + def display_response(self, response): + """Display response in this tab's UI.""" + self.response = response + + # Update status + status_text = f"{response.status_code} {response.status_text}" + self.status_label.set_text(status_text) + + # Update time + time_text = f"{response.response_time_ms:.0f} ms" + self.time_label.set_text(time_text) + + # Update response headers + buffer = self.response_headers_textview.get_buffer() + buffer.set_text(response.headers) + + # Extract content-type and format body + content_type = self._extract_content_type(response.headers) + formatted_body = self._format_response_body(response.body, content_type) + + # Update response body + source_buffer = self.response_body_sourceview.get_buffer() + source_buffer.set_text(formatted_body) + + # Set language for syntax highlighting + language = self._get_language_from_content_type(content_type) + source_buffer.set_language(language) + + def display_error(self, error): + """Display error in this tab's UI.""" + self.status_label.set_text("Error") + self.time_label.set_text("") + + # Clear response headers + buffer = self.response_headers_textview.get_buffer() + buffer.set_text("") + + # Display error in body + source_buffer = self.response_body_sourceview.get_buffer() + source_buffer.set_text(error) + source_buffer.set_language(None) + + def _extract_content_type(self, headers_text): + """Extract content-type from response headers.""" + if not headers_text: + return "" + + for line in headers_text.split('\n'): + line = line.strip() + if line.lower().startswith('content-type:'): + return line.split(':', 1)[1].strip().lower() + + return "" + + def _get_language_from_content_type(self, content_type): + """Get GtkSourceView language ID from content type.""" + if not content_type: + return None + + language_manager = GtkSource.LanguageManager.get_default() + + if 'application/json' in content_type or 'text/json' in content_type: + return language_manager.get_language('json') + elif 'application/xml' in content_type or 'text/xml' in content_type: + return language_manager.get_language('xml') + elif 'text/html' in content_type: + return language_manager.get_language('html') + elif 'application/javascript' in content_type or 'text/javascript' in content_type: + return language_manager.get_language('js') + elif 'text/css' in content_type: + return language_manager.get_language('css') + + return None + + def _format_response_body(self, body, content_type): + """Format response body based on content type.""" + if not body or not body.strip(): + return body + + try: + if 'application/json' in content_type or 'text/json' in content_type: + parsed = json.loads(body) + return json.dumps(parsed, indent=2, ensure_ascii=False) + elif 'application/xml' in content_type or 'text/xml' in content_type: + dom = xml.dom.minidom.parseString(body) + return dom.toprettyxml(indent=" ") + except Exception as e: + print(f"Failed to format body: {e}") + + return body