# window.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, Gio, GtkSource from .models import HttpRequest, HttpResponse, HistoryEntry from .http_client import HttpClient from .history_manager import HistoryManager from .project_manager import ProjectManager from .icon_picker_dialog import IconPickerDialog from .widgets.header_row import HeaderRow from .widgets.history_item import HistoryItem from .widgets.project_item import ProjectItem from datetime import datetime import threading import json import xml.dom.minidom @Gtk.Template(resource_path='/cz/vesp/roster/main-window.ui') class RosterWindow(Adw.ApplicationWindow): __gtype_name__ = 'RosterWindow' # Top bar widgets method_dropdown = Gtk.Template.Child() url_entry = Gtk.Template.Child() send_button = Gtk.Template.Child() # Panes main_pane = Gtk.Template.Child() split_pane = Gtk.Template.Child() # Sidebar widgets projects_listbox = Gtk.Template.Child() add_project_button = Gtk.Template.Child() save_request_button = Gtk.Template.Child() # Containers for tabs request_tabs_container = Gtk.Template.Child() response_tabs_container = Gtk.Template.Child() # Status bar status_label = Gtk.Template.Child() time_label = Gtk.Template.Child() # History history_listbox = Gtk.Template.Child() def __init__(self, **kwargs): super().__init__(**kwargs) self.http_client = HttpClient() self.history_manager = HistoryManager() self.project_manager = ProjectManager() # Create window actions self._create_actions() # Setup UI self._setup_method_dropdown() self._setup_request_tabs() self._setup_response_tabs() self._load_history() self._load_projects() # Add initial header row self._add_header_row() # Set split pane position to center after window is shown self.connect("map", self._on_window_mapped) def _on_window_mapped(self, widget): """Set split pane position to center when window is mapped.""" # Use idle_add to ensure the widget is fully allocated GLib.idle_add(self._center_split_pane) def _center_split_pane(self): """Center the split pane divider.""" # Get the allocated width of the paned widget allocation = self.split_pane.get_allocation() if allocation.width > 0: self.split_pane.set_position(allocation.width // 2) return False # Don't repeat def _setup_sourceview_theme(self): """Set up GtkSourceView theme based on system color scheme.""" # Get the style manager to detect dark mode style_manager = Adw.StyleManager.get_default() is_dark = style_manager.get_dark() # Get the appropriate color scheme (using classic for more subtle colors) # You can change these to customize the color theme: # - 'classic' / 'classic-dark' (subtle, muted colors) # - 'Adwaita' / 'Adwaita-dark' (GNOME default) # - 'tango' (colorful) # - 'solarized-light' / 'solarized-dark' (popular alternative) # - 'kate' / 'kate-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) # Listen for theme changes style_manager.connect('notify::dark', self._on_theme_changed) def _on_theme_changed(self, style_manager, param): """Handle system theme changes.""" is_dark = style_manager.get_dark() style_scheme_manager = GtkSource.StyleSchemeManager.get_default() scheme_name = 'classic-dark' if is_dark else 'classic' scheme = style_scheme_manager.get_scheme(scheme_name) if scheme: self.response_body_sourceview.get_buffer().set_style_scheme(scheme) def _create_actions(self): """Create window-level actions.""" pass def _setup_method_dropdown(self): """Populate HTTP method dropdown.""" methods = Gtk.StringList() methods.append("GET") methods.append("POST") methods.append("PUT") methods.append("DELETE") self.method_dropdown.set_model(methods) self.method_dropdown.set_selected(0) # Default to GET def _setup_request_tabs(self): """Create request tabs programmatically.""" # Create stack for switching between pages self.request_stack = Gtk.Stack() self.request_stack.set_vexpand(True) # Create stack switcher for the tabs request_switcher = Gtk.StackSwitcher() request_switcher.set_stack(self.request_stack) request_switcher.set_halign(Gtk.Align.CENTER) # Add switcher and stack to container self.request_tabs_container.append(request_switcher) self.request_tabs_container.append(self.request_stack) # Headers tab headers_scroll = Gtk.ScrolledWindow() self.headers_listbox = Gtk.ListBox() self.headers_listbox.add_css_class("boxed-list") headers_scroll.set_child(self.headers_listbox) # Add header button container 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) self.add_header_button = Gtk.Button(label="Add Header") self.add_header_button.connect("clicked", self.on_add_header_clicked) headers_box.append(self.add_header_button) self.request_stack.add_titled(headers_box, "headers", "Headers") # Body tab body_scroll = Gtk.ScrolledWindow() body_scroll.set_vexpand(True) self.body_textview = Gtk.TextView() self.body_textview.set_monospace(True) self.body_textview.set_left_margin(12) self.body_textview.set_right_margin(12) self.body_textview.set_top_margin(12) self.body_textview.set_bottom_margin(12) body_scroll.set_child(self.body_textview) self.request_stack.add_titled(body_scroll, "body", "Body") def _setup_response_tabs(self): """Create response tabs programmatically.""" # Create stack for switching between pages self.response_stack = Gtk.Stack() self.response_stack.set_vexpand(True) # Create stack switcher for the tabs response_switcher = Gtk.StackSwitcher() response_switcher.set_stack(self.response_stack) response_switcher.set_halign(Gtk.Align.CENTER) # Add switcher and stack to container self.response_tabs_container.append(response_switcher) self.response_tabs_container.append(self.response_stack) # Response Headers tab 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 tab with syntax highlighting 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 syntax highlighting with system theme colors self._setup_sourceview_theme() # Set font family and size # You can customize these values: # - font-family: Change to any font (e.g., "Monospace", "JetBrains Mono", "Fira Code") # - font-size: Change to any size (e.g., 10pt, 12pt, 14pt, 16pt) 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") @Gtk.Template.Callback() def on_send_clicked(self, button): """Handle Send button click.""" # Validate URL url = self.url_entry.get_text().strip() if not url: self._show_toast("Please enter a URL") return # Build request from UI request = self._build_request_from_ui() # Disable send button during request self.send_button.set_sensitive(False) self.send_button.set_label("Sending...") self.status_label.set_text("Sending...") self.time_label.set_text("") # Execute in thread to avoid blocking UI thread = threading.Thread(target=self._execute_request_thread, args=(request,)) thread.daemon = True thread.start() def _execute_request_thread(self, request): """Execute request in background thread.""" response, error = self.http_client.execute_request(request) # Update UI in main thread GLib.idle_add(self._handle_response, request, response, error) def _handle_response(self, request, response, error): """Handle response in main thread.""" # Re-enable send button self.send_button.set_sensitive(True) self.send_button.set_label("Send") # Create history entry entry = HistoryEntry( timestamp=datetime.now().isoformat(), request=request, response=response, error=error ) # Save to history self.history_manager.add_entry(entry) # Update UI if response: self._display_response(response) else: self._display_error(error) # Refresh history list self._load_history() def _build_request_from_ui(self): """Build HttpRequest from UI inputs.""" 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: # In GTK4, ListBox wraps children in ListBoxRow, so we need to get the actual child 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_textview.get_buffer() body = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), False) return HttpRequest(method=method, url=url, headers=headers, body=body) def _display_response(self, response): """Display response in UI.""" # 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 with syntax highlighting 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 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) # No syntax highlighting for errors # Show toast self._show_toast(f"Request failed: {error}") def _extract_content_type(self, headers_text): """Extract content-type from response headers.""" if not headers_text: return "" # Parse headers line by line for line in headers_text.split('\n'): line = line.strip() if line.lower().startswith('content-type:'): # Extract value after colon 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() # Map content types to language IDs 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: # JSON formatting 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) # XML formatting 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: # If formatting fails, return original body print(f"Failed to format body: {e}") # Return original for other content types or if formatting failed return body 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) self.headers_listbox.append(row) def _on_header_remove(self, header_row): """Handle header row removal.""" # In GTK4, we need to remove the parent ListBoxRow, not the HeaderRow itself parent = header_row.get_parent() if parent: self.headers_listbox.remove(parent) def _load_history(self): """Load history from file and populate list.""" # Clear existing history items while True: child = self.history_listbox.get_first_child() if child is None: break self.history_listbox.remove(child) # Load and display history entries = self.history_manager.load_history() for entry in entries: item = HistoryItem(entry) item.connect('load-requested', self._on_history_load_requested, entry) self.history_listbox.append(item) def _on_history_load_requested(self, widget, entry): """Handle load request from history item.""" # Show warning dialog dialog = Adw.AlertDialog() dialog.set_heading("Load Request?") dialog.set_body("This will replace your current request. Any unsaved changes will be lost.") dialog.add_response("cancel", "Cancel") dialog.add_response("load", "Load") dialog.set_response_appearance("load", Adw.ResponseAppearance.SUGGESTED) dialog.set_default_response("cancel") dialog.set_close_response("cancel") dialog.connect("response", self._on_load_dialog_response, entry) dialog.present(self) def _on_load_dialog_response(self, dialog, response, entry): """Handle load dialog response.""" if response == "load": self._load_request_from_entry(entry) def _load_request_from_entry(self, entry): """Load request from history entry into UI.""" request = entry.request # 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 existing headers while True: child = self.headers_listbox.get_first_child() if child is None: break self.headers_listbox.remove(child) # Set headers for key, value in request.headers.items(): self._add_header_row(key, value) # Add one empty row if no headers if not request.headers: self._add_header_row() # Set body buffer = self.body_textview.get_buffer() buffer.set_text(request.body) # Switch to headers tab self.request_stack.set_visible_child_name("headers") self._show_toast("Request loaded from history") def _show_toast(self, message): """Show a toast notification.""" toast = Adw.Toast() toast.set_title(message) toast.set_timeout(3) # Get the toast overlay (we need to add one) # For now, just print to console print(f"Toast: {message}") # Project Management Methods def _load_projects(self): """Load and display projects.""" # Clear existing while child := self.projects_listbox.get_first_child(): self.projects_listbox.remove(child) # Load and populate projects = self.project_manager.load_projects() for project in projects: item = ProjectItem(project) item.connect('edit-requested', self._on_project_edit, project) item.connect('delete-requested', self._on_project_delete, project) item.connect('add-request-requested', self._on_add_to_project, project) item.connect('request-load-requested', self._on_load_request) item.connect('request-delete-requested', self._on_delete_request, project) self.projects_listbox.append(item) @Gtk.Template.Callback() def on_add_project_clicked(self, button): """Show dialog to create project.""" dialog = Adw.AlertDialog() dialog.set_heading("New Project") dialog.set_body("Enter a name for the new project:") entry = Gtk.Entry() entry.set_placeholder_text("Project name") dialog.set_extra_child(entry) dialog.add_response("cancel", "Cancel") dialog.add_response("create", "Create") dialog.set_response_appearance("create", Adw.ResponseAppearance.SUGGESTED) dialog.set_default_response("create") dialog.set_close_response("cancel") def on_response(dlg, response): if response == "create": name = entry.get_text().strip() if name: self.project_manager.add_project(name) self._load_projects() dialog.connect("response", on_response) dialog.present(self) def _on_project_edit(self, widget, project): """Show edit project dialog with icon picker.""" dialog = Adw.AlertDialog() dialog.set_heading("Edit Project") dialog.set_body(f"Edit '{project.name}':") box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) # Project name entry name_label = Gtk.Label(label="Name:", xalign=0) entry = Gtk.Entry() entry.set_text(project.name) box.append(name_label) box.append(entry) # Icon selector button icon_label = Gtk.Label(label="Icon:", xalign=0) icon_button = Gtk.Button() icon_button.set_child(Gtk.Image.new_from_icon_name(project.icon)) icon_button.selected_icon = project.icon # Store current selection def on_icon_button_clicked(btn): icon_dialog = IconPickerDialog(current_icon=btn.selected_icon) def on_icon_selected(dlg, icon_name): btn.selected_icon = icon_name btn.set_child(Gtk.Image.new_from_icon_name(icon_name)) icon_dialog.connect('icon-selected', on_icon_selected) icon_dialog.present(self) icon_button.connect('clicked', on_icon_button_clicked) box.append(icon_label) box.append(icon_button) dialog.set_extra_child(box) dialog.add_response("cancel", "Cancel") dialog.add_response("save", "Save") dialog.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED) def on_response(dlg, response): if response == "save": new_name = entry.get_text().strip() new_icon = icon_button.selected_icon if new_name: self.project_manager.update_project(project.id, new_name, new_icon) self._load_projects() dialog.connect("response", on_response) dialog.present(self) def _on_project_delete(self, widget, project): """Show delete confirmation.""" dialog = Adw.AlertDialog() dialog.set_heading("Delete Project?") dialog.set_body(f"Delete '{project.name}' and all its saved requests? This cannot be undone.") dialog.add_response("cancel", "Cancel") dialog.add_response("delete", "Delete") dialog.set_response_appearance("delete", Adw.ResponseAppearance.DESTRUCTIVE) dialog.set_close_response("cancel") def on_response(dlg, response): if response == "delete": self.project_manager.delete_project(project.id) self._load_projects() dialog.connect("response", on_response) dialog.present(self) @Gtk.Template.Callback() def on_save_request_clicked(self, button): """Save current request to a project.""" request = self._build_request_from_ui() if not request.url.strip(): self._show_toast("Cannot save: URL is empty") return projects = self.project_manager.load_projects() if not projects: # Show error - no projects dialog = Adw.AlertDialog() dialog.set_heading("No Projects") dialog.set_body("Create a project first before saving requests.") dialog.add_response("ok", "OK") dialog.present(self) return # Create save dialog dialog = Adw.AlertDialog() dialog.set_heading("Save Request") dialog.set_body("Choose a project and enter a name:") box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) # Project dropdown project_list = Gtk.StringList() for p in projects: project_list.append(p.name) dropdown = Gtk.DropDown(model=project_list) box.append(dropdown) # Name entry entry = Gtk.Entry() entry.set_placeholder_text("Request name") box.append(entry) dialog.set_extra_child(box) dialog.add_response("cancel", "Cancel") dialog.add_response("save", "Save") dialog.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED) def on_response(dlg, response): if response == "save": name = entry.get_text().strip() if name: selected = dropdown.get_selected() project = projects[selected] self.project_manager.add_request(project.id, name, request) self._load_projects() dialog.connect("response", on_response) dialog.present(self) def _on_add_to_project(self, widget, project): """Save current request to specific project.""" request = self._build_request_from_ui() if not request.url.strip(): self._show_toast("Cannot save: URL is empty") return dialog = Adw.AlertDialog() dialog.set_heading(f"Save to {project.name}") dialog.set_body("Enter a name for this request:") entry = Gtk.Entry() entry.set_placeholder_text("Request name") dialog.set_extra_child(entry) dialog.add_response("cancel", "Cancel") dialog.add_response("save", "Save") dialog.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED) def on_response(dlg, response): if response == "save": name = entry.get_text().strip() if name: self.project_manager.add_request(project.id, name, request) self._load_projects() dialog.connect("response", on_response) dialog.present(self) def _on_load_request(self, widget, saved_request): """Load saved request into UI (direct load, no warning).""" req = saved_request.request # Set method methods = ["GET", "POST", "PUT", "DELETE"] if req.method in methods: self.method_dropdown.set_selected(methods.index(req.method)) # Set URL self.url_entry.set_text(req.url) # Clear headers while child := self.headers_listbox.get_first_child(): self.headers_listbox.remove(child) # Set headers for key, value in req.headers.items(): self._add_header_row(key, value) if not req.headers: self._add_header_row() # Set body buffer = self.body_textview.get_buffer() buffer.set_text(req.body) # Switch to headers tab self.request_stack.set_visible_child_name("headers") def _on_delete_request(self, widget, saved_request, project): """Delete saved request with confirmation.""" dialog = Adw.AlertDialog() dialog.set_heading("Delete Request?") dialog.set_body(f"Delete '{saved_request.name}'? This cannot be undone.") dialog.add_response("cancel", "Cancel") dialog.add_response("delete", "Delete") dialog.set_response_appearance("delete", Adw.ResponseAppearance.DESTRUCTIVE) def on_response(dlg, response): if response == "delete": self.project_manager.delete_request(project.id, saved_request.id) self._load_projects() dialog.connect("response", on_response) dialog.present(self)