From 3cc96a82d6a942c6acf9eeec02e98de3f17b5f38 Mon Sep 17 00:00:00 2001 From: Pavel Baksy Date: Thu, 18 Dec 2025 15:37:29 +0100 Subject: [PATCH] Add foldable sidebar with custom project icons Implement a collapsible sidebar that can be folded to show just project icons in a slim strip, or expanded to show full project details. Projects can now have custom icons selected from a 6x6 grid of 36 symbolic icons. Features: - Foldable sidebar with toggle button (fold/unfold) - Slim sidebar view showing only project icons when folded - Custom project icons with 36 symbolic icon choices - Icon picker dialog with 6x6 grid layout - Edit Project dialog (renamed from Rename) with name and icon selection - Project icons displayed in both full and slim sidebar views - Smooth transitions between folded/unfolded states - Click on slim project icon to unfold sidebar Technical changes: - Add icon field to Project model with default "folder-symbolic" - Create constants.py with PROJECT_ICONS list (36 symbolic icons) - Implement IconPickerDialog with grid layout and selection - Create SlimProjectItem widget for folded sidebar view - Update ProjectManager.update_project() to handle icon changes - Restructure sidebar using GtkStack for full/slim view switching - Update project-item.ui to display project icon - Change "Rename" menu to "Edit Project" with icon picker - Add fold/unfold buttons with sidebar-show icons - Update build system with new files and resources --- src/constants.py | 69 ++++++++++++ src/icon-picker-dialog.ui | 56 ++++++++++ src/icon_picker_dialog.py | 70 +++++++++++++ src/main-window.ui | 173 +++++++++++++++++++++---------- src/meson.build | 3 + src/models.py | 7 +- src/project_manager.py | 9 +- src/roster.gresource.xml | 2 + src/widgets/__init__.py | 3 +- src/widgets/project-item.ui | 10 +- src/widgets/project_item.py | 8 +- src/widgets/slim-project-item.ui | 21 ++++ src/widgets/slim_project_item.py | 44 ++++++++ src/window.py | 78 ++++++++++++-- 14 files changed, 479 insertions(+), 74 deletions(-) create mode 100644 src/constants.py create mode 100644 src/icon-picker-dialog.ui create mode 100644 src/icon_picker_dialog.py create mode 100644 src/widgets/slim-project-item.ui create mode 100644 src/widgets/slim_project_item.py diff --git a/src/constants.py b/src/constants.py new file mode 100644 index 0000000..9be50b8 --- /dev/null +++ b/src/constants.py @@ -0,0 +1,69 @@ +# constants.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 + +# 36 symbolic icons for projects (6x6 grid) +PROJECT_ICONS = [ + # Row 1: Folders & Organization + "folder-symbolic", + "folder-open-symbolic", + "folder-documents-symbolic", + "folder-download-symbolic", + "folder-music-symbolic", + "folder-pictures-symbolic", + + # Row 2: Development & Code + "application-x-executable-symbolic", + "text-x-generic-symbolic", + "code-symbolic", + "utilities-terminal-symbolic", + "package-x-generic-symbolic", + "software-update-available-symbolic", + + # Row 3: Network & Web + "network-server-symbolic", + "network-wireless-symbolic", + "weather-clear-symbolic", + "globe-symbolic", + "world-symbolic", + "web-browser-symbolic", + + # Row 4: Tools & Actions + "document-edit-symbolic", + "preferences-system-symbolic", + "system-search-symbolic", + "view-list-symbolic", + "emblem-system-symbolic", + "edit-find-symbolic", + + # Row 5: Objects & Concepts + "starred-symbolic", + "non-starred-symbolic", + "bookmark-new-symbolic", + "user-bookmarks-symbolic", + "tag-symbolic", + "mail-send-symbolic", + + # Row 6: Status & Symbols + "emblem-ok-symbolic", + "dialog-warning-symbolic", + "dialog-information-symbolic", + "security-high-symbolic", + "changes-prevent-symbolic", + "document-properties-symbolic", +] diff --git a/src/icon-picker-dialog.ui b/src/icon-picker-dialog.ui new file mode 100644 index 0000000..52fdc09 --- /dev/null +++ b/src/icon-picker-dialog.ui @@ -0,0 +1,56 @@ + + + + + + + diff --git a/src/icon_picker_dialog.py b/src/icon_picker_dialog.py new file mode 100644 index 0000000..aa6985a --- /dev/null +++ b/src/icon_picker_dialog.py @@ -0,0 +1,70 @@ +# icon_picker_dialog.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 + +from gi.repository import Adw, Gtk, GObject +from .constants import PROJECT_ICONS + + +@Gtk.Template(resource_path='/cz/vesp/roster/icon-picker-dialog.ui') +class IconPickerDialog(Adw.Dialog): + """Dialog for selecting a project icon.""" + + __gtype_name__ = 'IconPickerDialog' + + icon_flowbox = Gtk.Template.Child() + + __gsignals__ = { + 'icon-selected': (GObject.SIGNAL_RUN_FIRST, None, (str,)), + } + + def __init__(self, current_icon=None): + super().__init__() + self.current_icon = current_icon + self._populate_icons() + + def _populate_icons(self): + """Populate the flowbox with icon buttons.""" + for icon_name in PROJECT_ICONS: + button = Gtk.Button() + button.set_icon_name(icon_name) + button.set_tooltip_text(icon_name) + button.add_css_class("flat") + button.set_size_request(48, 48) + + # Create icon image with larger size + icon = Gtk.Image.new_from_icon_name(icon_name) + icon.set_icon_size(Gtk.IconSize.LARGE) + button.set_child(icon) + + # Store icon name as data + button.icon_name = icon_name + + # Highlight current icon + if icon_name == self.current_icon: + button.add_css_class("suggested-action") + + self.icon_flowbox.append(button) + + @Gtk.Template.Callback() + def on_icon_selected(self, flowbox, child): + """Handle icon selection.""" + button = child.get_child() + if hasattr(button, 'icon_name'): + self.emit('icon-selected', button.icon_name) + self.close() diff --git a/src/main-window.ui b/src/main-window.ui index 328e34f..9bd7e57 100644 --- a/src/main-window.ui +++ b/src/main-window.ui @@ -83,69 +83,136 @@ - - vertical - 200 + + slide-left-right - + - - horizontal - 6 - 12 - 12 - 6 - 6 + + full + + + vertical + 200 - - - Projects - 0 - True - - - + + + + horizontal + 6 + 12 + 12 + 6 + 6 - - - list-add-symbolic - Add Project - - + + + Projects + 0 + True + + + + + + + list-add-symbolic + Add Project + + + + + + + + sidebar-show-right-symbolic + Fold Sidebar + + + + + + + + + + + True + + + + + + + + + + + + Save Current Request + 12 + 12 + 12 + + + + - + - + - - True - - - - - - - + + slim + + + vertical + 60 - - - - Save Current Request - 12 - 12 - 12 - - + + + + sidebar-show-left-symbolic + Unfold Sidebar + 6 + 6 + 6 + 6 + + + + + + + + + True + + + vertical + 6 + 6 + 6 + + + + + + diff --git a/src/meson.build b/src/meson.build index f0d2467..d02f448 100644 --- a/src/meson.build +++ b/src/meson.build @@ -34,6 +34,8 @@ roster_sources = [ 'http_client.py', 'history_manager.py', 'project_manager.py', + 'constants.py', + 'icon_picker_dialog.py', ] install_data(roster_sources, install_dir: moduledir) @@ -45,6 +47,7 @@ widgets_sources = [ 'widgets/history_item.py', 'widgets/project_item.py', 'widgets/request_item.py', + 'widgets/slim_project_item.py', ] install_data(widgets_sources, install_dir: moduledir / 'widgets') diff --git a/src/models.py b/src/models.py index 6995d94..433a741 100644 --- a/src/models.py +++ b/src/models.py @@ -124,6 +124,7 @@ class Project: name: str requests: List[SavedRequest] created_at: str # ISO format + icon: str = "folder-symbolic" # Icon name def to_dict(self): """Convert to dictionary for JSON serialization.""" @@ -131,7 +132,8 @@ class Project: 'id': self.id, 'name': self.name, 'requests': [req.to_dict() for req in self.requests], - 'created_at': self.created_at + 'created_at': self.created_at, + 'icon': self.icon } @classmethod @@ -141,5 +143,6 @@ class Project: id=data['id'], name=data['name'], requests=[SavedRequest.from_dict(r) for r in data.get('requests', [])], - created_at=data['created_at'] + created_at=data['created_at'], + icon=data.get('icon', 'folder-symbolic') # Default for old data ) diff --git a/src/project_manager.py b/src/project_manager.py index 4503267..7bcb1b5 100644 --- a/src/project_manager.py +++ b/src/project_manager.py @@ -76,12 +76,15 @@ class ProjectManager: self.save_projects(projects) return project - def rename_project(self, project_id: str, new_name: str): - """Rename a project.""" + def update_project(self, project_id: str, new_name: str = None, new_icon: str = None): + """Update a project's name and/or icon.""" projects = self.load_projects() for p in projects: if p.id == project_id: - p.name = new_name + if new_name is not None: + p.name = new_name + if new_icon is not None: + p.icon = new_icon break self.save_projects(projects) diff --git a/src/roster.gresource.xml b/src/roster.gresource.xml index 2da70e7..e141c03 100644 --- a/src/roster.gresource.xml +++ b/src/roster.gresource.xml @@ -3,9 +3,11 @@ main-window.ui shortcuts-dialog.ui + icon-picker-dialog.ui widgets/header-row.ui widgets/history-item.ui widgets/project-item.ui widgets/request-item.ui + widgets/slim-project-item.ui diff --git a/src/widgets/__init__.py b/src/widgets/__init__.py index afc6ef2..c5be70a 100644 --- a/src/widgets/__init__.py +++ b/src/widgets/__init__.py @@ -21,5 +21,6 @@ from .header_row import HeaderRow from .history_item import HistoryItem from .project_item import ProjectItem from .request_item import RequestItem +from .slim_project_item import SlimProjectItem -__all__ = ['HeaderRow', 'HistoryItem', 'ProjectItem', 'RequestItem'] +__all__ = ['HeaderRow', 'HistoryItem', 'ProjectItem', 'RequestItem', 'SlimProjectItem'] diff --git a/src/widgets/project-item.ui b/src/widgets/project-item.ui index ada6ec1..adbd0c5 100644 --- a/src/widgets/project-item.ui +++ b/src/widgets/project-item.ui @@ -10,8 +10,8 @@ project.add-request - Rename - project.rename + Edit Project + project.edit Delete @@ -39,6 +39,12 @@ + + + folder-symbolic + + + 0 diff --git a/src/widgets/project_item.py b/src/widgets/project_item.py index 5d2a362..fac522b 100644 --- a/src/widgets/project_item.py +++ b/src/widgets/project_item.py @@ -28,12 +28,13 @@ class ProjectItem(Gtk.Box): __gtype_name__ = 'ProjectItem' expand_icon = Gtk.Template.Child() + project_icon = Gtk.Template.Child() name_label = Gtk.Template.Child() requests_revealer = Gtk.Template.Child() requests_listbox = Gtk.Template.Child() __gsignals__ = { - 'rename-requested': (GObject.SIGNAL_RUN_FIRST, None, ()), + 'edit-requested': (GObject.SIGNAL_RUN_FIRST, None, ()), 'delete-requested': (GObject.SIGNAL_RUN_FIRST, None, ()), 'add-request-requested': (GObject.SIGNAL_RUN_FIRST, None, ()), 'request-load-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)), @@ -60,8 +61,8 @@ class ProjectItem(Gtk.Box): action.connect("activate", lambda *_: self.emit('add-request-requested')) actions.add_action(action) - action = Gio.SimpleAction.new("rename", None) - action.connect("activate", lambda *_: self.emit('rename-requested')) + action = Gio.SimpleAction.new("edit", None) + action.connect("activate", lambda *_: self.emit('edit-requested')) actions.add_action(action) action = Gio.SimpleAction.new("delete", None) @@ -73,6 +74,7 @@ class ProjectItem(Gtk.Box): def _populate(self): """Populate with project data.""" self.name_label.set_text(self.project.name) + self.project_icon.set_from_icon_name(self.project.icon) # Clear and add requests while child := self.requests_listbox.get_first_child(): diff --git a/src/widgets/slim-project-item.ui b/src/widgets/slim-project-item.ui new file mode 100644 index 0000000..7a8964d --- /dev/null +++ b/src/widgets/slim-project-item.ui @@ -0,0 +1,21 @@ + + + + + + diff --git a/src/widgets/slim_project_item.py b/src/widgets/slim_project_item.py new file mode 100644 index 0000000..f5b3e2e --- /dev/null +++ b/src/widgets/slim_project_item.py @@ -0,0 +1,44 @@ +# slim_project_item.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 + +from gi.repository import Gtk, GObject + + +@Gtk.Template(resource_path='/cz/vesp/roster/widgets/slim-project-item.ui') +class SlimProjectItem(Gtk.Button): + """Slim widget showing only project icon (for folded sidebar).""" + + __gtype_name__ = 'SlimProjectItem' + + project_icon = Gtk.Template.Child() + + __gsignals__ = { + 'project-clicked': (GObject.SIGNAL_RUN_FIRST, None, ()), + } + + def __init__(self, project): + super().__init__() + self.project = project + self.project_icon.set_from_icon_name(project.icon) + self.set_tooltip_text(project.name) + + @Gtk.Template.Callback() + def on_clicked(self, button): + """Handle click - unfold sidebar and show this project.""" + self.emit('project-clicked') diff --git a/src/window.py b/src/window.py index 777ebc1..75935f9 100644 --- a/src/window.py +++ b/src/window.py @@ -22,9 +22,11 @@ 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 .widgets.slim_project_item import SlimProjectItem from datetime import datetime import threading @@ -43,9 +45,13 @@ class RosterWindow(Adw.ApplicationWindow): split_pane = Gtk.Template.Child() # Sidebar widgets + sidebar_stack = Gtk.Template.Child() projects_listbox = Gtk.Template.Child() + slim_projects_box = Gtk.Template.Child() add_project_button = Gtk.Template.Child() save_request_button = Gtk.Template.Child() + fold_sidebar_button = Gtk.Template.Child() + unfold_sidebar_button = Gtk.Template.Child() # Containers for tabs request_tabs_container = Gtk.Template.Child() @@ -452,18 +458,26 @@ class RosterWindow(Adw.ApplicationWindow): # Clear existing while child := self.projects_listbox.get_first_child(): self.projects_listbox.remove(child) + while child := self.slim_projects_box.get_first_child(): + self.slim_projects_box.remove(child) # Load and populate projects = self.project_manager.load_projects() for project in projects: + # Full project item item = ProjectItem(project) - item.connect('rename-requested', self._on_project_rename, 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) + # Slim project item + slim_item = SlimProjectItem(project) + slim_item.connect('project-clicked', self._on_slim_project_clicked) + self.slim_projects_box.append(slim_item) + @Gtk.Template.Callback() def on_add_project_clicked(self, button): """Show dialog to create project.""" @@ -491,25 +505,53 @@ class RosterWindow(Adw.ApplicationWindow): dialog.connect("response", on_response) dialog.present(self) - def _on_project_rename(self, widget, project): - """Show rename dialog.""" + def _on_project_edit(self, widget, project): + """Show edit project dialog with icon picker.""" dialog = Adw.AlertDialog() - dialog.set_heading("Rename Project") - dialog.set_body(f"Rename '{project.name}' to:") + 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) - dialog.set_extra_child(entry) + 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("rename", "Rename") - dialog.set_response_appearance("rename", Adw.ResponseAppearance.SUGGESTED) + dialog.add_response("save", "Save") + dialog.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED) def on_response(dlg, response): - if response == "rename": + if response == "save": new_name = entry.get_text().strip() + new_icon = icon_button.selected_icon if new_name: - self.project_manager.rename_project(project.id, new_name) + self.project_manager.update_project(project.id, new_name, new_icon) self._load_projects() dialog.connect("response", on_response) @@ -667,3 +709,19 @@ class RosterWindow(Adw.ApplicationWindow): dialog.connect("response", on_response) dialog.present(self) + + # Sidebar fold/unfold methods + + @Gtk.Template.Callback() + def on_fold_sidebar_clicked(self, button): + """Fold the sidebar to slim view.""" + self.sidebar_stack.set_visible_child_name("slim") + + @Gtk.Template.Callback() + def on_unfold_sidebar_clicked(self, button): + """Unfold the sidebar to full view.""" + self.sidebar_stack.set_visible_child_name("full") + + def _on_slim_project_clicked(self, slim_item): + """Handle click on slim project item - unfold sidebar.""" + self.sidebar_stack.set_visible_child_name("full")