msansm1/rss-podcast-dlpublic Fork
⑂ f837ddc
Commits
⬇ Clone ▾
git clone https://git.rickub.com/msansm1/rss-podcast-dl.git
git clone ssh://git@rickub.com/msansm1/rss-podcast-dl.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

update UX and fixes code issues

msansm1 committed 2026-09-30T22:40:20+02:00 Browse files
f837ddc parent: 2e074e1
modified README.md +114 -41
@@ -4,15 +4,24 @@ Un outil en Rust avec interface egui pour télécharger automatiquement des podc
44
55 ## Fonctionnalités
66
7-- **Gestion des flux RSS** : Ajout, modification et suppression de flux podcast
8-- **Téléchargement automatique** : Synchronisation et téléchargement des nouveaux épisodes
9-- **Dossiers personnalisés** : Chaque flux peut avoir son propre dossier de téléchargement
10-- **Interface graphique** : Application native avec egui (OpenGL)
11-- **Persistance** : Sauvegarde des flux configurés dans un fichier JSON
7+- **Gestion des flux RSS** : ajout, modification, suppression (avec confirmation) de flux podcast, activation/désactivation, tags de catégorisation
8+- **Synchronisation asynchrone** : synchronisation globale ou flux par flux, sans bloquer l'interface, avec indicateur de progression
9+- **Vue détaillée par flux** : liste des épisodes avec dates, tailles et durées, téléchargement individuel, filtre par titre, actualisation
10+- **Téléchargement atomique** : écriture via un fichier `.part` puis renommage — un téléchargement interrompu n'est jamais confondu avec un épisode complet
11+- **Tags ID3** : titre, artiste et album écrits dans les fichiers téléchargés
12+- **Recherche** : filtrage instantané des flux par nom, URL ou tags, avec compteur de résultats
13+- **Filtres par date** : périodes personnalisées ou prédéfinies (7/30 derniers jours)
14+- **Playlists** : regroupement des flux avec description
15+- **Export / Import** : sauvegarde et restauration des flux au format JSON
16+- **Historique** : journal des opérations (synchronisations, téléchargements, ajouts/suppressions, import/export) avec filtres par type et statut, persisté entre les sessions
17+- **Statistiques** : nombre de flux, épisodes téléchargés, dates de dernière activité, tableau par flux
18+- **Thèmes sombre et clair** : palette centralisée, choix persisté entre les sessions
19+- **Notifications** : messages flottants non intrusifs (coin haut droit) avec bouton de fermeture
20+- **Menu et raccourcis clavier** : barre de menus complète et raccourcis globaux
1221
1322 ## Prérequis
1423
15-- [Rust](https://www.rust-lang.org/) 1.70 ou supérieur
24+- [Rust](https://www.rust-lang.org/) 1.92 ou supérieur (exigence MSRV d'egui 0.35)
1625 - Git (optionnel, pour cloner le dépôt)
1726
1827 ## Installation
@@ -45,37 +54,64 @@ Ou exécuter directement le binaire compilé :
4554
4655 ### Interface
4756
48-L'application propose une interface simple avec :
57+L'application s'organise autour d'une barre de menus, d'un écran principal (recherche, filtres, actions, liste des flux et des playlists), d'une barre de statut et de notifications flottantes.
4958
50-- **Écran d'accueil** :
51- - Bouton "Ajouter un flux" pour ajouter un nouveau flux RSS
52- - Bouton "Synchroniser les flux" pour télécharger les nouveaux épisodes
53- - Liste des flux configurés avec options d'édition et suppression
59+- **Écran principal** :
60+ - Recherche instantanée (nom, URL, tags) et filtres par date
61+ - Boutons « Ajouter un flux », « Synchroniser tous », « Exporter », « Importer »
62+ - Cartes de flux : statut activé/désactivé, dernière vérification, nombre d'épisodes téléchargés, dernier épisode récupéré, indicateur de synchronisation en cours
63+ - Actions par flux : vue détaillée (📖), synchroniser (🔄), éditer (✏️), supprimer (🗑️ avec confirmation)
5464
55-- **Ajout d'un flux** :
56- - **Nom du podcast** : Nom pour identifier le flux
57- - **URL du flux RSS** : URL du flux RSS du podcast
58- - **Dossier de téléchargement** : Chemin absolu vers le dossier de destination
65+- **Vue détaillée d'un flux** :
66+ - Liste des épisodes du plus récent au plus ancien, avec date, taille et durée
67+ - Téléchargement individuel d'un épisode, état « téléchargé » détecté sur le disque
68+ - Filtre par titre et bouton d'actualisation
5969
60-- **Édition d'un flux** :
61- - Modification du nom, URL et dossier de téléchargement
62- - Activation/désactivation du flux
70+- **Barre de menus** :
71+ - **Fichier** : ajouter un flux, importer, exporter, quitter
72+ - **Actions** : synchroniser tous les flux, ouvrir les fenêtres Statistiques et Historique
73+ - **Affichage** : bascule thème sombre/clair
74+
75+### Raccourcis clavier
76+
77+| Raccourci | Action |
78+|---|---|
79+| `Ctrl+N` | Ajouter un flux |
80+| `F5` | Synchroniser tous les flux |
81+| `Ctrl+F` | Focus sur la recherche |
82+| `Entrée` | Valider un dialogue (ajout/édition de flux) |
83+| `Échap` | Fermer un dialogue ou la vue détaillée |
6384
6485 ## Configuration
6586
66-Les flux sont automatiquement sauvegardés dans un fichier `feeds.json` situé dans :
67-- **Windows** : `%APPDATA%\rss-podcast-dl-egui\feeds.json`
68-- **Linux/macOS** : `~/.config/rss-podcast-dl-egui/feeds.json`
87+Les fichiers de configuration sont stockés dans un répertoire dédié :
88+
89+- **Windows** : `%APPDATA%\rss-podcast-dl\`
90+- **Linux/macOS** : `~/.config/rss-podcast-dl/`
91+
92+| Fichier | Contenu |
93+|---|---|
94+| `config.json` | Flux configurés |
95+| `config_playlists.json` | Playlists |
96+| `settings.json` | Préférences (thème) |
97+| `history.json` | Historique des opérations |
6998
7099 ### Format du fichier de configuration
71100
72101 ```json
73102 [
74103 {
104+ "id": "9d2c8a4e-1b3f-4c5d-9e6f-7a8b9c0d1e2f",
75105 "url": "https://exemple.com/podcast.rss",
76106 "name": "Nom du Podcast",
77- "download_path": "/chemin/vers/dossier",
78- "enabled": true
107+ "download_path": "C:\\Users\\moi\\Musique\\podcasts",
108+ "last_checked": "2026-09-29T10:00:00Z",
109+ "last_episode_date": null,
110+ "enabled": true,
111+ "tags": ["Tech", "Français"],
112+ "downloaded_episodes_count": 12,
113+ "last_downloaded_episode_name": "Épisode 42",
114+ "last_downloaded_episode_date": "2026-09-28T08:00:00Z"
79115 }
80116 ]
81117 ```
@@ -86,39 +122,63 @@ Les flux sont automatiquement sauvegardés dans un fichier `feeds.json` situé d
86122 .
87123 ├── src/
88124 │ ├── app.rs # Application principale egui
125+│ ├── main.rs # Point d'entrée
89126 │ ├── config/
90127 │ │ ├── app_state.rs # État global de l'application
91-│ │ ├── feed.rs # Structure Feed et méthodes
128+│ │ ├── backup.rs # Sauvegardes de la configuration
92129 │ │ ├── episode.rs # Structure Episode
130+│ │ ├── feed.rs # Structure Feed et méthodes
131+│ │ ├── history.rs # Historique des opérations
93132 │ │ ├── mod.rs # Exports du module config
94-│ │ └── storage.rs # Sauvegarde/chargement des flux
95-│ ├── main.rs # Point d'entrée
133+│ │ ├── playlist.rs # Structure Playlist
134+│ │ ├── search.rs # Requêtes de recherche
135+│ │ ├── settings.rs # Persistance des préférences (thème)
136+│ │ ├── statistics.rs # Statistiques et formatage
137+│ │ ├── storage.rs # Sauvegarde/chargement JSON
138+│ │ └── sync_task.rs # Tâches de synchronisation asynchrones
96139 │ ├── rss/
97-│ │ ├── downloader.rs # Téléchargement des épisodes
140+│ │ ├── async_sync.rs # Synchronisation asynchrone des flux
141+│ │ ├── audio_metadata.rs # Tags ID3
142+│ │ ├── downloader.rs # Téléchargement des épisodes (atomique)
98143 │ │ ├── episode_extractor.rs # Extraction des épisodes depuis le RSS
99144 │ │ ├── mod.rs
100-│ │ ├── parser.rs # Parsing du flux RSS
101-│ │ └── sync.rs # Synchronisation des flux
145+│ │ └── parser.rs # Récupération et parsing du flux RSS
102146 │ └── ui/
103-│ ├── add_feed_dialog.rs # Dialogue d'ajout de flux
104-│ ├── feed_edit_dialog.rs # Dialogue d'édition de flux
105-│ ├── feed_list.rs # Liste des flux
106-│ ├── home.rs # Écran d'accueil
107-│ └── mod.rs
147+│ ├── add_feed_dialog.rs # Dialogue d'ajout de flux (modal)
148+│ ├── confirm_dialog.rs # Confirmations de suppression (modales)
149+│ ├── export_import.rs # Export/Import JSON (modaux)
150+│ ├── feed_detail.rs # Vue détaillée : épisodes d'un flux
151+│ ├── feed_edit_dialog.rs # Dialogue d'édition de flux (modal)
152+│ ├── feed_list.rs # Liste des flux
153+│ ├── filter_panel.rs # Filtres par date
154+│ ├── history_panel.rs # Fenêtre Historique
155+│ ├── home.rs # Écran principal
156+│ ├── menu_bar.rs # Barre de menus et raccourcis clavier
157+│ ├── mod.rs # Module UI + helper de tronquage
158+│ ├── notifications.rs # Notifications flottantes
159+│ ├── playlist_list.rs # Liste des playlists et dialogues
160+│ ├── search_bar.rs # Barre de recherche
161+│ ├── stats_panel.rs # Fenêtre Statistiques
162+│ ├── status_bar.rs # Barre de statut
163+│ ├── sync_status.rs # Indicateur de synchronisation
164+│ └── theme.rs # Palette de couleurs centralisée
108165 ├── spec/
109166 │ ├── init-plan.md # Plan initial du projet
110167 │ └── init-tasks.md # Tâches détaillées
168+├── assets/
111169 └── Cargo.toml
112170 ```
113171
114172 ## Technologies utilisées
115173
116-- **Langage** : Rust
117-- **Framework UI** : [egui](https://github.com/emilk/egui) - Immediate mode GUI
174+- **Langage** : Rust (edition 2021)
175+- **Framework UI** : [egui](https://github.com/emilk/egui) 0.35 / [eframe](https://github.com/emilk/egui) - Immediate mode GUI
118176 - **Parsing RSS** : [rss](https://crates.io/crates/rss) crate
119177 - **Sérialisation** : [serde](https://crates.io/crates/serde) avec [serde_json](https://crates.io/crates/serde_json)
120178 - **Logging** : [log](https://crates.io/crates/log) avec [env_logger](https://crates.io/crates/env_logger)
121179 - **Requêtes HTTP** : [reqwest](https://crates.io/crates/reqwest) (avec support blocking)
180+- **Tags audio** : [id3](https://crates.io/crates/id3)
181+- **Dialogues natifs** : [rfd](https://crates.io/crates/rfd) (sélecteurs de fichiers et de dossiers)
122182
123183 ## Dépendances
124184
@@ -128,6 +188,13 @@ Voir [Cargo.toml](Cargo.toml) pour la liste complète des dépendances.
128188
129189 - `RUST_LOG` : Niveau de logging (ex: `RUST_LOG=info`)
130190
191+## Tests
192+
193+```bash
194+cargo test # Exécute la suite de tests (modèles, stockage, parsing, extraction…)
195+cargo clippy # Lint sans avertissement
196+```
197+
131198 ## Roadmap
132199
133200 - [x] Phase 1 : Structure de base et configuration
@@ -137,11 +204,17 @@ Voir [Cargo.toml](Cargo.toml) pour la liste complète des dépendances.
137204 - [x] Phase 5 : Synchronisation
138205 - [x] Phase 6 : Intégration et tests
139206 - [x] Phase 7 : Finalisation
140-- [x] Phase 8 : Fonctionnalités Avancées
141-- [x] Phase 9 : Optimisations et Fonctionnalités de Productivité
207+- [x] Phase 8 : Fonctionnalités avancées (playlists, export/import, thèmes)
208+- [x] Phase 9 : Fonctionnalités de productivité (recherche, historique, statistiques)
209+- [x] Phase 10 : Confort d'utilisation (menu, raccourcis, vue détaillée par épisode,
210+ confirmations, notifications flottantes, thème persisté)
142211
143212 ### Fonctionnalités futures
144-Toutes les fonctionnalités prévues ont été implémentées. Le projet est complet à 100%.
213+
214+- Synchronisation planifiée (automatique en arrière-plan)
215+- Lecteur audio intégré
216+- Support OPML pour l'import/export
217+- Taille/position de fenêtre persistées
145218
146219 ## Contribution
147220
@@ -149,8 +222,8 @@ Les contributions sont les bienvenues ! Ouvrez une issue ou soumettez une pull r
149222
150223 ## Licence
151224
152-Ce projet est sous licence MIT. Voir [LICENSE](LICENSE) pour plus de détails.
225+Ce projet est sous licence MIT (voir l'en-tête `license` du [Cargo.toml](Cargo.toml)).
153226
154227 ---
155228
156-Développé avec Rust et egui.
\ No newline at end of file
229+Développé avec Rust et egui.
@@ -4,15 +4,24 @@ Un outil en Rust avec interface egui pour télécharger automatiquement des podc
4 4
5 ## Fonctionnalités5 ## Fonctionnalités
6 6
7-- **Gestion des flux RSS** : Ajout, modification et suppression de flux podcast7+- **Gestion des flux RSS** : ajout, modification, suppression (avec confirmation) de flux podcast, activation/désactivation, tags de catégorisation
8-- **Téléchargement automatique** : Synchronisation et téléchargement des nouveaux épisodes8+- **Synchronisation asynchrone** : synchronisation globale ou flux par flux, sans bloquer l'interface, avec indicateur de progression
9-- **Dossiers personnalisés** : Chaque flux peut avoir son propre dossier de téléchargement9+- **Vue détaillée par flux** : liste des épisodes avec dates, tailles et durées, téléchargement individuel, filtre par titre, actualisation
10-- **Interface graphique** : Application native avec egui (OpenGL)10+- **Téléchargement atomique** : écriture via un fichier `.part` puis renommage — un téléchargement interrompu n'est jamais confondu avec un épisode complet
11-- **Persistance** : Sauvegarde des flux configurés dans un fichier JSON11+- **Tags ID3** : titre, artiste et album écrits dans les fichiers téléchargés
12+- **Recherche** : filtrage instantané des flux par nom, URL ou tags, avec compteur de résultats
13+- **Filtres par date** : périodes personnalisées ou prédéfinies (7/30 derniers jours)
14+- **Playlists** : regroupement des flux avec description
15+- **Export / Import** : sauvegarde et restauration des flux au format JSON
16+- **Historique** : journal des opérations (synchronisations, téléchargements, ajouts/suppressions, import/export) avec filtres par type et statut, persisté entre les sessions
17+- **Statistiques** : nombre de flux, épisodes téléchargés, dates de dernière activité, tableau par flux
18+- **Thèmes sombre et clair** : palette centralisée, choix persisté entre les sessions
19+- **Notifications** : messages flottants non intrusifs (coin haut droit) avec bouton de fermeture
20+- **Menu et raccourcis clavier** : barre de menus complète et raccourcis globaux
12 21
13 ## Prérequis22 ## Prérequis
14 23
15-- [Rust](https://www.rust-lang.org/) 1.70 ou supérieur24+- [Rust](https://www.rust-lang.org/) 1.92 ou supérieur (exigence MSRV d'egui 0.35)
16 - Git (optionnel, pour cloner le dépôt)25 - Git (optionnel, pour cloner le dépôt)
17 26
18 ## Installation27 ## Installation
@@ -45,37 +54,64 @@ Ou exécuter directement le binaire compilé :
45 54
46 ### Interface55 ### Interface
47 56
48-L'application propose une interface simple avec :57+L'application s'organise autour d'une barre de menus, d'un écran principal (recherche, filtres, actions, liste des flux et des playlists), d'une barre de statut et de notifications flottantes.
49 58
50-- **Écran d'accueil** :59+- **Écran principal** :
51- - Bouton "Ajouter un flux" pour ajouter un nouveau flux RSS60+ - Recherche instantanée (nom, URL, tags) et filtres par date
52- - Bouton "Synchroniser les flux" pour télécharger les nouveaux épisodes61+ - Boutons « Ajouter un flux », « Synchroniser tous », « Exporter », « Importer »
53- - Liste des flux configurés avec options d'édition et suppression62+ - Cartes de flux : statut activé/désactivé, dernière vérification, nombre d'épisodes téléchargés, dernier épisode récupéré, indicateur de synchronisation en cours
63+ - Actions par flux : vue détaillée (📖), synchroniser (🔄), éditer (✏️), supprimer (🗑️ avec confirmation)
54 64
55-- **Ajout d'un flux** :65+- **Vue détaillée d'un flux** :
56- - **Nom du podcast** : Nom pour identifier le flux66+ - Liste des épisodes du plus récent au plus ancien, avec date, taille et durée
57- - **URL du flux RSS** : URL du flux RSS du podcast67+ - Téléchargement individuel d'un épisode, état « téléchargé » détecté sur le disque
58- - **Dossier de téléchargement** : Chemin absolu vers le dossier de destination68+ - Filtre par titre et bouton d'actualisation
59 69
60-- **Édition d'un flux** :70+- **Barre de menus** :
61- - Modification du nom, URL et dossier de téléchargement71+ - **Fichier** : ajouter un flux, importer, exporter, quitter
62- - Activation/désactivation du flux72+ - **Actions** : synchroniser tous les flux, ouvrir les fenêtres Statistiques et Historique
73+ - **Affichage** : bascule thème sombre/clair
74+
75+### Raccourcis clavier
76+
77+| Raccourci | Action |
78+|---|---|
79+| `Ctrl+N` | Ajouter un flux |
80+| `F5` | Synchroniser tous les flux |
81+| `Ctrl+F` | Focus sur la recherche |
82+| `Entrée` | Valider un dialogue (ajout/édition de flux) |
83+| `Échap` | Fermer un dialogue ou la vue détaillée |
63 84
64 ## Configuration85 ## Configuration
65 86
66-Les flux sont automatiquement sauvegardés dans un fichier `feeds.json` situé dans :87+Les fichiers de configuration sont stockés dans un répertoire dédié :
67-- **Windows** : `%APPDATA%\rss-podcast-dl-egui\feeds.json`88+
68-- **Linux/macOS** : `~/.config/rss-podcast-dl-egui/feeds.json`89+- **Windows** : `%APPDATA%\rss-podcast-dl\`
90+- **Linux/macOS** : `~/.config/rss-podcast-dl/`
91+
92+| Fichier | Contenu |
93+|---|---|
94+| `config.json` | Flux configurés |
95+| `config_playlists.json` | Playlists |
96+| `settings.json` | Préférences (thème) |
97+| `history.json` | Historique des opérations |
69 98
70 ### Format du fichier de configuration99 ### Format du fichier de configuration
71 100
72 ```json101 ```json
73 [102 [
74 {103 {
104+ "id": "9d2c8a4e-1b3f-4c5d-9e6f-7a8b9c0d1e2f",
75 "url": "https://exemple.com/podcast.rss",105 "url": "https://exemple.com/podcast.rss",
76 "name": "Nom du Podcast",106 "name": "Nom du Podcast",
77- "download_path": "/chemin/vers/dossier",107+ "download_path": "C:\\Users\\moi\\Musique\\podcasts",
78- "enabled": true108+ "last_checked": "2026-09-29T10:00:00Z",
109+ "last_episode_date": null,
110+ "enabled": true,
111+ "tags": ["Tech", "Français"],
112+ "downloaded_episodes_count": 12,
113+ "last_downloaded_episode_name": "Épisode 42",
114+ "last_downloaded_episode_date": "2026-09-28T08:00:00Z"
79 }115 }
80 ]116 ]
81 ```117 ```
@@ -86,39 +122,63 @@ Les flux sont automatiquement sauvegardés dans un fichier `feeds.json` situé d
86 .122 .
87 ├── src/123 ├── src/
88 │ ├── app.rs # Application principale egui124 │ ├── app.rs # Application principale egui
125+│ ├── main.rs # Point d'entrée
89 │ ├── config/126 │ ├── config/
90 │ │ ├── app_state.rs # État global de l'application127 │ │ ├── app_state.rs # État global de l'application
91-│ │ ├── feed.rs # Structure Feed et méthodes128+│ │ ├── backup.rs # Sauvegardes de la configuration
92 │ │ ├── episode.rs # Structure Episode129 │ │ ├── episode.rs # Structure Episode
130+│ │ ├── feed.rs # Structure Feed et méthodes
131+│ │ ├── history.rs # Historique des opérations
93 │ │ ├── mod.rs # Exports du module config132 │ │ ├── mod.rs # Exports du module config
94-│ │ └── storage.rs # Sauvegarde/chargement des flux133+│ │ ├── playlist.rs # Structure Playlist
95-│ ├── main.rs # Point d'entrée134+│ │ ├── search.rs # Requêtes de recherche
135+│ │ ├── settings.rs # Persistance des préférences (thème)
136+│ │ ├── statistics.rs # Statistiques et formatage
137+│ │ ├── storage.rs # Sauvegarde/chargement JSON
138+│ │ └── sync_task.rs # Tâches de synchronisation asynchrones
96 │ ├── rss/139 │ ├── rss/
97-│ │ ├── downloader.rs # Téléchargement des épisodes140+│ │ ├── async_sync.rs # Synchronisation asynchrone des flux
141+│ │ ├── audio_metadata.rs # Tags ID3
142+│ │ ├── downloader.rs # Téléchargement des épisodes (atomique)
98 │ │ ├── episode_extractor.rs # Extraction des épisodes depuis le RSS143 │ │ ├── episode_extractor.rs # Extraction des épisodes depuis le RSS
99 │ │ ├── mod.rs144 │ │ ├── mod.rs
100-│ │ ├── parser.rs # Parsing du flux RSS145+│ │ └── parser.rs # Récupération et parsing du flux RSS
101-│ │ └── sync.rs # Synchronisation des flux
102 │ └── ui/146 │ └── ui/
103-│ ├── add_feed_dialog.rs # Dialogue d'ajout de flux147+│ ├── add_feed_dialog.rs # Dialogue d'ajout de flux (modal)
104-│ ├── feed_edit_dialog.rs # Dialogue d'édition de flux148+│ ├── confirm_dialog.rs # Confirmations de suppression (modales)
105-│ ├── feed_list.rs # Liste des flux149+│ ├── export_import.rs # Export/Import JSON (modaux)
106-│ ├── home.rs # Écran d'accueil150+│ ├── feed_detail.rs # Vue détaillée : épisodes d'un flux
107-│ └── mod.rs151+│ ├── feed_edit_dialog.rs # Dialogue d'édition de flux (modal)
152+│ ├── feed_list.rs # Liste des flux
153+│ ├── filter_panel.rs # Filtres par date
154+│ ├── history_panel.rs # Fenêtre Historique
155+│ ├── home.rs # Écran principal
156+│ ├── menu_bar.rs # Barre de menus et raccourcis clavier
157+│ ├── mod.rs # Module UI + helper de tronquage
158+│ ├── notifications.rs # Notifications flottantes
159+│ ├── playlist_list.rs # Liste des playlists et dialogues
160+│ ├── search_bar.rs # Barre de recherche
161+│ ├── stats_panel.rs # Fenêtre Statistiques
162+│ ├── status_bar.rs # Barre de statut
163+│ ├── sync_status.rs # Indicateur de synchronisation
164+│ └── theme.rs # Palette de couleurs centralisée
108 ├── spec/165 ├── spec/
109 │ ├── init-plan.md # Plan initial du projet166 │ ├── init-plan.md # Plan initial du projet
110 │ └── init-tasks.md # Tâches détaillées167 │ └── init-tasks.md # Tâches détaillées
168+├── assets/
111 └── Cargo.toml169 └── Cargo.toml
112 ```170 ```
113 171
114 ## Technologies utilisées172 ## Technologies utilisées
115 173
116-- **Langage** : Rust174+- **Langage** : Rust (edition 2021)
117-- **Framework UI** : [egui](https://github.com/emilk/egui) - Immediate mode GUI175+- **Framework UI** : [egui](https://github.com/emilk/egui) 0.35 / [eframe](https://github.com/emilk/egui) - Immediate mode GUI
118 - **Parsing RSS** : [rss](https://crates.io/crates/rss) crate176 - **Parsing RSS** : [rss](https://crates.io/crates/rss) crate
119 - **Sérialisation** : [serde](https://crates.io/crates/serde) avec [serde_json](https://crates.io/crates/serde_json)177 - **Sérialisation** : [serde](https://crates.io/crates/serde) avec [serde_json](https://crates.io/crates/serde_json)
120 - **Logging** : [log](https://crates.io/crates/log) avec [env_logger](https://crates.io/crates/env_logger)178 - **Logging** : [log](https://crates.io/crates/log) avec [env_logger](https://crates.io/crates/env_logger)
121 - **Requêtes HTTP** : [reqwest](https://crates.io/crates/reqwest) (avec support blocking)179 - **Requêtes HTTP** : [reqwest](https://crates.io/crates/reqwest) (avec support blocking)
180+- **Tags audio** : [id3](https://crates.io/crates/id3)
181+- **Dialogues natifs** : [rfd](https://crates.io/crates/rfd) (sélecteurs de fichiers et de dossiers)
122 182
123 ## Dépendances183 ## Dépendances
124 184
@@ -128,6 +188,13 @@ Voir [Cargo.toml](Cargo.toml) pour la liste complète des dépendances.
128 188
129 - `RUST_LOG` : Niveau de logging (ex: `RUST_LOG=info`)189 - `RUST_LOG` : Niveau de logging (ex: `RUST_LOG=info`)
130 190
191+## Tests
192+
193+```bash
194+cargo test # Exécute la suite de tests (modèles, stockage, parsing, extraction…)
195+cargo clippy # Lint sans avertissement
196+```
197+
131 ## Roadmap198 ## Roadmap
132 199
133 - [x] Phase 1 : Structure de base et configuration200 - [x] Phase 1 : Structure de base et configuration
@@ -137,11 +204,17 @@ Voir [Cargo.toml](Cargo.toml) pour la liste complète des dépendances.
137 - [x] Phase 5 : Synchronisation204 - [x] Phase 5 : Synchronisation
138 - [x] Phase 6 : Intégration et tests205 - [x] Phase 6 : Intégration et tests
139 - [x] Phase 7 : Finalisation206 - [x] Phase 7 : Finalisation
140-- [x] Phase 8 : Fonctionnalités Avancées207+- [x] Phase 8 : Fonctionnalités avancées (playlists, export/import, thèmes)
141-- [x] Phase 9 : Optimisations et Fonctionnalités de Productivité208+- [x] Phase 9 : Fonctionnalités de productivité (recherche, historique, statistiques)
209+- [x] Phase 10 : Confort d'utilisation (menu, raccourcis, vue détaillée par épisode,
210+ confirmations, notifications flottantes, thème persisté)
142 211
143 ### Fonctionnalités futures212 ### Fonctionnalités futures
144-Toutes les fonctionnalités prévues ont été implémentées. Le projet est complet à 100%.213+
214+- Synchronisation planifiée (automatique en arrière-plan)
215+- Lecteur audio intégré
216+- Support OPML pour l'import/export
217+- Taille/position de fenêtre persistées
145 218
146 ## Contribution219 ## Contribution
147 220
@@ -149,8 +222,8 @@ Les contributions sont les bienvenues ! Ouvrez une issue ou soumettez une pull r
149 222
150 ## Licence223 ## Licence
151 224
152-Ce projet est sous licence MIT. Voir [LICENSE](LICENSE) pour plus de détails.225+Ce projet est sous licence MIT (voir l'en-tête `license` du [Cargo.toml](Cargo.toml)).
153 226
154 ---227 ---
155 228
156-Développé avec Rust et egui.
\ No newline at end of file\ No newline at end of file
229+Développé avec Rust et egui.
modified src/app.rs +49 -117
@@ -2,9 +2,8 @@
22 //!
33 //! This module contains the main egui application and state management.
44
5-use crate::config::AppState;
65 use crate::config::storage::load_feeds_default;
7-use crate::config::Theme;
6+use crate::config::AppState;
87 use eframe::egui;
98 use log::{error, info};
109
@@ -13,7 +12,6 @@ use log::{error, info};
1312 pub struct RssPodcastApp {
1413 state: AppState,
1514 title: String,
16- clear_color: egui::Color32,
1715 }
1816
1917 impl RssPodcastApp {
@@ -21,7 +19,6 @@ impl RssPodcastApp {
2119 Self {
2220 state,
2321 title: "RSS Podcast Downloader".to_string(),
24- clear_color: egui::Color32::from_rgb(30, 30, 30),
2522 }
2623 }
2724 }
@@ -35,133 +32,45 @@ impl Default for RssPodcastApp {
3532
3633 impl eframe::App for RssPodcastApp {
3734 fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
38- let ctx = ui.ctx();
35+ let ctx = ui.ctx().clone();
3936 ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.title.clone()));
4037
41- // Appliquer un thème personnalisé moderne
42- self.apply_custom_theme(ctx, self.state.get_theme());
38+ // Appliquer le thème centralisé et récupérer la palette active
39+ let colors = crate::ui::theme::apply_custom_theme(&ctx, self.state.get_theme());
4340
44- // Get theme-specific colors
45- let notification_bg = match self.state.get_theme() {
46- Theme::Dark => egui::Color32::from_rgb(40, 42, 54),
47- Theme::Light => egui::Color32::from_rgb(220, 230, 245),
48- };
41+ // Raccourcis clavier globaux (Ctrl+N, F5, Ctrl+F, Échap)
42+ crate::ui::menu_bar::handle_shortcuts(&mut self.state, &ctx);
4943
50- // Draw notifications panel at the top
51- egui::Panel::top("notifications_panel")
52- .frame(egui::Frame {
53- fill: notification_bg,
54- corner_radius: 6.0.into(),
55- ..Default::default()
56- })
44+ // Barre de menus en haut
45+ egui::Panel::top("menu_bar")
5746 .show(ui, |ui| {
58- ui.vertical_centered(|ui| {
59- self.state.notification_manager.draw(ui);
60- });
47+ crate::ui::menu_bar::draw_menu(&mut self.state, ui);
6148 });
6249
50+ // Zone principale défilante + barre de statut fixe en bas
6351 egui::CentralPanel::default()
6452 .frame(egui::Frame {
65- fill: self.clear_color,
53+ fill: colors.window_bg,
6654 inner_margin: egui::Margin::symmetric(12, 8),
6755 ..Default::default()
6856 })
6957 .show(ui, |ui| {
7058 ui.spacing_mut().item_spacing = egui::Vec2::new(12.0, 12.0);
71- ui.spacing_mut().window_margin = egui::Margin::symmetric(16, 16);
72- crate::ui::home::draw_home(&mut self.state, ui);
59+ egui::ScrollArea::vertical()
60+ .auto_shrink([false, false])
61+ .show(ui, |ui| {
62+ ui.spacing_mut().window_margin = egui::Margin::symmetric(16, 16);
63+ crate::ui::home::draw_home(&mut self.state, ui);
64+ });
7365 ui.separator();
7466 crate::ui::status_bar::draw_status_bar(&mut self.state, ui);
7567 });
76- }
77-}
7868
79-impl RssPodcastApp {
80- /// Applique un thème personnalisé moderne et agréable
81- fn apply_custom_theme(&mut self, ctx: &egui::Context, theme: Theme) {
82- // Convert our Theme to egui::Theme
83- let egui_theme = match theme {
84- Theme::Dark => egui::Theme::Dark,
85- Theme::Light => egui::Theme::Light,
86- };
87- let mut style = (*ctx.style_of(egui_theme)).clone();
88-
89- match theme {
90- Theme::Dark => {
91- // Thème sombre moderne inspiré de Nord/Dracula
92- style.visuals.dark_mode = true;
93-
94- // Couleurs principales pour le thème sombre
95- let bg_primary = egui::Color32::from_rgb(36, 39, 58);
96- let bg_secondary = egui::Color32::from_rgb(46, 52, 72);
97- let bg_tertiary = egui::Color32::from_rgb(56, 62, 80);
98- let accent = egui::Color32::from_rgb(130, 170, 250);
99- let border = egui::Color32::from_rgb(80, 88, 106);
100-
101- // Arrière-plan
102- style.visuals.widgets.noninteractive.bg_fill = bg_primary;
103- style.visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, border);
104-
105- // Boutons normaux
106- style.visuals.widgets.inactive.bg_fill = bg_secondary;
107- style.visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, border);
108-
109- // Boutons hover
110- style.visuals.widgets.hovered.bg_fill = bg_tertiary;
111-
112- // Boutons actifs
113- style.visuals.widgets.active.bg_fill = accent;
114- style.visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, accent);
115-
116- // Couleur des liens
117- style.visuals.hyperlink_color = accent;
118-
119- // Couleur de la fenêtre
120- self.clear_color = egui::Color32::from_rgb(30, 30, 30);
121- }
122- Theme::Light => {
123- // Thème clair moderne
124- style.visuals.dark_mode = false;
125-
126- // Couleurs principales pour le thème clair
127- let bg_primary = egui::Color32::from_rgb(240, 245, 252);
128- let bg_secondary = egui::Color32::from_rgb(220, 230, 245);
129- let bg_tertiary = egui::Color32::from_rgb(200, 220, 240);
130- let accent = egui::Color32::from_rgb(100, 140, 220);
131- let border = egui::Color32::from_rgb(180, 195, 210);
132-
133- // Arrière-plan
134- style.visuals.widgets.noninteractive.bg_fill = bg_primary;
135- style.visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, border);
136-
137- // Boutons normaux
138- style.visuals.widgets.inactive.bg_fill = bg_secondary;
139- style.visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, border);
140-
141- // Boutons hover
142- style.visuals.widgets.hovered.bg_fill = bg_tertiary;
143-
144- // Boutons actifs
145- style.visuals.widgets.active.bg_fill = accent;
146- style.visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, accent);
147-
148- // Couleur des liens
149- style.visuals.hyperlink_color = accent;
150-
151- // Couleur de la fenêtre
152- self.clear_color = egui::Color32::from_rgb(248, 250, 255);
153- }
154- }
155-
156- // Espacement amélioré (commun aux deux thèmes)
157- style.spacing.item_spacing = egui::Vec2::new(12.0, 12.0);
158- style.spacing.button_padding = egui::Vec2::new(12.0, 6.0);
159-
160- // Taille des icônes
161- style.spacing.icon_width = 20.0;
162- style.spacing.icon_spacing = 8.0;
163-
164- ctx.set_style_of(egui_theme, style);
69+ // Notifications flottantes (overlay, coin haut droit)
70+ self.state.notification_manager.draw_overlay(&ctx);
71+
72+ // Dialogues de confirmation (suppressions)
73+ crate::ui::confirm_dialog::draw_confirmations(&mut self.state, &ctx);
16574 }
16675 }
16776
@@ -170,9 +79,9 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
17079
17180 let mut options = eframe::NativeOptions::default();
17281 options.viewport.inner_size = Some(egui::Vec2::new(1000.0, 600.0));
173-
82+
17483 let mut state = AppState::with_default_config();
175-
84+
17685 match load_feeds_default() {
17786 Ok(feeds) => {
17887 state.feeds = feeds;
@@ -187,15 +96,38 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
18796 match crate::config::storage::load_playlists_default() {
18897 Ok(playlists) => {
18998 state.playlists = playlists;
190- info!("Loaded {} playlists from configuration", state.playlists.len());
99+ info!(
100+ "Loaded {} playlists from configuration",
101+ state.playlists.len()
102+ );
191103 }
192104 Err(e) => {
193105 info!("No existing playlists found or error loading: {}", e);
194106 }
195107 }
196108
109+ // Restore the persisted theme (settings.json)
110+ if let Some(theme) = crate::config::settings::load_theme() {
111+ state.theme = theme;
112+ info!("Restored persisted theme");
113+ }
114+
115+ // Load the operation history and persist new entries automatically
116+ let history_path = crate::config::settings::get_history_path();
117+ if history_path.exists() {
118+ if let Err(e) = state.history_manager.load_from_file(&history_path) {
119+ log::warn!("Failed to load history: {}", e);
120+ } else {
121+ info!(
122+ "Loaded {} history entries",
123+ state.history_manager.len()
124+ );
125+ }
126+ }
127+ state.history_manager.set_save_path(history_path);
128+
197129 let app = RssPodcastApp::new(state);
198-
130+
199131 if let Err(e) = eframe::run_native(
200132 "RSS Podcast Downloader",
201133 options,
@@ -2,9 +2,8 @@
2 //!2 //!
3 //! This module contains the main egui application and state management.3 //! This module contains the main egui application and state management.
4 4
5-use crate::config::AppState;
6 use crate::config::storage::load_feeds_default;5 use crate::config::storage::load_feeds_default;
7-use crate::config::Theme;6+use crate::config::AppState;
8 use eframe::egui;7 use eframe::egui;
9 use log::{error, info};8 use log::{error, info};
10 9
@@ -13,7 +12,6 @@ use log::{error, info};
13 pub struct RssPodcastApp {12 pub struct RssPodcastApp {
14 state: AppState,13 state: AppState,
15 title: String,14 title: String,
16- clear_color: egui::Color32,
17 }15 }
18 16
19 impl RssPodcastApp {17 impl RssPodcastApp {
@@ -21,7 +19,6 @@ impl RssPodcastApp {
21 Self {19 Self {
22 state,20 state,
23 title: "RSS Podcast Downloader".to_string(),21 title: "RSS Podcast Downloader".to_string(),
24- clear_color: egui::Color32::from_rgb(30, 30, 30),
25 }22 }
26 }23 }
27 }24 }
@@ -35,133 +32,45 @@ impl Default for RssPodcastApp {
35 32
36 impl eframe::App for RssPodcastApp {33 impl eframe::App for RssPodcastApp {
37 fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {34 fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
38- let ctx = ui.ctx();35+ let ctx = ui.ctx().clone();
39 ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.title.clone()));36 ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.title.clone()));
40 37
41- // Appliquer un thème personnalisé moderne38+ // Appliquer le thème centralisé et récupérer la palette active
42- self.apply_custom_theme(ctx, self.state.get_theme());39+ let colors = crate::ui::theme::apply_custom_theme(&ctx, self.state.get_theme());
43 40
44- // Get theme-specific colors41+ // Raccourcis clavier globaux (Ctrl+N, F5, Ctrl+F, Échap)
45- let notification_bg = match self.state.get_theme() {42+ crate::ui::menu_bar::handle_shortcuts(&mut self.state, &ctx);
46- Theme::Dark => egui::Color32::from_rgb(40, 42, 54),
47- Theme::Light => egui::Color32::from_rgb(220, 230, 245),
48- };
49 43
50- // Draw notifications panel at the top44+ // Barre de menus en haut
51- egui::Panel::top("notifications_panel")45+ egui::Panel::top("menu_bar")
52- .frame(egui::Frame {
53- fill: notification_bg,
54- corner_radius: 6.0.into(),
55- ..Default::default()
56- })
57 .show(ui, |ui| {46 .show(ui, |ui| {
58- ui.vertical_centered(|ui| {47+ crate::ui::menu_bar::draw_menu(&mut self.state, ui);
59- self.state.notification_manager.draw(ui);
60- });
61 });48 });
62 49
50+ // Zone principale défilante + barre de statut fixe en bas
63 egui::CentralPanel::default()51 egui::CentralPanel::default()
64 .frame(egui::Frame {52 .frame(egui::Frame {
65- fill: self.clear_color,53+ fill: colors.window_bg,
66 inner_margin: egui::Margin::symmetric(12, 8),54 inner_margin: egui::Margin::symmetric(12, 8),
67 ..Default::default()55 ..Default::default()
68 })56 })
69 .show(ui, |ui| {57 .show(ui, |ui| {
70 ui.spacing_mut().item_spacing = egui::Vec2::new(12.0, 12.0);58 ui.spacing_mut().item_spacing = egui::Vec2::new(12.0, 12.0);
71- ui.spacing_mut().window_margin = egui::Margin::symmetric(16, 16);59+ egui::ScrollArea::vertical()
72- crate::ui::home::draw_home(&mut self.state, ui);60+ .auto_shrink([false, false])
61+ .show(ui, |ui| {
62+ ui.spacing_mut().window_margin = egui::Margin::symmetric(16, 16);
63+ crate::ui::home::draw_home(&mut self.state, ui);
64+ });
73 ui.separator();65 ui.separator();
74 crate::ui::status_bar::draw_status_bar(&mut self.state, ui);66 crate::ui::status_bar::draw_status_bar(&mut self.state, ui);
75 });67 });
76- }
77-}
78 68
79-impl RssPodcastApp {69+ // Notifications flottantes (overlay, coin haut droit)
80- /// Applique un thème personnalisé moderne et agréable70+ self.state.notification_manager.draw_overlay(&ctx);
81- fn apply_custom_theme(&mut self, ctx: &egui::Context, theme: Theme) {71+
82- // Convert our Theme to egui::Theme72+ // Dialogues de confirmation (suppressions)
83- let egui_theme = match theme {73+ crate::ui::confirm_dialog::draw_confirmations(&mut self.state, &ctx);
84- Theme::Dark => egui::Theme::Dark,
85- Theme::Light => egui::Theme::Light,
86- };
87- let mut style = (*ctx.style_of(egui_theme)).clone();
88-
89- match theme {
90- Theme::Dark => {
91- // Thème sombre moderne inspiré de Nord/Dracula
92- style.visuals.dark_mode = true;
93-
94- // Couleurs principales pour le thème sombre
95- let bg_primary = egui::Color32::from_rgb(36, 39, 58);
96- let bg_secondary = egui::Color32::from_rgb(46, 52, 72);
97- let bg_tertiary = egui::Color32::from_rgb(56, 62, 80);
98- let accent = egui::Color32::from_rgb(130, 170, 250);
99- let border = egui::Color32::from_rgb(80, 88, 106);
100-
101- // Arrière-plan
102- style.visuals.widgets.noninteractive.bg_fill = bg_primary;
103- style.visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, border);
104-
105- // Boutons normaux
106- style.visuals.widgets.inactive.bg_fill = bg_secondary;
107- style.visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, border);
108-
109- // Boutons hover
110- style.visuals.widgets.hovered.bg_fill = bg_tertiary;
111-
112- // Boutons actifs
113- style.visuals.widgets.active.bg_fill = accent;
114- style.visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, accent);
115-
116- // Couleur des liens
117- style.visuals.hyperlink_color = accent;
118-
119- // Couleur de la fenêtre
120- self.clear_color = egui::Color32::from_rgb(30, 30, 30);
121- }
122- Theme::Light => {
123- // Thème clair moderne
124- style.visuals.dark_mode = false;
125-
126- // Couleurs principales pour le thème clair
127- let bg_primary = egui::Color32::from_rgb(240, 245, 252);
128- let bg_secondary = egui::Color32::from_rgb(220, 230, 245);
129- let bg_tertiary = egui::Color32::from_rgb(200, 220, 240);
130- let accent = egui::Color32::from_rgb(100, 140, 220);
131- let border = egui::Color32::from_rgb(180, 195, 210);
132-
133- // Arrière-plan
134- style.visuals.widgets.noninteractive.bg_fill = bg_primary;
135- style.visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, border);
136-
137- // Boutons normaux
138- style.visuals.widgets.inactive.bg_fill = bg_secondary;
139- style.visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, border);
140-
141- // Boutons hover
142- style.visuals.widgets.hovered.bg_fill = bg_tertiary;
143-
144- // Boutons actifs
145- style.visuals.widgets.active.bg_fill = accent;
146- style.visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, accent);
147-
148- // Couleur des liens
149- style.visuals.hyperlink_color = accent;
150-
151- // Couleur de la fenêtre
152- self.clear_color = egui::Color32::from_rgb(248, 250, 255);
153- }
154- }
155-
156- // Espacement amélioré (commun aux deux thèmes)
157- style.spacing.item_spacing = egui::Vec2::new(12.0, 12.0);
158- style.spacing.button_padding = egui::Vec2::new(12.0, 6.0);
159-
160- // Taille des icônes
161- style.spacing.icon_width = 20.0;
162- style.spacing.icon_spacing = 8.0;
163-
164- ctx.set_style_of(egui_theme, style);
165 }74 }
166 }75 }
167 76
@@ -170,9 +79,9 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
170 79
171 let mut options = eframe::NativeOptions::default();80 let mut options = eframe::NativeOptions::default();
172 options.viewport.inner_size = Some(egui::Vec2::new(1000.0, 600.0));81 options.viewport.inner_size = Some(egui::Vec2::new(1000.0, 600.0));
173- 82+
174 let mut state = AppState::with_default_config();83 let mut state = AppState::with_default_config();
175- 84+
176 match load_feeds_default() {85 match load_feeds_default() {
177 Ok(feeds) => {86 Ok(feeds) => {
178 state.feeds = feeds;87 state.feeds = feeds;
@@ -187,15 +96,38 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
187 match crate::config::storage::load_playlists_default() {96 match crate::config::storage::load_playlists_default() {
188 Ok(playlists) => {97 Ok(playlists) => {
189 state.playlists = playlists;98 state.playlists = playlists;
190- info!("Loaded {} playlists from configuration", state.playlists.len());99+ info!(
100+ "Loaded {} playlists from configuration",
101+ state.playlists.len()
102+ );
191 }103 }
192 Err(e) => {104 Err(e) => {
193 info!("No existing playlists found or error loading: {}", e);105 info!("No existing playlists found or error loading: {}", e);
194 }106 }
195 }107 }
196 108
109+ // Restore the persisted theme (settings.json)
110+ if let Some(theme) = crate::config::settings::load_theme() {
111+ state.theme = theme;
112+ info!("Restored persisted theme");
113+ }
114+
115+ // Load the operation history and persist new entries automatically
116+ let history_path = crate::config::settings::get_history_path();
117+ if history_path.exists() {
118+ if let Err(e) = state.history_manager.load_from_file(&history_path) {
119+ log::warn!("Failed to load history: {}", e);
120+ } else {
121+ info!(
122+ "Loaded {} history entries",
123+ state.history_manager.len()
124+ );
125+ }
126+ }
127+ state.history_manager.set_save_path(history_path);
128+
197 let app = RssPodcastApp::new(state);129 let app = RssPodcastApp::new(state);
198- 130+
199 if let Err(e) = eframe::run_native(131 if let Err(e) = eframe::run_native(
200 "RSS Podcast Downloader",132 "RSS Podcast Downloader",
201 options,133 options,
modified src/config/app_state.rs +165 -44
@@ -2,15 +2,16 @@
22 //!
33 //! This module defines the main application state structure.
44
5+use crate::config::episode::Episode;
56 use crate::config::feed::Feed;
6-use crate::config::history::HistoryManager;
7+use crate::config::history::{HistoryEntry, HistoryManager, OperationType};
78 use crate::config::playlist::Playlist;
89 use crate::config::search::SearchQuery;
9-use crate::config::statistics::AppStatistics;
1010 use crate::config::sync_task::SyncTaskManager;
1111 use crate::ui::notifications::NotificationManager;
1212 use chrono::{DateTime, Utc};
1313 use serde::{Deserialize, Serialize};
14+use std::collections::HashSet;
1415
1516 /// Theme options for the application
1617 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -90,8 +91,6 @@ pub struct AppState {
9091 pub filter_date_from: Option<DateTime<Utc>>,
9192 /// Filter: date to (inclusive)
9293 pub filter_date_to: Option<DateTime<Utc>>,
93- /// Filter: episode number
94- pub filter_episode_number: String,
9594 /// Whether to show the date picker for 'from' date
9695 pub show_date_picker_from: bool,
9796 /// Whether to show the date picker for 'to' date
@@ -102,24 +101,38 @@ pub struct AppState {
102101 pub show_import_dialog: bool,
103102 /// Last export/import error message
104103 pub export_import_error: Option<String>,
105- // Phase 9: Search functionality
106- /// Current search query
107- /// Not yet wired into the UI (see ui/search_bar.rs)
108- #[allow(dead_code)]
104+ /// Current search query (toolbar search bar)
109105 pub search_query: SearchQuery,
110- /// Whether search panel is visible
111- #[allow(dead_code)]
112- pub show_search: bool,
113- // Phase 9: Statistics
114- /// Application statistics
115- /// Not yet wired into the UI (see ui/stats_panel.rs)
116- #[allow(dead_code)]
117- pub statistics: AppStatistics,
118- // Phase 9: History
106+ /// Request to give focus to the search bar (Ctrl+F)
107+ pub focus_search: bool,
119108 /// History manager for tracking operations
120- /// Not yet wired into the UI (see ui/history_panel.rs)
121- #[allow(dead_code)]
122109 pub history_manager: HistoryManager,
110+ /// History window: filter by operation type
111+ pub history_filter_type: Option<OperationType>,
112+ /// History window: filter by status
113+ pub history_filter_status:
114+ Option<crate::config::history::OperationStatus>,
115+ /// Whether the statistics window is visible
116+ pub show_stats_window: bool,
117+ /// Whether the history window is visible
118+ pub show_history_window: bool,
119+ /// Feed pending deletion confirmation (feed id)
120+ pub confirm_delete_feed_id: Option<String>,
121+ /// Playlist pending deletion confirmation (playlist id)
122+ pub confirm_delete_playlist_id: Option<String>,
123+ // Feed detail view
124+ /// ID of the feed shown in the detail view
125+ pub detail_feed_id: Option<String>,
126+ /// Episodes of the feed shown in the detail view
127+ pub detail_episodes: Vec<Episode>,
128+ /// Whether episodes are currently being loaded in the detail view
129+ pub detail_loading: bool,
130+ /// Error message when loading episodes failed
131+ pub detail_error: Option<String>,
132+ /// Episode filter (detail view)
133+ pub detail_episode_filter: String,
134+ /// IDs of episodes currently being downloaded individually
135+ pub downloading_episode_ids: HashSet<String>,
123136 /// Manager for async synchronization tasks
124137 pub sync_task_manager: SyncTaskManager,
125138 }
@@ -152,17 +165,26 @@ impl AppState {
152165 playlists: Vec::new(),
153166 filter_date_from: None,
154167 filter_date_to: None,
155- filter_episode_number: String::new(),
156168 show_date_picker_from: false,
157169 show_date_picker_to: false,
158170 show_export_dialog: false,
159171 show_import_dialog: false,
160172 export_import_error: None,
161- // Phase 9 fields
162173 search_query: SearchQuery::default(),
163- show_search: false,
164- statistics: AppStatistics::new(),
174+ focus_search: false,
165175 history_manager: HistoryManager::new(),
176+ history_filter_type: None,
177+ history_filter_status: None,
178+ show_stats_window: false,
179+ show_history_window: false,
180+ confirm_delete_feed_id: None,
181+ confirm_delete_playlist_id: None,
182+ detail_feed_id: None,
183+ detail_episodes: Vec::new(),
184+ detail_loading: false,
185+ detail_error: None,
186+ detail_episode_filter: String::new(),
187+ downloading_episode_ids: HashSet::new(),
166188 theme: Theme::default(),
167189 sync_task_manager: SyncTaskManager::new(),
168190 }
@@ -175,15 +197,90 @@ impl AppState {
175197
176198 /// Adds a new feed to the application state
177199 pub fn add_feed(&mut self, feed: Feed) {
200+ self.record_history(
201+ OperationType::AddFeed,
202+ format!("Flux ajouté : {}", feed.name),
203+ );
178204 self.feeds.push(feed);
179205 self.notification_manager.success("Flux ajouté avec succès");
180206 }
181207
208+ /// Deletes a feed by id, returning the removed feed if found
209+ pub fn delete_feed_by_id(&mut self, feed_id: &str) -> Option<Feed> {
210+ let index = self.feeds.iter().position(|f| f.id == feed_id)?;
211+ let feed = self.feeds.remove(index);
212+ if self.current_feed == Some(index) {
213+ self.select_feed(None);
214+ }
215+ Some(feed)
216+ }
217+
182218 /// Selects a feed by index
183219 pub fn select_feed(&mut self, index: Option<usize>) {
184220 self.current_feed = index;
185221 }
186222
223+ /// Opens the detail view for a feed and starts loading its episodes
224+ pub fn open_feed_detail(&mut self, feed_id: String) {
225+ let Some(feed) = self.feeds.iter().find(|f| f.id == feed_id).cloned() else {
226+ return;
227+ };
228+ self.detail_feed_id = Some(feed.id.clone());
229+ self.detail_episodes.clear();
230+ self.detail_error = None;
231+ self.detail_episode_filter.clear();
232+ self.detail_loading = true;
233+ self.sync_task_manager.start_load_episodes(feed);
234+ }
235+
236+ /// Closes the feed detail view
237+ pub fn close_feed_detail(&mut self) {
238+ self.detail_feed_id = None;
239+ self.detail_episodes.clear();
240+ self.detail_error = None;
241+ self.detail_episode_filter.clear();
242+ self.detail_loading = false;
243+ }
244+
245+ /// Starts an asynchronous synchronization of all feeds
246+ pub fn start_sync_all_feeds(&mut self) {
247+ if !self.has_feeds() {
248+ self.notification_manager.info("Aucun flux à synchroniser");
249+ return;
250+ }
251+
252+ log::info!("Starting asynchronous synchronization of all feeds");
253+
254+ let feeds_to_sync = self.feeds.clone();
255+ self.sync_task_manager.start_sync_all_feeds(feeds_to_sync);
256+ self.record_history(
257+ OperationType::SyncAll,
258+ "Synchronisation de tous les flux lancée".to_string(),
259+ );
260+
261+ self.notification_manager
262+ .info("Synchronisation de tous les flux en cours...");
263+ }
264+
265+ /// Records a successful operation in the history
266+ pub fn record_history(&mut self, operation_type: OperationType, description: impl Into<String>) {
267+ let mut entry = HistoryEntry::new(operation_type, description.into());
268+ entry.mark_success();
269+ self.history_manager.add_entry(entry);
270+ }
271+
272+ /// Records a failed operation in the history
273+ pub fn record_history_failure(
274+ &mut self,
275+ operation_type: OperationType,
276+ description: impl Into<String>,
277+ error: impl Into<String>,
278+ ) {
279+ let mut entry = HistoryEntry::new(operation_type, description.into());
280+ entry.mark_failed(error.into());
281+ self.history_manager.add_entry(entry);
282+ }
283+
187284 /// Returns the number of feeds
188285 pub fn feed_count(&self) -> usize {
189286 self.feeds.len()
@@ -235,15 +332,12 @@ impl AppState {
235332 self.notification_manager.success("Playlist ajoutée");
236333 }
237334
238- /// Removes a playlist by index
239- pub fn remove_playlist(&mut self, index: usize) -> Option<Playlist> {
240- if index < self.playlists.len() {
241- let playlist = self.playlists.remove(index);
242- self.notification_manager.success("Playlist supprimée");
243- Some(playlist)
244- } else {
245- None
246- }
335+ /// Removes a playlist by id, returning the removed playlist if found
336+ pub fn remove_playlist_by_id(&mut self, playlist_id: &str) -> Option<Playlist> {
337+ let index = self.playlists.iter().position(|p| p.id == playlist_id)?;
338+ let playlist = self.playlists.remove(index);
339+ self.notification_manager.success("Playlist supprimée");
340+ Some(playlist)
247341 }
248342
249343 /// Clears the add playlist form fields
@@ -263,7 +357,6 @@ impl AppState {
263357 pub fn clear_filters(&mut self) {
264358 self.filter_date_from = None;
265359 self.filter_date_to = None;
266- self.filter_episode_number.clear();
267360 self.notification_manager.info("Filtres effacés");
268361 }
269362
@@ -279,14 +372,18 @@ impl AppState {
279372 self.export_import_error = None;
280373 }
281374
282- /// Toggles the theme between light and dark
375+ /// Toggles the theme between light and dark and persists the choice
283376 pub fn toggle_theme(&mut self) {
284377 self.theme.toggle();
285378 let theme_name = match self.theme {
286379 Theme::Dark => "sombre",
287380 Theme::Light => "clair",
288381 };
289- self.notification_manager.info(format!("Thème basculé vers: {}", theme_name));
382+ if let Err(e) = crate::config::settings::save_theme(self.theme) {
383+ log::error!("Failed to save theme: {}", e);
384+ }
385+ self.notification_manager
386+ .info(format!("Thème basculé vers: {}", theme_name));
290387 }
291388
292389 /// Gets the current theme
@@ -311,21 +408,45 @@ mod tests {
311408 fn test_add_feed() {
312409 let mut app_state = AppState::new();
313410 let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads");
314-
411+
315412 app_state.add_feed(feed);
316-
413+
317414 assert_eq!(app_state.feeds.len(), 1);
318415 assert_eq!(app_state.feeds[0].name, "Test Podcast");
319416 }
320417
418+ #[test]
419+ fn test_delete_feed_by_id() {
420+ let mut app_state = AppState::new();
421+ let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads");
422+ let feed_id = feed.id.clone();
423+ app_state.add_feed(feed);
424+
425+ assert!(app_state.delete_feed_by_id(&feed_id).is_some());
426+ assert!(app_state.feeds.is_empty());
427+ assert!(app_state.delete_feed_by_id(&feed_id).is_none());
428+ }
429+
430+ #[test]
431+ fn test_remove_playlist_by_id() {
432+ let mut app_state = AppState::new();
433+ let playlist = Playlist::new("Ma playlist");
434+ let playlist_id = playlist.id.clone();
435+ app_state.add_playlist(playlist);
436+
437+ assert!(app_state.remove_playlist_by_id(&playlist_id).is_some());
438+ assert!(app_state.playlists.is_empty());
439+ assert!(app_state.remove_playlist_by_id(&playlist_id).is_none());
440+ }
441+
321442 #[test]
322443 fn test_select_feed() {
323444 let mut app_state = AppState::new();
324445 let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads");
325-
446+
326447 app_state.add_feed(feed);
327448 app_state.select_feed(Some(0));
328-
449+
329450 assert_eq!(app_state.current_feed, Some(0));
330451 assert_eq!(app_state.feeds[0].name, "Test Podcast");
331452 }
@@ -334,10 +455,10 @@ mod tests {
334455 fn test_feed_count() {
335456 let mut app_state = AppState::new();
336457 assert_eq!(app_state.feed_count(), 0);
337-
458+
338459 app_state.add_feed(Feed::new("url1", "Podcast 1", "path1"));
339460 assert_eq!(app_state.feed_count(), 1);
340-
461+
341462 app_state.add_feed(Feed::new("url2", "Podcast 2", "path2"));
342463 assert_eq!(app_state.feed_count(), 2);
343464 }
@@ -345,13 +466,13 @@ mod tests {
345466 #[test]
346467 fn test_enabled_feed_count() {
347468 let mut app_state = AppState::new();
348-
469+
349470 app_state.add_feed(Feed::new("url1", "Podcast 1", "path1")); // enabled
350471 app_state.add_feed(Feed::new("url2", "Podcast 2", "path2")); // enabled
351472 app_state.add_feed(Feed::new("url3", "Podcast 3", "path3")); // will be disabled
352-
473+
353474 app_state.feeds[2].enabled = false;
354-
475+
355476 assert_eq!(app_state.enabled_feed_count(), 2);
356477 }
357478 }
@@ -2,15 +2,16 @@
2 //!2 //!
3 //! This module defines the main application state structure.3 //! This module defines the main application state structure.
4 4
5+use crate::config::episode::Episode;
5 use crate::config::feed::Feed;6 use crate::config::feed::Feed;
6-use crate::config::history::HistoryManager;7+use crate::config::history::{HistoryEntry, HistoryManager, OperationType};
7 use crate::config::playlist::Playlist;8 use crate::config::playlist::Playlist;
8 use crate::config::search::SearchQuery;9 use crate::config::search::SearchQuery;
9-use crate::config::statistics::AppStatistics;
10 use crate::config::sync_task::SyncTaskManager;10 use crate::config::sync_task::SyncTaskManager;
11 use crate::ui::notifications::NotificationManager;11 use crate::ui::notifications::NotificationManager;
12 use chrono::{DateTime, Utc};12 use chrono::{DateTime, Utc};
13 use serde::{Deserialize, Serialize};13 use serde::{Deserialize, Serialize};
14+use std::collections::HashSet;
14 15
15 /// Theme options for the application16 /// Theme options for the application
16 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]17 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -90,8 +91,6 @@ pub struct AppState {
90 pub filter_date_from: Option<DateTime<Utc>>,91 pub filter_date_from: Option<DateTime<Utc>>,
91 /// Filter: date to (inclusive)92 /// Filter: date to (inclusive)
92 pub filter_date_to: Option<DateTime<Utc>>,93 pub filter_date_to: Option<DateTime<Utc>>,
93- /// Filter: episode number
94- pub filter_episode_number: String,
95 /// Whether to show the date picker for 'from' date94 /// Whether to show the date picker for 'from' date
96 pub show_date_picker_from: bool,95 pub show_date_picker_from: bool,
97 /// Whether to show the date picker for 'to' date96 /// Whether to show the date picker for 'to' date
@@ -102,24 +101,38 @@ pub struct AppState {
102 pub show_import_dialog: bool,101 pub show_import_dialog: bool,
103 /// Last export/import error message102 /// Last export/import error message
104 pub export_import_error: Option<String>,103 pub export_import_error: Option<String>,
105- // Phase 9: Search functionality104+ /// Current search query (toolbar search bar)
106- /// Current search query
107- /// Not yet wired into the UI (see ui/search_bar.rs)
108- #[allow(dead_code)]
109 pub search_query: SearchQuery,105 pub search_query: SearchQuery,
110- /// Whether search panel is visible106+ /// Request to give focus to the search bar (Ctrl+F)
111- #[allow(dead_code)]107+ pub focus_search: bool,
112- pub show_search: bool,
113- // Phase 9: Statistics
114- /// Application statistics
115- /// Not yet wired into the UI (see ui/stats_panel.rs)
116- #[allow(dead_code)]
117- pub statistics: AppStatistics,
118- // Phase 9: History
119 /// History manager for tracking operations108 /// History manager for tracking operations
120- /// Not yet wired into the UI (see ui/history_panel.rs)
121- #[allow(dead_code)]
122 pub history_manager: HistoryManager,109 pub history_manager: HistoryManager,
110+ /// History window: filter by operation type
111+ pub history_filter_type: Option<OperationType>,
112+ /// History window: filter by status
113+ pub history_filter_status:
114+ Option<crate::config::history::OperationStatus>,
115+ /// Whether the statistics window is visible
116+ pub show_stats_window: bool,
117+ /// Whether the history window is visible
118+ pub show_history_window: bool,
119+ /// Feed pending deletion confirmation (feed id)
120+ pub confirm_delete_feed_id: Option<String>,
121+ /// Playlist pending deletion confirmation (playlist id)
122+ pub confirm_delete_playlist_id: Option<String>,
123+ // Feed detail view
124+ /// ID of the feed shown in the detail view
125+ pub detail_feed_id: Option<String>,
126+ /// Episodes of the feed shown in the detail view
127+ pub detail_episodes: Vec<Episode>,
128+ /// Whether episodes are currently being loaded in the detail view
129+ pub detail_loading: bool,
130+ /// Error message when loading episodes failed
131+ pub detail_error: Option<String>,
132+ /// Episode filter (detail view)
133+ pub detail_episode_filter: String,
134+ /// IDs of episodes currently being downloaded individually
135+ pub downloading_episode_ids: HashSet<String>,
123 /// Manager for async synchronization tasks136 /// Manager for async synchronization tasks
124 pub sync_task_manager: SyncTaskManager,137 pub sync_task_manager: SyncTaskManager,
125 }138 }
@@ -152,17 +165,26 @@ impl AppState {
152 playlists: Vec::new(),165 playlists: Vec::new(),
153 filter_date_from: None,166 filter_date_from: None,
154 filter_date_to: None,167 filter_date_to: None,
155- filter_episode_number: String::new(),
156 show_date_picker_from: false,168 show_date_picker_from: false,
157 show_date_picker_to: false,169 show_date_picker_to: false,
158 show_export_dialog: false,170 show_export_dialog: false,
159 show_import_dialog: false,171 show_import_dialog: false,
160 export_import_error: None,172 export_import_error: None,
161- // Phase 9 fields
162 search_query: SearchQuery::default(),173 search_query: SearchQuery::default(),
163- show_search: false,174+ focus_search: false,
164- statistics: AppStatistics::new(),
165 history_manager: HistoryManager::new(),175 history_manager: HistoryManager::new(),
176+ history_filter_type: None,
177+ history_filter_status: None,
178+ show_stats_window: false,
179+ show_history_window: false,
180+ confirm_delete_feed_id: None,
181+ confirm_delete_playlist_id: None,
182+ detail_feed_id: None,
183+ detail_episodes: Vec::new(),
184+ detail_loading: false,
185+ detail_error: None,
186+ detail_episode_filter: String::new(),
187+ downloading_episode_ids: HashSet::new(),
166 theme: Theme::default(),188 theme: Theme::default(),
167 sync_task_manager: SyncTaskManager::new(),189 sync_task_manager: SyncTaskManager::new(),
168 }190 }
@@ -175,15 +197,90 @@ impl AppState {
175 197
176 /// Adds a new feed to the application state198 /// Adds a new feed to the application state
177 pub fn add_feed(&mut self, feed: Feed) {199 pub fn add_feed(&mut self, feed: Feed) {
200+ self.record_history(
201+ OperationType::AddFeed,
202+ format!("Flux ajouté : {}", feed.name),
203+ );
178 self.feeds.push(feed);204 self.feeds.push(feed);
179 self.notification_manager.success("Flux ajouté avec succès");205 self.notification_manager.success("Flux ajouté avec succès");
180 }206 }
181 207
208+ /// Deletes a feed by id, returning the removed feed if found
209+ pub fn delete_feed_by_id(&mut self, feed_id: &str) -> Option<Feed> {
210+ let index = self.feeds.iter().position(|f| f.id == feed_id)?;
211+ let feed = self.feeds.remove(index);
212+ if self.current_feed == Some(index) {
213+ self.select_feed(None);
214+ }
215+ Some(feed)
216+ }
217+
182 /// Selects a feed by index218 /// Selects a feed by index
183 pub fn select_feed(&mut self, index: Option<usize>) {219 pub fn select_feed(&mut self, index: Option<usize>) {
184 self.current_feed = index;220 self.current_feed = index;
185 }221 }
186 222
223+ /// Opens the detail view for a feed and starts loading its episodes
224+ pub fn open_feed_detail(&mut self, feed_id: String) {
225+ let Some(feed) = self.feeds.iter().find(|f| f.id == feed_id).cloned() else {
226+ return;
227+ };
228+ self.detail_feed_id = Some(feed.id.clone());
229+ self.detail_episodes.clear();
230+ self.detail_error = None;
231+ self.detail_episode_filter.clear();
232+ self.detail_loading = true;
233+ self.sync_task_manager.start_load_episodes(feed);
234+ }
235+
236+ /// Closes the feed detail view
237+ pub fn close_feed_detail(&mut self) {
238+ self.detail_feed_id = None;
239+ self.detail_episodes.clear();
240+ self.detail_error = None;
241+ self.detail_episode_filter.clear();
242+ self.detail_loading = false;
243+ }
244+
245+ /// Starts an asynchronous synchronization of all feeds
246+ pub fn start_sync_all_feeds(&mut self) {
247+ if !self.has_feeds() {
248+ self.notification_manager.info("Aucun flux à synchroniser");
249+ return;
250+ }
251+
252+ log::info!("Starting asynchronous synchronization of all feeds");
253+
254+ let feeds_to_sync = self.feeds.clone();
255+ self.sync_task_manager.start_sync_all_feeds(feeds_to_sync);
256+ self.record_history(
257+ OperationType::SyncAll,
258+ "Synchronisation de tous les flux lancée".to_string(),
259+ );
260+
261+ self.notification_manager
262+ .info("Synchronisation de tous les flux en cours...");
263+ }
264+
265+ /// Records a successful operation in the history
266+ pub fn record_history(&mut self, operation_type: OperationType, description: impl Into<String>) {
267+ let mut entry = HistoryEntry::new(operation_type, description.into());
268+ entry.mark_success();
269+ self.history_manager.add_entry(entry);
270+ }
271+
272+ /// Records a failed operation in the history
273+ pub fn record_history_failure(
274+ &mut self,
275+ operation_type: OperationType,
276+ description: impl Into<String>,
277+ error: impl Into<String>,
278+ ) {
279+ let mut entry = HistoryEntry::new(operation_type, description.into());
280+ entry.mark_failed(error.into());
281+ self.history_manager.add_entry(entry);
282+ }
283+
187 /// Returns the number of feeds284 /// Returns the number of feeds
188 pub fn feed_count(&self) -> usize {285 pub fn feed_count(&self) -> usize {
189 self.feeds.len()286 self.feeds.len()
@@ -235,15 +332,12 @@ impl AppState {
235 self.notification_manager.success("Playlist ajoutée");332 self.notification_manager.success("Playlist ajoutée");
236 }333 }
237 334
238- /// Removes a playlist by index335+ /// Removes a playlist by id, returning the removed playlist if found
239- pub fn remove_playlist(&mut self, index: usize) -> Option<Playlist> {336+ pub fn remove_playlist_by_id(&mut self, playlist_id: &str) -> Option<Playlist> {
240- if index < self.playlists.len() {337+ let index = self.playlists.iter().position(|p| p.id == playlist_id)?;
241- let playlist = self.playlists.remove(index);338+ let playlist = self.playlists.remove(index);
242- self.notification_manager.success("Playlist supprimée");339+ self.notification_manager.success("Playlist supprimée");
243- Some(playlist)340+ Some(playlist)
244- } else {
245- None
246- }
247 }341 }
248 342
249 /// Clears the add playlist form fields343 /// Clears the add playlist form fields
@@ -263,7 +357,6 @@ impl AppState {
263 pub fn clear_filters(&mut self) {357 pub fn clear_filters(&mut self) {
264 self.filter_date_from = None;358 self.filter_date_from = None;
265 self.filter_date_to = None;359 self.filter_date_to = None;
266- self.filter_episode_number.clear();
267 self.notification_manager.info("Filtres effacés");360 self.notification_manager.info("Filtres effacés");
268 }361 }
269 362
@@ -279,14 +372,18 @@ impl AppState {
279 self.export_import_error = None;372 self.export_import_error = None;
280 }373 }
281 374
282- /// Toggles the theme between light and dark375+ /// Toggles the theme between light and dark and persists the choice
283 pub fn toggle_theme(&mut self) {376 pub fn toggle_theme(&mut self) {
284 self.theme.toggle();377 self.theme.toggle();
285 let theme_name = match self.theme {378 let theme_name = match self.theme {
286 Theme::Dark => "sombre",379 Theme::Dark => "sombre",
287 Theme::Light => "clair",380 Theme::Light => "clair",
288 };381 };
289- self.notification_manager.info(format!("Thème basculé vers: {}", theme_name));382+ if let Err(e) = crate::config::settings::save_theme(self.theme) {
383+ log::error!("Failed to save theme: {}", e);
384+ }
385+ self.notification_manager
386+ .info(format!("Thème basculé vers: {}", theme_name));
290 }387 }
291 388
292 /// Gets the current theme389 /// Gets the current theme
@@ -311,21 +408,45 @@ mod tests {
311 fn test_add_feed() {408 fn test_add_feed() {
312 let mut app_state = AppState::new();409 let mut app_state = AppState::new();
313 let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads");410 let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads");
314- 411+
315 app_state.add_feed(feed);412 app_state.add_feed(feed);
316- 413+
317 assert_eq!(app_state.feeds.len(), 1);414 assert_eq!(app_state.feeds.len(), 1);
318 assert_eq!(app_state.feeds[0].name, "Test Podcast");415 assert_eq!(app_state.feeds[0].name, "Test Podcast");
319 }416 }
320 417
418+ #[test]
419+ fn test_delete_feed_by_id() {
420+ let mut app_state = AppState::new();
421+ let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads");
422+ let feed_id = feed.id.clone();
423+ app_state.add_feed(feed);
424+
425+ assert!(app_state.delete_feed_by_id(&feed_id).is_some());
426+ assert!(app_state.feeds.is_empty());
427+ assert!(app_state.delete_feed_by_id(&feed_id).is_none());
428+ }
429+
430+ #[test]
431+ fn test_remove_playlist_by_id() {
432+ let mut app_state = AppState::new();
433+ let playlist = Playlist::new("Ma playlist");
434+ let playlist_id = playlist.id.clone();
435+ app_state.add_playlist(playlist);
436+
437+ assert!(app_state.remove_playlist_by_id(&playlist_id).is_some());
438+ assert!(app_state.playlists.is_empty());
439+ assert!(app_state.remove_playlist_by_id(&playlist_id).is_none());
440+ }
441+
321 #[test]442 #[test]
322 fn test_select_feed() {443 fn test_select_feed() {
323 let mut app_state = AppState::new();444 let mut app_state = AppState::new();
324 let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads");445 let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads");
325- 446+
326 app_state.add_feed(feed);447 app_state.add_feed(feed);
327 app_state.select_feed(Some(0));448 app_state.select_feed(Some(0));
328- 449+
329 assert_eq!(app_state.current_feed, Some(0));450 assert_eq!(app_state.current_feed, Some(0));
330 assert_eq!(app_state.feeds[0].name, "Test Podcast");451 assert_eq!(app_state.feeds[0].name, "Test Podcast");
331 }452 }
@@ -334,10 +455,10 @@ mod tests {
334 fn test_feed_count() {455 fn test_feed_count() {
335 let mut app_state = AppState::new();456 let mut app_state = AppState::new();
336 assert_eq!(app_state.feed_count(), 0);457 assert_eq!(app_state.feed_count(), 0);
337- 458+
338 app_state.add_feed(Feed::new("url1", "Podcast 1", "path1"));459 app_state.add_feed(Feed::new("url1", "Podcast 1", "path1"));
339 assert_eq!(app_state.feed_count(), 1);460 assert_eq!(app_state.feed_count(), 1);
340- 461+
341 app_state.add_feed(Feed::new("url2", "Podcast 2", "path2"));462 app_state.add_feed(Feed::new("url2", "Podcast 2", "path2"));
342 assert_eq!(app_state.feed_count(), 2);463 assert_eq!(app_state.feed_count(), 2);
343 }464 }
@@ -345,13 +466,13 @@ mod tests {
345 #[test]466 #[test]
346 fn test_enabled_feed_count() {467 fn test_enabled_feed_count() {
347 let mut app_state = AppState::new();468 let mut app_state = AppState::new();
348- 469+
349 app_state.add_feed(Feed::new("url1", "Podcast 1", "path1")); // enabled470 app_state.add_feed(Feed::new("url1", "Podcast 1", "path1")); // enabled
350 app_state.add_feed(Feed::new("url2", "Podcast 2", "path2")); // enabled471 app_state.add_feed(Feed::new("url2", "Podcast 2", "path2")); // enabled
351 app_state.add_feed(Feed::new("url3", "Podcast 3", "path3")); // will be disabled472 app_state.add_feed(Feed::new("url3", "Podcast 3", "path3")); // will be disabled
352- 473+
353 app_state.feeds[2].enabled = false;474 app_state.feeds[2].enabled = false;
354- 475+
355 assert_eq!(app_state.enabled_feed_count(), 2);476 assert_eq!(app_state.enabled_feed_count(), 2);
356 }477 }
357 }478 }
modified src/config/mod.rs +1 -0
@@ -9,6 +9,7 @@ pub mod feed;
99 pub mod history;
1010 pub mod playlist;
1111 pub mod search;
12+pub mod settings;
1213 pub mod statistics;
1314 pub mod storage;
1415 pub mod sync_task;
@@ -9,6 +9,7 @@ pub mod feed;
9 pub mod history;9 pub mod history;
10 pub mod playlist;10 pub mod playlist;
11 pub mod search;11 pub mod search;
12+pub mod settings;
12 pub mod statistics;13 pub mod statistics;
13 pub mod storage;14 pub mod storage;
14 pub mod sync_task;15 pub mod sync_task;
modified src/config/search.rs +1 -1
@@ -57,7 +57,7 @@ impl SearchQuery {
5757 }
5858
5959 /// Vérifier si le texte contient la requête
60- fn contains_query(&self, text: &str) -> bool {
60+ pub fn contains_query(&self, text: &str) -> bool {
6161 if self.is_empty() {
6262 return true;
6363 }
@@ -57,7 +57,7 @@ impl SearchQuery {
57 }57 }
58 58
59 /// Vérifier si le texte contient la requête59 /// Vérifier si le texte contient la requête
60- fn contains_query(&self, text: &str) -> bool {60+ pub fn contains_query(&self, text: &str) -> bool {
61 if self.is_empty() {61 if self.is_empty() {
62 return true;62 return true;
63 }63 }
added src/config/settings.rs +102 -0
new file mode 100644
@@ -0,0 +1,102 @@
1+//! Settings module - Application settings persistence
2+//!
3+//! Persists user preferences (theme, etc.) to a JSON settings file.
4+
5+use crate::config::app_state::Theme;
6+use log::{info, warn};
7+use serde::{Deserialize, Serialize};
8+use std::fs;
9+use std::path::PathBuf;
10+use thiserror::Error;
11+
12+/// Error type for settings operations
13+#[derive(Debug, Error)]
14+pub enum SettingsError {
15+ #[error("Failed to write settings file: {0}")]
16+ WriteError(String),
17+ #[error("Failed to serialize settings: {0}")]
18+ SerializeError(String),
19+}
20+
21+/// Application settings persisted across sessions
22+#[derive(Debug, Default, Serialize, Deserialize)]
23+pub struct Settings {
24+ /// UI theme (dark or light)
25+ pub theme: Theme,
26+}
27+
28+/// Returns the path of the settings file in the configuration directory
29+pub fn get_settings_path() -> PathBuf {
30+ crate::config::storage::get_config_dir().join("settings.json")
31+}
32+
33+/// Saves the current settings (theme) to the default settings file
34+pub fn save_theme(theme: Theme) -> Result<(), SettingsError> {
35+ let settings = Settings { theme };
36+ let path = get_settings_path();
37+
38+ if let Some(parent) = path.parent() {
39+ if !parent.exists() {
40+ fs::create_dir_all(parent).map_err(|e| {
41+ SettingsError::WriteError(format!("{}: {}", parent.display(), e))
42+ })?;
43+ }
44+ }
45+
46+ let json = serde_json::to_string_pretty(&settings)
47+ .map_err(|e| SettingsError::SerializeError(e.to_string()))?;
48+ fs::write(&path, json).map_err(|e| SettingsError::WriteError(e.to_string()))?;
49+
50+ info!("Saved theme settings to {}", path.display());
51+ Ok(())
52+}
53+
54+/// Loads the persisted theme, if a settings file exists and is valid
55+pub fn load_theme() -> Option<Theme> {
56+ let path = get_settings_path();
57+ if !path.exists() {
58+ return None;
59+ }
60+
61+ let content = match fs::read_to_string(&path) {
62+ Ok(content) => content,
63+ Err(e) => {
64+ warn!("Failed to read settings file {}: {}", path.display(), e);
65+ return None;
66+ }
67+ };
68+
69+ match serde_json::from_str::<Settings>(&content) {
70+ Ok(settings) => Some(settings.theme),
71+ Err(e) => {
72+ warn!("Failed to parse settings file {}: {}", path.display(), e);
73+ None
74+ }
75+ }
76+}
77+
78+/// Returns the path of the history file in the configuration directory
79+pub fn get_history_path() -> PathBuf {
80+ crate::config::storage::get_config_dir().join("history.json")
81+}
82+
83+#[cfg(test)]
84+mod tests {
85+ use super::*;
86+
87+ #[test]
88+ fn test_settings_default() {
89+ let settings = Settings::default();
90+ assert_eq!(settings.theme, Theme::Dark);
91+ }
92+
93+ #[test]
94+ fn test_settings_roundtrip() {
95+ let settings = Settings {
96+ theme: Theme::Light,
97+ };
98+ let json = serde_json::to_string(&settings).unwrap();
99+ let parsed: Settings = serde_json::from_str(&json).unwrap();
100+ assert_eq!(parsed.theme, Theme::Light);
101+ }
102+}
new file mode 100644
@@ -0,0 +1,102 @@
1+//! Settings module - Application settings persistence
2+//!
3+//! Persists user preferences (theme, etc.) to a JSON settings file.
4+
5+use crate::config::app_state::Theme;
6+use log::{info, warn};
7+use serde::{Deserialize, Serialize};
8+use std::fs;
9+use std::path::PathBuf;
10+use thiserror::Error;
11+
12+/// Error type for settings operations
13+#[derive(Debug, Error)]
14+pub enum SettingsError {
15+ #[error("Failed to write settings file: {0}")]
16+ WriteError(String),
17+ #[error("Failed to serialize settings: {0}")]
18+ SerializeError(String),
19+}
20+
21+/// Application settings persisted across sessions
22+#[derive(Debug, Default, Serialize, Deserialize)]
23+pub struct Settings {
24+ /// UI theme (dark or light)
25+ pub theme: Theme,
26+}
27+
28+/// Returns the path of the settings file in the configuration directory
29+pub fn get_settings_path() -> PathBuf {
30+ crate::config::storage::get_config_dir().join("settings.json")
31+}
32+
33+/// Saves the current settings (theme) to the default settings file
34+pub fn save_theme(theme: Theme) -> Result<(), SettingsError> {
35+ let settings = Settings { theme };
36+ let path = get_settings_path();
37+
38+ if let Some(parent) = path.parent() {
39+ if !parent.exists() {
40+ fs::create_dir_all(parent).map_err(|e| {
41+ SettingsError::WriteError(format!("{}: {}", parent.display(), e))
42+ })?;
43+ }
44+ }
45+
46+ let json = serde_json::to_string_pretty(&settings)
47+ .map_err(|e| SettingsError::SerializeError(e.to_string()))?;
48+ fs::write(&path, json).map_err(|e| SettingsError::WriteError(e.to_string()))?;
49+
50+ info!("Saved theme settings to {}", path.display());
51+ Ok(())
52+}
53+
54+/// Loads the persisted theme, if a settings file exists and is valid
55+pub fn load_theme() -> Option<Theme> {
56+ let path = get_settings_path();
57+ if !path.exists() {
58+ return None;
59+ }
60+
61+ let content = match fs::read_to_string(&path) {
62+ Ok(content) => content,
63+ Err(e) => {
64+ warn!("Failed to read settings file {}: {}", path.display(), e);
65+ return None;
66+ }
67+ };
68+
69+ match serde_json::from_str::<Settings>(&content) {
70+ Ok(settings) => Some(settings.theme),
71+ Err(e) => {
72+ warn!("Failed to parse settings file {}: {}", path.display(), e);
73+ None
74+ }
75+ }
76+}
77+
78+/// Returns the path of the history file in the configuration directory
79+pub fn get_history_path() -> PathBuf {
80+ crate::config::storage::get_config_dir().join("history.json")
81+}
82+
83+#[cfg(test)]
84+mod tests {
85+ use super::*;
86+
87+ #[test]
88+ fn test_settings_default() {
89+ let settings = Settings::default();
90+ assert_eq!(settings.theme, Theme::Dark);
91+ }
92+
93+ #[test]
94+ fn test_settings_roundtrip() {
95+ let settings = Settings {
96+ theme: Theme::Light,
97+ };
98+ let json = serde_json::to_string(&settings).unwrap();
99+ let parsed: Settings = serde_json::from_str(&json).unwrap();
100+ assert_eq!(parsed.theme, Theme::Light);
101+ }
102+}
modified src/config/sync_task.rs +121 -34
@@ -35,6 +35,23 @@ pub enum SyncMessage {
3535 episode_title: String,
3636 error: String,
3737 },
38+ /// Episodes of a feed were loaded (detail view)
39+ EpisodesLoaded {
40+ feed_id: String,
41+ episodes: Vec<crate::config::episode::Episode>,
42+ },
43+ /// Episode list load failed (detail view)
44+ EpisodesLoadFailed {
45+ feed_id: String,
46+ error: String,
47+ },
48+ /// Individual episode download completed
49+ EpisodeDownloadCompleted {
50+ feed_id: String,
51+ episode_id: String,
52+ episode_title: String,
53+ size: Option<u64>,
54+ },
3855 }
3956
4057 /// Status of a feed synchronization
@@ -92,10 +109,12 @@ impl SyncStatus {
92109 /// Update feed sync status
93110 pub fn update_feed_status(&mut self, feed_id: String, status: FeedSyncStatus) {
94111 self.feed_sync_status.insert(feed_id, status);
95-
112+
96113 // Update overall progress
97114 if self.global_sync_in_progress && self.total_feeds > 0 {
98- let completed = self.feed_sync_status.values()
115+ let completed = self
116+ .feed_sync_status
117+ .values()
99118 .filter(|s| matches!(s, FeedSyncStatus::Completed | FeedSyncStatus::Failed { .. }))
100119 .count();
101120 self.completed_feeds = completed;
@@ -107,17 +126,17 @@ impl SyncStatus {
107126 pub fn get_overall_progress(&self) -> f32 {
108127 self.overall_progress
109128 }
110-
129+
111130 /// Check if there are any sync activities (in progress or recently completed)
112131 /// Used to determine if we should show sync-related UI elements
113132 pub fn has_sync_activity(&self) -> bool {
114- self.global_sync_in_progress ||
115- !self.feed_sync_status.is_empty()
133+ self.global_sync_in_progress || !self.feed_sync_status.is_empty()
116134 }
117135
118136 /// Get syncing feed count
119137 pub fn syncing_feed_count(&self) -> usize {
120- self.feed_sync_status.values()
138+ self.feed_sync_status
139+ .values()
121140 .filter(|s| matches!(s, FeedSyncStatus::InProgress))
122141 .count()
123142 }
@@ -148,15 +167,15 @@ impl SyncTaskManager {
148167 /// Process incoming messages and update status
149168 pub fn process_messages(&mut self) -> Vec<SyncMessage> {
150169 let mut messages = Vec::new();
151-
170+
152171 let receiver = self.receiver.clone();
153172 let receiver_guard = receiver.lock().unwrap();
154-
173+
155174 while let Ok(msg) = receiver_guard.try_recv() {
156175 self.handle_message(&msg);
157176 messages.push(msg);
158177 }
159-
178+
160179 messages
161180 }
162181
@@ -164,17 +183,13 @@ impl SyncTaskManager {
164183 fn handle_message(&mut self, message: &SyncMessage) {
165184 match message {
166185 SyncMessage::SyncStarted { feed_id } => {
167- self.status.update_feed_status(
168- feed_id.clone(),
169- FeedSyncStatus::InProgress,
170- );
186+ self.status
187+ .update_feed_status(feed_id.clone(), FeedSyncStatus::InProgress);
171188 }
172189 SyncMessage::SyncCompleted { feed_id, .. } => {
173- self.status.update_feed_status(
174- feed_id.clone(),
175- FeedSyncStatus::Completed,
176- );
177-
190+ self.status
191+ .update_feed_status(feed_id.clone(), FeedSyncStatus::Completed);
192+
178193 // Check if all feeds are completed
179194 if self.status.global_sync_in_progress {
180195 let all_completed = self.status.feed_sync_status.values().all(|s| {
@@ -190,9 +205,9 @@ impl SyncTaskManager {
190205 feed_id.clone(),
191206 FeedSyncStatus::Failed {
192207 error: error.clone(),
193- }
208+ },
194209 );
195-
210+
196211 // Check if all feeds are completed (including failed ones)
197212 if self.status.global_sync_in_progress {
198213 let all_completed = self.status.feed_sync_status.values().all(|s| {
@@ -203,8 +218,11 @@ impl SyncTaskManager {
203218 }
204219 }
205220 }
206- SyncMessage::EpisodeDownloadFailed { .. } => {
207- // Displayed by the caller (home.rs), no status update needed
221+ SyncMessage::EpisodeDownloadFailed { .. }
222+ | SyncMessage::EpisodesLoaded { .. }
223+ | SyncMessage::EpisodesLoadFailed { .. }
224+ | SyncMessage::EpisodeDownloadCompleted { .. } => {
225+ // Consumed directly by the caller (home.rs), no status update needed
208226 }
209227 }
210228 }
@@ -212,9 +230,9 @@ impl SyncTaskManager {
212230 /// Start syncing all feeds asynchronously
213231 pub fn start_sync_all_feeds(&mut self, feeds: Vec<crate::config::feed::Feed>) {
214232 self.status.start_global_sync(feeds.len());
215-
233+
216234 let sender = self.sender.clone();
217-
235+
218236 thread::spawn(move || {
219237 crate::rss::async_sync::sync_all_feeds_async(feeds, sender);
220238 });
@@ -224,15 +242,79 @@ impl SyncTaskManager {
224242 pub fn start_sync_feed(&mut self, feed: crate::config::feed::Feed) {
225243 let sender = self.sender.clone();
226244 let feed_id = feed.id.clone();
227-
245+
228246 // Update status
229- self.status.update_feed_status(feed_id, FeedSyncStatus::InProgress);
230-
247+ self.status
248+ .update_feed_status(feed_id, FeedSyncStatus::InProgress);
249+
231250 thread::spawn(move || {
232251 crate::rss::async_sync::sync_feed_async(feed, sender);
233252 });
234253 }
235-
254+
255+ /// Loads the episodes of a feed asynchronously (detail view)
256+ pub fn start_load_episodes(&mut self, feed: crate::config::feed::Feed) {
257+ let sender = self.sender.clone();
258+ let feed_id = feed.id.clone();
259+
260+ thread::spawn(move || {
261+ let result = (|| -> Result<Vec<crate::config::episode::Episode>, String> {
262+ let content =
263+ crate::rss::parser::fetch_feed(&feed.url).map_err(|e| e.to_string())?;
264+ let channel = crate::rss::parser::parse_rss_content(&content)
265+ .map_err(|e| e.to_string())?;
266+ crate::rss::episode_extractor::extract_episodes(&channel).map_err(|e| e.to_string())
267+ })();
268+
269+ match result {
270+ Ok(episodes) => {
271+ let _ = sender.send(SyncMessage::EpisodesLoaded {
272+ feed_id,
273+ episodes,
274+ });
275+ }
276+ Err(error) => {
277+ let _ = sender.send(SyncMessage::EpisodesLoadFailed { feed_id, error });
278+ }
279+ }
280+ });
281+ }
282+
283+ /// Downloads a single episode asynchronously (detail view)
284+ pub fn start_download_episode(
285+ &mut self,
286+ feed: crate::config::feed::Feed,
287+ episode: crate::config::episode::Episode,
288+ ) {
289+ let sender = self.sender.clone();
290+ let feed_id = feed.id.clone();
291+
292+ thread::spawn(move || {
293+ match crate::rss::downloader::download_episode(
294+ &episode,
295+ &feed.download_path,
296+ Box::new(|_| {}),
297+ ) {
298+ Ok(path) => {
299+ let size = std::fs::metadata(&path).map(|m| m.len()).ok();
300+ let _ = sender.send(SyncMessage::EpisodeDownloadCompleted {
301+ feed_id,
302+ episode_id: episode.id.clone(),
303+ episode_title: episode.title.clone(),
304+ size,
305+ });
306+ }
307+ Err(e) => {
308+ let _ = sender.send(SyncMessage::EpisodeDownloadFailed {
309+ feed_id,
310+ episode_title: episode.title.clone(),
311+ error: e.to_string(),
312+ });
313+ }
314+ }
315+ });
316+ }
317+
236318 /// Clears completed and failed sync statuses
237319 pub fn clear_completed_sync_status(&mut self) {
238320 self.status.feed_sync_status.retain(|_, status| {
@@ -264,7 +346,7 @@ mod tests {
264346 fn test_sync_status_start_global_sync() {
265347 let mut status = SyncStatus::new();
266348 status.start_global_sync(5);
267-
349+
268350 assert!(status.global_sync_in_progress);
269351 assert_eq!(status.total_feeds, 5);
270352 assert_eq!(status.completed_feeds, 0);
@@ -275,17 +357,22 @@ mod tests {
275357 fn test_sync_status_overall_progress() {
276358 let mut status = SyncStatus::new();
277359 status.start_global_sync(3);
278-
360+
279361 // Add completed feeds
280362 status.update_feed_status("feed-1".to_string(), FeedSyncStatus::Completed);
281363 status.update_feed_status("feed-2".to_string(), FeedSyncStatus::Completed);
282-
364+
283365 assert_eq!(status.completed_feeds, 2);
284366 assert!((status.get_overall_progress() - 0.666).abs() < 0.01);
285-
367+
286368 // Add failed feed
287- status.update_feed_status("feed-3".to_string(), FeedSyncStatus::Failed { error: "Error".to_string() });
288-
369+ status.update_feed_status(
370+ "feed-3".to_string(),
371+ FeedSyncStatus::Failed {
372+ error: "Error".to_string(),
373+ },
374+ );
375+
289376 assert_eq!(status.completed_feeds, 3);
290377 assert_eq!(status.get_overall_progress(), 1.0);
291378 }
@@ -35,6 +35,23 @@ pub enum SyncMessage {
35 episode_title: String,35 episode_title: String,
36 error: String,36 error: String,
37 },37 },
38+ /// Episodes of a feed were loaded (detail view)
39+ EpisodesLoaded {
40+ feed_id: String,
41+ episodes: Vec<crate::config::episode::Episode>,
42+ },
43+ /// Episode list load failed (detail view)
44+ EpisodesLoadFailed {
45+ feed_id: String,
46+ error: String,
47+ },
48+ /// Individual episode download completed
49+ EpisodeDownloadCompleted {
50+ feed_id: String,
51+ episode_id: String,
52+ episode_title: String,
53+ size: Option<u64>,
54+ },
38 }55 }
39 56
40 /// Status of a feed synchronization57 /// Status of a feed synchronization
@@ -92,10 +109,12 @@ impl SyncStatus {
92 /// Update feed sync status109 /// Update feed sync status
93 pub fn update_feed_status(&mut self, feed_id: String, status: FeedSyncStatus) {110 pub fn update_feed_status(&mut self, feed_id: String, status: FeedSyncStatus) {
94 self.feed_sync_status.insert(feed_id, status);111 self.feed_sync_status.insert(feed_id, status);
95- 112+
96 // Update overall progress113 // Update overall progress
97 if self.global_sync_in_progress && self.total_feeds > 0 {114 if self.global_sync_in_progress && self.total_feeds > 0 {
98- let completed = self.feed_sync_status.values()115+ let completed = self
116+ .feed_sync_status
117+ .values()
99 .filter(|s| matches!(s, FeedSyncStatus::Completed | FeedSyncStatus::Failed { .. }))118 .filter(|s| matches!(s, FeedSyncStatus::Completed | FeedSyncStatus::Failed { .. }))
100 .count();119 .count();
101 self.completed_feeds = completed;120 self.completed_feeds = completed;
@@ -107,17 +126,17 @@ impl SyncStatus {
107 pub fn get_overall_progress(&self) -> f32 {126 pub fn get_overall_progress(&self) -> f32 {
108 self.overall_progress127 self.overall_progress
109 }128 }
110- 129+
111 /// Check if there are any sync activities (in progress or recently completed)130 /// Check if there are any sync activities (in progress or recently completed)
112 /// Used to determine if we should show sync-related UI elements131 /// Used to determine if we should show sync-related UI elements
113 pub fn has_sync_activity(&self) -> bool {132 pub fn has_sync_activity(&self) -> bool {
114- self.global_sync_in_progress || 133+ self.global_sync_in_progress || !self.feed_sync_status.is_empty()
115- !self.feed_sync_status.is_empty()
116 }134 }
117 135
118 /// Get syncing feed count136 /// Get syncing feed count
119 pub fn syncing_feed_count(&self) -> usize {137 pub fn syncing_feed_count(&self) -> usize {
120- self.feed_sync_status.values()138+ self.feed_sync_status
139+ .values()
121 .filter(|s| matches!(s, FeedSyncStatus::InProgress))140 .filter(|s| matches!(s, FeedSyncStatus::InProgress))
122 .count()141 .count()
123 }142 }
@@ -148,15 +167,15 @@ impl SyncTaskManager {
148 /// Process incoming messages and update status167 /// Process incoming messages and update status
149 pub fn process_messages(&mut self) -> Vec<SyncMessage> {168 pub fn process_messages(&mut self) -> Vec<SyncMessage> {
150 let mut messages = Vec::new();169 let mut messages = Vec::new();
151- 170+
152 let receiver = self.receiver.clone();171 let receiver = self.receiver.clone();
153 let receiver_guard = receiver.lock().unwrap();172 let receiver_guard = receiver.lock().unwrap();
154- 173+
155 while let Ok(msg) = receiver_guard.try_recv() {174 while let Ok(msg) = receiver_guard.try_recv() {
156 self.handle_message(&msg);175 self.handle_message(&msg);
157 messages.push(msg);176 messages.push(msg);
158 }177 }
159- 178+
160 messages179 messages
161 }180 }
162 181
@@ -164,17 +183,13 @@ impl SyncTaskManager {
164 fn handle_message(&mut self, message: &SyncMessage) {183 fn handle_message(&mut self, message: &SyncMessage) {
165 match message {184 match message {
166 SyncMessage::SyncStarted { feed_id } => {185 SyncMessage::SyncStarted { feed_id } => {
167- self.status.update_feed_status(186+ self.status
168- feed_id.clone(),187+ .update_feed_status(feed_id.clone(), FeedSyncStatus::InProgress);
169- FeedSyncStatus::InProgress,
170- );
171 }188 }
172 SyncMessage::SyncCompleted { feed_id, .. } => {189 SyncMessage::SyncCompleted { feed_id, .. } => {
173- self.status.update_feed_status(190+ self.status
174- feed_id.clone(),191+ .update_feed_status(feed_id.clone(), FeedSyncStatus::Completed);
175- FeedSyncStatus::Completed,192+
176- );
177-
178 // Check if all feeds are completed193 // Check if all feeds are completed
179 if self.status.global_sync_in_progress {194 if self.status.global_sync_in_progress {
180 let all_completed = self.status.feed_sync_status.values().all(|s| {195 let all_completed = self.status.feed_sync_status.values().all(|s| {
@@ -190,9 +205,9 @@ impl SyncTaskManager {
190 feed_id.clone(),205 feed_id.clone(),
191 FeedSyncStatus::Failed {206 FeedSyncStatus::Failed {
192 error: error.clone(),207 error: error.clone(),
193- }208+ },
194 );209 );
195- 210+
196 // Check if all feeds are completed (including failed ones)211 // Check if all feeds are completed (including failed ones)
197 if self.status.global_sync_in_progress {212 if self.status.global_sync_in_progress {
198 let all_completed = self.status.feed_sync_status.values().all(|s| {213 let all_completed = self.status.feed_sync_status.values().all(|s| {
@@ -203,8 +218,11 @@ impl SyncTaskManager {
203 }218 }
204 }219 }
205 }220 }
206- SyncMessage::EpisodeDownloadFailed { .. } => {221+ SyncMessage::EpisodeDownloadFailed { .. }
207- // Displayed by the caller (home.rs), no status update needed222+ | SyncMessage::EpisodesLoaded { .. }
223+ | SyncMessage::EpisodesLoadFailed { .. }
224+ | SyncMessage::EpisodeDownloadCompleted { .. } => {
225+ // Consumed directly by the caller (home.rs), no status update needed
208 }226 }
209 }227 }
210 }228 }
@@ -212,9 +230,9 @@ impl SyncTaskManager {
212 /// Start syncing all feeds asynchronously230 /// Start syncing all feeds asynchronously
213 pub fn start_sync_all_feeds(&mut self, feeds: Vec<crate::config::feed::Feed>) {231 pub fn start_sync_all_feeds(&mut self, feeds: Vec<crate::config::feed::Feed>) {
214 self.status.start_global_sync(feeds.len());232 self.status.start_global_sync(feeds.len());
215- 233+
216 let sender = self.sender.clone();234 let sender = self.sender.clone();
217- 235+
218 thread::spawn(move || {236 thread::spawn(move || {
219 crate::rss::async_sync::sync_all_feeds_async(feeds, sender);237 crate::rss::async_sync::sync_all_feeds_async(feeds, sender);
220 });238 });
@@ -224,15 +242,79 @@ impl SyncTaskManager {
224 pub fn start_sync_feed(&mut self, feed: crate::config::feed::Feed) {242 pub fn start_sync_feed(&mut self, feed: crate::config::feed::Feed) {
225 let sender = self.sender.clone();243 let sender = self.sender.clone();
226 let feed_id = feed.id.clone();244 let feed_id = feed.id.clone();
227- 245+
228 // Update status246 // Update status
229- self.status.update_feed_status(feed_id, FeedSyncStatus::InProgress);247+ self.status
230- 248+ .update_feed_status(feed_id, FeedSyncStatus::InProgress);
249+
231 thread::spawn(move || {250 thread::spawn(move || {
232 crate::rss::async_sync::sync_feed_async(feed, sender);251 crate::rss::async_sync::sync_feed_async(feed, sender);
233 });252 });
234 }253 }
235- 254+
255+ /// Loads the episodes of a feed asynchronously (detail view)
256+ pub fn start_load_episodes(&mut self, feed: crate::config::feed::Feed) {
257+ let sender = self.sender.clone();
258+ let feed_id = feed.id.clone();
259+
260+ thread::spawn(move || {
261+ let result = (|| -> Result<Vec<crate::config::episode::Episode>, String> {
262+ let content =
263+ crate::rss::parser::fetch_feed(&feed.url).map_err(|e| e.to_string())?;
264+ let channel = crate::rss::parser::parse_rss_content(&content)
265+ .map_err(|e| e.to_string())?;
266+ crate::rss::episode_extractor::extract_episodes(&channel).map_err(|e| e.to_string())
267+ })();
268+
269+ match result {
270+ Ok(episodes) => {
271+ let _ = sender.send(SyncMessage::EpisodesLoaded {
272+ feed_id,
273+ episodes,
274+ });
275+ }
276+ Err(error) => {
277+ let _ = sender.send(SyncMessage::EpisodesLoadFailed { feed_id, error });
278+ }
279+ }
280+ });
281+ }
282+
283+ /// Downloads a single episode asynchronously (detail view)
284+ pub fn start_download_episode(
285+ &mut self,
286+ feed: crate::config::feed::Feed,
287+ episode: crate::config::episode::Episode,
288+ ) {
289+ let sender = self.sender.clone();
290+ let feed_id = feed.id.clone();
291+
292+ thread::spawn(move || {
293+ match crate::rss::downloader::download_episode(
294+ &episode,
295+ &feed.download_path,
296+ Box::new(|_| {}),
297+ ) {
298+ Ok(path) => {
299+ let size = std::fs::metadata(&path).map(|m| m.len()).ok();
300+ let _ = sender.send(SyncMessage::EpisodeDownloadCompleted {
301+ feed_id,
302+ episode_id: episode.id.clone(),
303+ episode_title: episode.title.clone(),
304+ size,
305+ });
306+ }
307+ Err(e) => {
308+ let _ = sender.send(SyncMessage::EpisodeDownloadFailed {
309+ feed_id,
310+ episode_title: episode.title.clone(),
311+ error: e.to_string(),
312+ });
313+ }
314+ }
315+ });
316+ }
317+
236 /// Clears completed and failed sync statuses318 /// Clears completed and failed sync statuses
237 pub fn clear_completed_sync_status(&mut self) {319 pub fn clear_completed_sync_status(&mut self) {
238 self.status.feed_sync_status.retain(|_, status| {320 self.status.feed_sync_status.retain(|_, status| {
@@ -264,7 +346,7 @@ mod tests {
264 fn test_sync_status_start_global_sync() {346 fn test_sync_status_start_global_sync() {
265 let mut status = SyncStatus::new();347 let mut status = SyncStatus::new();
266 status.start_global_sync(5);348 status.start_global_sync(5);
267- 349+
268 assert!(status.global_sync_in_progress);350 assert!(status.global_sync_in_progress);
269 assert_eq!(status.total_feeds, 5);351 assert_eq!(status.total_feeds, 5);
270 assert_eq!(status.completed_feeds, 0);352 assert_eq!(status.completed_feeds, 0);
@@ -275,17 +357,22 @@ mod tests {
275 fn test_sync_status_overall_progress() {357 fn test_sync_status_overall_progress() {
276 let mut status = SyncStatus::new();358 let mut status = SyncStatus::new();
277 status.start_global_sync(3);359 status.start_global_sync(3);
278- 360+
279 // Add completed feeds361 // Add completed feeds
280 status.update_feed_status("feed-1".to_string(), FeedSyncStatus::Completed);362 status.update_feed_status("feed-1".to_string(), FeedSyncStatus::Completed);
281 status.update_feed_status("feed-2".to_string(), FeedSyncStatus::Completed);363 status.update_feed_status("feed-2".to_string(), FeedSyncStatus::Completed);
282- 364+
283 assert_eq!(status.completed_feeds, 2);365 assert_eq!(status.completed_feeds, 2);
284 assert!((status.get_overall_progress() - 0.666).abs() < 0.01);366 assert!((status.get_overall_progress() - 0.666).abs() < 0.01);
285- 367+
286 // Add failed feed368 // Add failed feed
287- status.update_feed_status("feed-3".to_string(), FeedSyncStatus::Failed { error: "Error".to_string() });369+ status.update_feed_status(
288- 370+ "feed-3".to_string(),
371+ FeedSyncStatus::Failed {
372+ error: "Error".to_string(),
373+ },
374+ );
375+
289 assert_eq!(status.completed_feeds, 3);376 assert_eq!(status.completed_feeds, 3);
290 assert_eq!(status.get_overall_progress(), 1.0);377 assert_eq!(status.get_overall_progress(), 1.0);
291 }378 }
modified src/rss/async_sync.rs +47 -2
@@ -93,8 +93,8 @@ pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) {
9393 info!("Successfully downloaded episode: {}", episode.title);
9494 downloaded_count += 1;
9595
96- // Update feed download statistics
97- feed_mut.downloaded_episodes_count += 1;
96+ // Track the most recent download (the counter itself is
97+ // recomputed from the files on disk below)
9898 feed_mut.last_downloaded_episode_name = Some(episode.title.clone());
9999 feed_mut.last_downloaded_episode_date = Some(Utc::now());
100100 }
@@ -109,6 +109,14 @@ pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) {
109109 }
110110 }
111111
112+ // Recompute the downloaded episode count from the files actually
113+ // present on disk: the displayed counter must reflect the current
114+ // state, not a cumulative total that drifts over time.
115+ feed_mut.downloaded_episodes_count = count_downloaded_episodes(
116+ &episodes,
117+ std::path::Path::new(&feed_mut.download_path),
118+ );
119+
112120 // Update feed metadata
113121 feed_mut.last_checked = Some(Utc::now());
114122 if let Some(latest) = episodes.iter().max_by_key(|e| e.pub_date) {
@@ -173,6 +181,14 @@ fn is_episode_downloaded(feed: &Feed, episode: &Episode) -> bool {
173181 !crate::rss::downloader::needs_download(episode, std::path::Path::new(&feed.download_path))
174182 }
175183
184+/// Counts the episodes of a feed that are actually present on disk
185+fn count_downloaded_episodes(episodes: &[Episode], download_path: &std::path::Path) -> usize {
186+ episodes
187+ .iter()
188+ .filter(|e| !crate::rss::downloader::needs_download(e, download_path))
189+ .count()
190+}
191+
176192 #[cfg(test)]
177193 mod tests {
178194 use super::*;
@@ -206,4 +222,33 @@ mod tests {
206222 feed = Feed::new("https://example.com/rss", "Test", "");
207223 assert!(validate_feed(&feed).is_err());
208224 }
225+
226+ #[test]
227+ fn test_count_downloaded_episodes() {
228+ let temp_dir = tempfile::tempdir().unwrap();
229+ let download_path = temp_dir.path();
230+
231+ let episodes: Vec<Episode> = (0..3)
232+ .map(|i| {
233+ Episode::new(
234+ format!("ep-{}", i),
235+ format!("Episode {}", i),
236+ format!("https://example.com/episode{}.mp3", i),
237+ Utc::now(),
238+ )
239+ })
240+ .collect();
241+
242+ // Nothing on disk yet
243+ assert_eq!(count_downloaded_episodes(&episodes, download_path), 0);
244+
245+ // Two files present: counted; the third is not
246+ std::fs::write(download_path.join(&episodes[0].audio_filename), "a").unwrap();
247+ std::fs::write(download_path.join(&episodes[1].audio_filename), "b").unwrap();
248+ assert_eq!(count_downloaded_episodes(&episodes, download_path), 2);
249+
250+ // An empty (incomplete) file must not be counted
251+ std::fs::write(download_path.join(&episodes[2].audio_filename), "").unwrap();
252+ assert_eq!(count_downloaded_episodes(&episodes, download_path), 2);
253+ }
209254 }
@@ -93,8 +93,8 @@ pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) {
93 info!("Successfully downloaded episode: {}", episode.title);93 info!("Successfully downloaded episode: {}", episode.title);
94 downloaded_count += 1;94 downloaded_count += 1;
95 95
96- // Update feed download statistics96+ // Track the most recent download (the counter itself is
97- feed_mut.downloaded_episodes_count += 1;97+ // recomputed from the files on disk below)
98 feed_mut.last_downloaded_episode_name = Some(episode.title.clone());98 feed_mut.last_downloaded_episode_name = Some(episode.title.clone());
99 feed_mut.last_downloaded_episode_date = Some(Utc::now());99 feed_mut.last_downloaded_episode_date = Some(Utc::now());
100 }100 }
@@ -109,6 +109,14 @@ pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) {
109 }109 }
110 }110 }
111 111
112+ // Recompute the downloaded episode count from the files actually
113+ // present on disk: the displayed counter must reflect the current
114+ // state, not a cumulative total that drifts over time.
115+ feed_mut.downloaded_episodes_count = count_downloaded_episodes(
116+ &episodes,
117+ std::path::Path::new(&feed_mut.download_path),
118+ );
119+
112 // Update feed metadata120 // Update feed metadata
113 feed_mut.last_checked = Some(Utc::now());121 feed_mut.last_checked = Some(Utc::now());
114 if let Some(latest) = episodes.iter().max_by_key(|e| e.pub_date) {122 if let Some(latest) = episodes.iter().max_by_key(|e| e.pub_date) {
@@ -173,6 +181,14 @@ fn is_episode_downloaded(feed: &Feed, episode: &Episode) -> bool {
173 !crate::rss::downloader::needs_download(episode, std::path::Path::new(&feed.download_path))181 !crate::rss::downloader::needs_download(episode, std::path::Path::new(&feed.download_path))
174 }182 }
175 183
184+/// Counts the episodes of a feed that are actually present on disk
185+fn count_downloaded_episodes(episodes: &[Episode], download_path: &std::path::Path) -> usize {
186+ episodes
187+ .iter()
188+ .filter(|e| !crate::rss::downloader::needs_download(e, download_path))
189+ .count()
190+}
191+
176 #[cfg(test)]192 #[cfg(test)]
177 mod tests {193 mod tests {
178 use super::*;194 use super::*;
@@ -206,4 +222,33 @@ mod tests {
206 feed = Feed::new("https://example.com/rss", "Test", "");222 feed = Feed::new("https://example.com/rss", "Test", "");
207 assert!(validate_feed(&feed).is_err());223 assert!(validate_feed(&feed).is_err());
208 }224 }
225+
226+ #[test]
227+ fn test_count_downloaded_episodes() {
228+ let temp_dir = tempfile::tempdir().unwrap();
229+ let download_path = temp_dir.path();
230+
231+ let episodes: Vec<Episode> = (0..3)
232+ .map(|i| {
233+ Episode::new(
234+ format!("ep-{}", i),
235+ format!("Episode {}", i),
236+ format!("https://example.com/episode{}.mp3", i),
237+ Utc::now(),
238+ )
239+ })
240+ .collect();
241+
242+ // Nothing on disk yet
243+ assert_eq!(count_downloaded_episodes(&episodes, download_path), 0);
244+
245+ // Two files present: counted; the third is not
246+ std::fs::write(download_path.join(&episodes[0].audio_filename), "a").unwrap();
247+ std::fs::write(download_path.join(&episodes[1].audio_filename), "b").unwrap();
248+ assert_eq!(count_downloaded_episodes(&episodes, download_path), 2);
249+
250+ // An empty (incomplete) file must not be counted
251+ std::fs::write(download_path.join(&episodes[2].audio_filename), "").unwrap();
252+ assert_eq!(count_downloaded_episodes(&episodes, download_path), 2);
253+ }
209 }254 }
modified src/rss/downloader.rs +6 -6
@@ -207,17 +207,17 @@ pub fn download_episode(
207207
208208 download_file(&episode.audio_url, &save_path, Some(progress_callback))?;
209209
210- // Ensure ID3 tags are present after downloading
211- ensure_id3_tags(
210+ // Ensure ID3 tags are present after downloading. A tag failure must not
211+ // fail the whole download: the audio file is already complete on disk
212+ // and would otherwise never be counted as downloaded.
213+ if let Err(e) = ensure_id3_tags(
212214 &save_path,
213215 &episode.title,
214216 episode.artist.as_deref(),
215217 episode.album.as_deref(),
216- ).map_err(|e| {
217- // Log the error but don't fail the download
218+ ) {
218219 warn!("Failed to write ID3 tags for {}: {}", save_path.display(), e);
219- DownloadError::NetworkError(format!("ID3 tag writing failed: {}", e))
220- })?;
220+ }
221221
222222 Ok(save_path)
223223 }
@@ -207,17 +207,17 @@ pub fn download_episode(
207 207
208 download_file(&episode.audio_url, &save_path, Some(progress_callback))?;208 download_file(&episode.audio_url, &save_path, Some(progress_callback))?;
209 209
210- // Ensure ID3 tags are present after downloading210+ // Ensure ID3 tags are present after downloading. A tag failure must not
211- ensure_id3_tags(211+ // fail the whole download: the audio file is already complete on disk
212+ // and would otherwise never be counted as downloaded.
213+ if let Err(e) = ensure_id3_tags(
212 &save_path,214 &save_path,
213 &episode.title,215 &episode.title,
214 episode.artist.as_deref(),216 episode.artist.as_deref(),
215 episode.album.as_deref(),217 episode.album.as_deref(),
216- ).map_err(|e| {218+ ) {
217- // Log the error but don't fail the download
218 warn!("Failed to write ID3 tags for {}: {}", save_path.display(), e);219 warn!("Failed to write ID3 tags for {}: {}", save_path.display(), e);
219- DownloadError::NetworkError(format!("ID3 tag writing failed: {}", e))220+ }
220- })?;
221 221
222 Ok(save_path)222 Ok(save_path)
223 }223 }
modified src/ui/add_feed_dialog.rs +86 -45
@@ -6,55 +6,96 @@ use crate::config::{AppState, Feed};
66 use egui::Ui;
77 use std::path::PathBuf;
88
9-/// Draws the add feed dialog
9+/// Draws the add feed dialog (modal)
1010 pub fn draw_add_feed_dialog(state: &mut AppState, ui: &mut Ui) {
1111 if !state.show_add_feed_dialog {
1212 return;
1313 }
1414
15- egui::Window::new("Ajouter un flux RSS")
16- .resizable(false)
17- .show(ui.ctx(), |ui| {
18- ui.vertical(|ui| {
19- ui.label("Nouveau flux");
20- ui.separator();
21-
22- ui.label("Nom du podcast");
23- ui.text_edit_singleline(&mut state.new_feed_name);
24-
25- ui.label("URL du flux RSS");
26- ui.text_edit_singleline(&mut state.new_feed_url);
27-
28- ui.label("Dossier de téléchargement");
29- ui.text_edit_singleline(&mut state.new_feed_download_path);
30-
31- ui.separator();
32-
33- // Validation
34- let valid = !state.new_feed_url.is_empty() && !state.new_feed_download_path.is_empty();
35-
36- ui.horizontal(|ui| {
37- if valid && ui.button("Ajouter").clicked() {
38- log::info!("Adding new feed: {} - {}", state.new_feed_name, state.new_feed_url);
39- let feed = Feed::new(
40- state.new_feed_url.clone(),
41- state.new_feed_name.clone(),
42- PathBuf::from(state.new_feed_download_path.clone())
43- );
44- state.add_feed(feed);
45- super::super::config::storage::save_feeds_default(&state.feeds).ok();
46- log::info!("Feed added successfully");
47- state.clear_new_feed_form();
48- state.show_add_feed_dialog = false;
49- } else {
50- ui.label("Veuillez remplir tous les champs");
51- }
52-
53- if ui.button("Annuler").clicked() {
54- state.clear_new_feed_form();
55- state.show_add_feed_dialog = false;
56- }
57- });
58- });
15+ let ctx = ui.ctx().clone();
16+ let modal = egui::Modal::new(egui::Id::new("add_feed_dialog"));
17+ let response = modal.show(&ctx, |ui| {
18+ ui.set_min_width(420.0);
19+ ui.heading("Ajouter un flux RSS");
20+ ui.separator();
21+
22+ ui.label("Nom du podcast");
23+ let name_response = ui.text_edit_singleline(&mut state.new_feed_name);
24+
25+ ui.label("URL du flux RSS");
26+ let url_response = ui.text_edit_singleline(&mut state.new_feed_url);
27+
28+ ui.label("Dossier de téléchargement");
29+ let download_width = (ui.available_width() - 110.0).max(180.0);
30+ ui.horizontal(|ui| {
31+ ui.add(
32+ egui::TextEdit::singleline(&mut state.new_feed_download_path)
33+ .desired_width(download_width)
34+ .hint_text("ex: C:\\Users\\moi\\Musique\\podcasts"),
35+ );
36+ if ui.button("Parcourir…").clicked() {
37+ if let Some(path) = rfd::FileDialog::new()
38+ .set_title("Choisir le dossier de téléchargement")
39+ .pick_folder()
40+ {
41+ state.new_feed_download_path = path.to_string_lossy().to_string();
42+ }
43+ }
44+ });
45+
46+ ui.separator();
47+
48+ // Validation : URL et dossier obligatoires
49+ let valid = !state.new_feed_url.trim().is_empty()
50+ && !state.new_feed_download_path.trim().is_empty();
51+
52+ let mut submitted = false;
53+ ui.horizontal(|ui| {
54+ if ui.add_enabled(valid, egui::Button::new("Ajouter")).clicked() {
55+ submitted = true;
56+ }
57+ if ui.button("Annuler").clicked() {
58+ state.clear_new_feed_form();
59+ state.show_add_feed_dialog = false;
60+ }
61+ });
62+ if !valid {
63+ ui.label(
64+ egui::RichText::new("L'URL du flux et le dossier de téléchargement sont requis.")
65+ .weak()
66+ .small(),
67+ );
68+ }
69+
70+ // Entrée pour valider depuis le champ nom ou URL
71+ let enter_pressed = ui.input(|i| {
72+ i.key_pressed(egui::Key::Enter) && (name_response.lost_focus() || url_response.lost_focus())
5973 });
74+ if enter_pressed && valid {
75+ submitted = true;
76+ }
77+
78+ submitted
79+ });
80+
81+ if response.inner {
82+ log::info!(
83+ "Adding new feed: {} - {}",
84+ state.new_feed_name,
85+ state.new_feed_url
86+ );
87+ let feed = Feed::new(
88+ state.new_feed_url.trim().to_string(),
89+ state.new_feed_name.trim().to_string(),
90+ PathBuf::from(state.new_feed_download_path.trim()),
91+ );
92+ state.add_feed(feed);
93+ super::super::config::storage::save_feeds_default(&state.feeds).ok();
94+ log::info!("Feed added successfully");
95+ state.clear_new_feed_form();
96+ state.show_add_feed_dialog = false;
97+ } else if response.should_close() {
98+ state.clear_new_feed_form();
99+ state.show_add_feed_dialog = false;
100+ }
60101 }
@@ -6,55 +6,96 @@ use crate::config::{AppState, Feed};
6 use egui::Ui;6 use egui::Ui;
7 use std::path::PathBuf;7 use std::path::PathBuf;
8 8
9-/// Draws the add feed dialog9+/// Draws the add feed dialog (modal)
10 pub fn draw_add_feed_dialog(state: &mut AppState, ui: &mut Ui) {10 pub fn draw_add_feed_dialog(state: &mut AppState, ui: &mut Ui) {
11 if !state.show_add_feed_dialog {11 if !state.show_add_feed_dialog {
12 return;12 return;
13 }13 }
14 14
15- egui::Window::new("Ajouter un flux RSS")15+ let ctx = ui.ctx().clone();
16- .resizable(false)16+ let modal = egui::Modal::new(egui::Id::new("add_feed_dialog"));
17- .show(ui.ctx(), |ui| {17+ let response = modal.show(&ctx, |ui| {
18- ui.vertical(|ui| {18+ ui.set_min_width(420.0);
19- ui.label("Nouveau flux");19+ ui.heading("Ajouter un flux RSS");
20- ui.separator();20+ ui.separator();
21-21+
22- ui.label("Nom du podcast");22+ ui.label("Nom du podcast");
23- ui.text_edit_singleline(&mut state.new_feed_name);23+ let name_response = ui.text_edit_singleline(&mut state.new_feed_name);
24-24+
25- ui.label("URL du flux RSS");25+ ui.label("URL du flux RSS");
26- ui.text_edit_singleline(&mut state.new_feed_url);26+ let url_response = ui.text_edit_singleline(&mut state.new_feed_url);
27-27+
28- ui.label("Dossier de téléchargement");28+ ui.label("Dossier de téléchargement");
29- ui.text_edit_singleline(&mut state.new_feed_download_path);29+ let download_width = (ui.available_width() - 110.0).max(180.0);
30-30+ ui.horizontal(|ui| {
31- ui.separator();31+ ui.add(
32-32+ egui::TextEdit::singleline(&mut state.new_feed_download_path)
33- // Validation33+ .desired_width(download_width)
34- let valid = !state.new_feed_url.is_empty() && !state.new_feed_download_path.is_empty();34+ .hint_text("ex: C:\\Users\\moi\\Musique\\podcasts"),
35-35+ );
36- ui.horizontal(|ui| {36+ if ui.button("Parcourir…").clicked() {
37- if valid && ui.button("Ajouter").clicked() {37+ if let Some(path) = rfd::FileDialog::new()
38- log::info!("Adding new feed: {} - {}", state.new_feed_name, state.new_feed_url);38+ .set_title("Choisir le dossier de téléchargement")
39- let feed = Feed::new(39+ .pick_folder()
40- state.new_feed_url.clone(),40+ {
41- state.new_feed_name.clone(),41+ state.new_feed_download_path = path.to_string_lossy().to_string();
42- PathBuf::from(state.new_feed_download_path.clone())42+ }
43- );43+ }
44- state.add_feed(feed);44+ });
45- super::super::config::storage::save_feeds_default(&state.feeds).ok();45+
46- log::info!("Feed added successfully");46+ ui.separator();
47- state.clear_new_feed_form();47+
48- state.show_add_feed_dialog = false;48+ // Validation : URL et dossier obligatoires
49- } else {49+ let valid = !state.new_feed_url.trim().is_empty()
50- ui.label("Veuillez remplir tous les champs");50+ && !state.new_feed_download_path.trim().is_empty();
51- }51+
52-52+ let mut submitted = false;
53- if ui.button("Annuler").clicked() {53+ ui.horizontal(|ui| {
54- state.clear_new_feed_form();54+ if ui.add_enabled(valid, egui::Button::new("Ajouter")).clicked() {
55- state.show_add_feed_dialog = false;55+ submitted = true;
56- }56+ }
57- });57+ if ui.button("Annuler").clicked() {
58- });58+ state.clear_new_feed_form();
59+ state.show_add_feed_dialog = false;
60+ }
61+ });
62+ if !valid {
63+ ui.label(
64+ egui::RichText::new("L'URL du flux et le dossier de téléchargement sont requis.")
65+ .weak()
66+ .small(),
67+ );
68+ }
69+
70+ // Entrée pour valider depuis le champ nom ou URL
71+ let enter_pressed = ui.input(|i| {
72+ i.key_pressed(egui::Key::Enter) && (name_response.lost_focus() || url_response.lost_focus())
59 });73 });
74+ if enter_pressed && valid {
75+ submitted = true;
76+ }
77+
78+ submitted
79+ });
80+
81+ if response.inner {
82+ log::info!(
83+ "Adding new feed: {} - {}",
84+ state.new_feed_name,
85+ state.new_feed_url
86+ );
87+ let feed = Feed::new(
88+ state.new_feed_url.trim().to_string(),
89+ state.new_feed_name.trim().to_string(),
90+ PathBuf::from(state.new_feed_download_path.trim()),
91+ );
92+ state.add_feed(feed);
93+ super::super::config::storage::save_feeds_default(&state.feeds).ok();
94+ log::info!("Feed added successfully");
95+ state.clear_new_feed_form();
96+ state.show_add_feed_dialog = false;
97+ } else if response.should_close() {
98+ state.clear_new_feed_form();
99+ state.show_add_feed_dialog = false;
100+ }
60 }101 }
added src/ui/confirm_dialog.rs +135 -0
new file mode 100644
@@ -0,0 +1,135 @@
1+//! Dialogues de confirmation pour les actions destructrices
2+//!
3+//! Affiche des fenêtres modales avant toute suppression de flux ou de playlist.
4+
5+use crate::config::AppState;
6+use egui::{Context, Ui};
7+
8+/// Action choisie par l'utilisateur dans un dialogue de confirmation
9+#[derive(Debug, Clone, Copy, PartialEq)]
10+enum ConfirmAction {
11+ None,
12+ Confirm,
13+ Cancel,
14+}
15+
16+/// Affiche les dialogues de confirmation en attente
17+pub fn draw_confirmations(state: &mut AppState, ctx: &Context) {
18+ draw_delete_feed_confirmation(state, ctx);
19+ draw_delete_playlist_confirmation(state, ctx);
20+}
21+
22+/// Confirmation avant suppression d'un flux
23+fn draw_delete_feed_confirmation(state: &mut AppState, ctx: &Context) {
24+ let feed_id = match state.confirm_delete_feed_id.clone() {
25+ Some(id) => id,
26+ None => return,
27+ };
28+
29+ // Le flux a pu être supprimé entre-temps : abandonner la confirmation
30+ let Some(feed_name) = state.feeds.iter().find(|f| f.id == feed_id).map(|f| f.name.clone()) else {
31+ state.confirm_delete_feed_id = None;
32+ return;
33+ };
34+
35+ let modal = egui::Modal::new(egui::Id::new("confirm_delete_feed"));
36+ let response = modal.show(ctx, |ui| {
37+ draw_confirm_content(
38+ ui,
39+ "Supprimer le flux",
40+ &format!("Voulez-vous vraiment supprimer le flux \"{}\" ?", feed_name),
41+ "Supprimer",
42+ )
43+ });
44+
45+ match response.inner {
46+ ConfirmAction::Confirm => {
47+ state.confirm_delete_feed_id = None;
48+ if let Some(feed) = state.delete_feed_by_id(&feed_id) {
49+ super::super::config::storage::save_feeds_default(&state.feeds).ok();
50+ state.record_history(
51+ crate::config::history::OperationType::RemoveFeed,
52+ format!("Flux supprimé : {}", feed.name),
53+ );
54+ state.notification_manager.success("Flux supprimé");
55+ }
56+ }
57+ ConfirmAction::Cancel => {
58+ state.confirm_delete_feed_id = None;
59+ }
60+ ConfirmAction::None => {
61+ if response.should_close() {
62+ state.confirm_delete_feed_id = None;
63+ }
64+ }
65+ }
66+}
67+
68+/// Confirmation avant suppression d'une playlist
69+fn draw_delete_playlist_confirmation(state: &mut AppState, ctx: &Context) {
70+ let playlist_id = match state.confirm_delete_playlist_id.clone() {
71+ Some(id) => id,
72+ None => return,
73+ };
74+
75+ let Some(playlist_name) = state
76+ .playlists
77+ .iter()
78+ .find(|p| p.id == playlist_id)
79+ .map(|p| p.name.clone())
80+ else {
81+ state.confirm_delete_playlist_id = None;
82+ return;
83+ };
84+
85+ let modal = egui::Modal::new(egui::Id::new("confirm_delete_playlist"));
86+ let response = modal.show(ctx, |ui| {
87+ draw_confirm_content(
88+ ui,
89+ "Supprimer la playlist",
90+ &format!(
91+ "Voulez-vous vraiment supprimer la playlist \"{}\" ?",
92+ playlist_name
93+ ),
94+ "Supprimer",
95+ )
96+ });
97+
98+ match response.inner {
99+ ConfirmAction::Confirm => {
100+ state.confirm_delete_playlist_id = None;
101+ if let Some(playlist) = state.remove_playlist_by_id(&playlist_id) {
102+ super::super::config::storage::save_playlists_default(&state.playlists).ok();
103+ log::info!("Deleted playlist: {}", playlist.name);
104+ }
105+ }
106+ ConfirmAction::Cancel => {
107+ state.confirm_delete_playlist_id = None;
108+ }
109+ ConfirmAction::None => {
110+ if response.should_close() {
111+ state.confirm_delete_playlist_id = None;
112+ }
113+ }
114+ }
115+}
116+
117+/// Contenu commun d'un dialogue de confirmation
118+fn draw_confirm_content(ui: &mut Ui, title: &str, message: &str, confirm_label: &str) -> ConfirmAction {
119+ ui.set_min_width(340.0);
120+ ui.heading(title);
121+ ui.add_space(8.0);
122+ ui.label(message);
123+ ui.add_space(12.0);
124+
125+ let mut action = ConfirmAction::None;
126+ ui.horizontal(|ui| {
127+ if ui.button(confirm_label).clicked() {
128+ action = ConfirmAction::Confirm;
129+ }
130+ if ui.button("Annuler").clicked() {
131+ action = ConfirmAction::Cancel;
132+ }
133+ });
134+ action
135+}
new file mode 100644
@@ -0,0 +1,135 @@
1+//! Dialogues de confirmation pour les actions destructrices
2+//!
3+//! Affiche des fenêtres modales avant toute suppression de flux ou de playlist.
4+
5+use crate::config::AppState;
6+use egui::{Context, Ui};
7+
8+/// Action choisie par l'utilisateur dans un dialogue de confirmation
9+#[derive(Debug, Clone, Copy, PartialEq)]
10+enum ConfirmAction {
11+ None,
12+ Confirm,
13+ Cancel,
14+}
15+
16+/// Affiche les dialogues de confirmation en attente
17+pub fn draw_confirmations(state: &mut AppState, ctx: &Context) {
18+ draw_delete_feed_confirmation(state, ctx);
19+ draw_delete_playlist_confirmation(state, ctx);
20+}
21+
22+/// Confirmation avant suppression d'un flux
23+fn draw_delete_feed_confirmation(state: &mut AppState, ctx: &Context) {
24+ let feed_id = match state.confirm_delete_feed_id.clone() {
25+ Some(id) => id,
26+ None => return,
27+ };
28+
29+ // Le flux a pu être supprimé entre-temps : abandonner la confirmation
30+ let Some(feed_name) = state.feeds.iter().find(|f| f.id == feed_id).map(|f| f.name.clone()) else {
31+ state.confirm_delete_feed_id = None;
32+ return;
33+ };
34+
35+ let modal = egui::Modal::new(egui::Id::new("confirm_delete_feed"));
36+ let response = modal.show(ctx, |ui| {
37+ draw_confirm_content(
38+ ui,
39+ "Supprimer le flux",
40+ &format!("Voulez-vous vraiment supprimer le flux \"{}\" ?", feed_name),
41+ "Supprimer",
42+ )
43+ });
44+
45+ match response.inner {
46+ ConfirmAction::Confirm => {
47+ state.confirm_delete_feed_id = None;
48+ if let Some(feed) = state.delete_feed_by_id(&feed_id) {
49+ super::super::config::storage::save_feeds_default(&state.feeds).ok();
50+ state.record_history(
51+ crate::config::history::OperationType::RemoveFeed,
52+ format!("Flux supprimé : {}", feed.name),
53+ );
54+ state.notification_manager.success("Flux supprimé");
55+ }
56+ }
57+ ConfirmAction::Cancel => {
58+ state.confirm_delete_feed_id = None;
59+ }
60+ ConfirmAction::None => {
61+ if response.should_close() {
62+ state.confirm_delete_feed_id = None;
63+ }
64+ }
65+ }
66+}
67+
68+/// Confirmation avant suppression d'une playlist
69+fn draw_delete_playlist_confirmation(state: &mut AppState, ctx: &Context) {
70+ let playlist_id = match state.confirm_delete_playlist_id.clone() {
71+ Some(id) => id,
72+ None => return,
73+ };
74+
75+ let Some(playlist_name) = state
76+ .playlists
77+ .iter()
78+ .find(|p| p.id == playlist_id)
79+ .map(|p| p.name.clone())
80+ else {
81+ state.confirm_delete_playlist_id = None;
82+ return;
83+ };
84+
85+ let modal = egui::Modal::new(egui::Id::new("confirm_delete_playlist"));
86+ let response = modal.show(ctx, |ui| {
87+ draw_confirm_content(
88+ ui,
89+ "Supprimer la playlist",
90+ &format!(
91+ "Voulez-vous vraiment supprimer la playlist \"{}\" ?",
92+ playlist_name
93+ ),
94+ "Supprimer",
95+ )
96+ });
97+
98+ match response.inner {
99+ ConfirmAction::Confirm => {
100+ state.confirm_delete_playlist_id = None;
101+ if let Some(playlist) = state.remove_playlist_by_id(&playlist_id) {
102+ super::super::config::storage::save_playlists_default(&state.playlists).ok();
103+ log::info!("Deleted playlist: {}", playlist.name);
104+ }
105+ }
106+ ConfirmAction::Cancel => {
107+ state.confirm_delete_playlist_id = None;
108+ }
109+ ConfirmAction::None => {
110+ if response.should_close() {
111+ state.confirm_delete_playlist_id = None;
112+ }
113+ }
114+ }
115+}
116+
117+/// Contenu commun d'un dialogue de confirmation
118+fn draw_confirm_content(ui: &mut Ui, title: &str, message: &str, confirm_label: &str) -> ConfirmAction {
119+ ui.set_min_width(340.0);
120+ ui.heading(title);
121+ ui.add_space(8.0);
122+ ui.label(message);
123+ ui.add_space(12.0);
124+
125+ let mut action = ConfirmAction::None;
126+ ui.horizontal(|ui| {
127+ if ui.button(confirm_label).clicked() {
128+ action = ConfirmAction::Confirm;
129+ }
130+ if ui.button("Annuler").clicked() {
131+ action = ConfirmAction::Cancel;
132+ }
133+ });
134+ action
135+}
modified src/ui/export_import.rs +102 -68
@@ -6,45 +6,54 @@ use crate::config::AppState;
66 use egui::{Color32, Ui};
77 use std::path::PathBuf;
88
9-/// Draws the export dialog
9+/// Draws the export dialog (modal)
1010 pub fn draw_export_dialog(state: &mut AppState, ui: &mut Ui) {
1111 if !state.show_export_dialog {
1212 return;
1313 }
1414
15- egui::Window::new("Exporter les flux")
16- .resizable(false)
17- .show(ui.ctx(), |ui| {
18- ui.vertical(|ui| {
19- ui.label("Exporter les flux vers un fichier JSON");
20- ui.label(format!("Nombre de flux à exporter: {}", state.feeds.len()));
21- ui.separator();
22-
23- ui.horizontal(|ui| {
24- if ui.button("Choisir un fichier").clicked() {
25- if let Some(path) = rfd::FileDialog::new()
26- .add_filter("JSON Files", &["json"])
27- .add_filter("All Files", &["*"])
28- .set_title("Exporter les flux")
29- .save_file()
30- {
31- export_feeds_to_path(state, path);
32- }
33- }
34-
35- if ui.button("Annuler").clicked() {
36- state.show_export_dialog = false;
37- state.clear_export_import_error();
38- }
39- });
40-
41- // Show error if any
42- if let Some(ref error) = state.export_import_error {
43- ui.separator();
44- ui.colored_label(Color32::RED, error);
15+ let ctx = ui.ctx().clone();
16+ let modal = egui::Modal::new(egui::Id::new("export_dialog"));
17+ let response = modal.show(&ctx, |ui| {
18+ ui.set_min_width(360.0);
19+ ui.heading("Exporter les flux");
20+ ui.separator();
21+ ui.label("Exporter les flux vers un fichier JSON");
22+ ui.label(format!("Nombre de flux à exporter : {}", state.feeds.len()));
23+ ui.separator();
24+
25+ let mut close = false;
26+ ui.horizontal(|ui| {
27+ if ui.button("Choisir un fichier").clicked() {
28+ if let Some(path) = rfd::FileDialog::new()
29+ .add_filter("JSON Files", &["json"])
30+ .add_filter("All Files", &["*"])
31+ .set_title("Exporter les flux")
32+ .save_file()
33+ {
34+ export_feeds_to_path(state, path);
35+ close = true;
4536 }
46- });
37+ }
38+
39+ if ui.button("Annuler").clicked() {
40+ close = true;
41+ }
4742 });
43+
44+ // Show error if any
45+ if let Some(ref error) = state.export_import_error {
46+ ui.separator();
47+ ui.colored_label(Color32::RED, error);
48+ }
49+
50+ close
51+ });
52+
53+ if response.inner || response.should_close() {
54+ state.show_export_dialog = false;
55+ state.clear_export_import_error();
56+ }
4857 }
4958
5059 /// Exports feeds to the specified path
@@ -52,7 +61,13 @@ fn export_feeds_to_path(state: &mut AppState, path: PathBuf) {
5261 match crate::config::storage::export_feeds_pretty(&state.feeds, &path) {
5362 Ok(_) => {
5463 log::info!("Successfully exported feeds to {}", path.display());
55- state.notification_manager.success(format!("Export réussi: {}", path.display()));
64+ state.record_history(
65+ crate::config::history::OperationType::ExportFeeds,
66+ format!("Export de {} flux vers {}", state.feeds.len(), path.display()),
67+ );
68+ state
69+ .notification_manager
70+ .success(format!("Export réussi: {}", path.display()));
5671 state.show_export_dialog = false;
5772 state.clear_export_import_error();
5873 }
@@ -63,44 +78,53 @@ fn export_feeds_to_path(state: &mut AppState, path: PathBuf) {
6378 }
6479 }
6580
66-/// Draws the import dialog
81+/// Draws the import dialog (modal)
6782 pub fn draw_import_dialog(state: &mut AppState, ui: &mut Ui) {
6883 if !state.show_import_dialog {
6984 return;
7085 }
7186
72- egui::Window::new("Importer les flux")
73- .resizable(false)
74- .show(ui.ctx(), |ui| {
75- ui.vertical(|ui| {
76- ui.label("Importer les flux depuis un fichier JSON");
77- ui.separator();
78-
79- ui.horizontal(|ui| {
80- if ui.button("Choisir un fichier").clicked() {
81- if let Some(path) = rfd::FileDialog::new()
82- .add_filter("JSON Files", &["json"])
83- .add_filter("All Files", &["*"])
84- .set_title("Importer les flux")
85- .pick_file()
86- {
87- import_feeds_from_path(state, path);
88- }
89- }
90-
91- if ui.button("Annuler").clicked() {
92- state.show_import_dialog = false;
93- state.clear_export_import_error();
94- }
95- });
96-
97- // Show error if any
98- if let Some(ref error) = state.export_import_error {
99- ui.separator();
100- ui.colored_label(Color32::RED, error);
87+ let ctx = ui.ctx().clone();
88+ let modal = egui::Modal::new(egui::Id::new("import_dialog"));
89+ let response = modal.show(&ctx, |ui| {
90+ ui.set_min_width(360.0);
91+ ui.heading("Importer des flux");
92+ ui.separator();
93+ ui.label("Importer les flux depuis un fichier JSON");
94+ ui.separator();
95+
96+ let mut close = false;
97+ ui.horizontal(|ui| {
98+ if ui.button("Choisir un fichier").clicked() {
99+ if let Some(path) = rfd::FileDialog::new()
100+ .add_filter("JSON Files", &["json"])
101+ .add_filter("All Files", &["*"])
102+ .set_title("Importer les flux")
103+ .pick_file()
104+ {
105+ import_feeds_from_path(state, path);
106+ close = true;
101107 }
102- });
108+ }
109+
110+ if ui.button("Annuler").clicked() {
111+ close = true;
112+ }
103113 });
114+
115+ // Show error if any
116+ if let Some(ref error) = state.export_import_error {
117+ ui.separator();
118+ ui.colored_label(Color32::RED, error);
119+ }
120+
121+ close
122+ });
123+
124+ if response.inner || response.should_close() {
125+ state.show_import_dialog = false;
126+ state.clear_export_import_error();
127+ }
104128 }
105129
106130 /// Imports feeds from the specified path
@@ -113,18 +137,28 @@ fn import_feeds_from_path(state: &mut AppState, path: PathBuf) {
113137 feed.id = uuid::Uuid::new_v4().to_string();
114138 }
115139 }
116-
140+
117141 // Append imported feeds to existing feeds
118142 let count = imported_feeds.len();
119143 state.feeds.extend(imported_feeds);
120-
144+
121145 // Save the updated feeds
122146 if let Err(e) = crate::config::storage::save_feeds_default(&state.feeds) {
123147 log::error!("Failed to save imported feeds: {}", e);
124148 state.set_export_import_error(format!("Erreur de sauvegarde: {}", e));
125149 } else {
126- log::info!("Successfully imported {} feeds from {}", count, path.display());
127- state.notification_manager.success(format!("Import réussi: {} flux ajoutés", count));
150+ log::info!(
151+ "Successfully imported {} feeds from {}",
152+ count,
153+ path.display()
154+ );
155+ state.record_history(
156+ crate::config::history::OperationType::ImportFeeds,
157+ format!("Import de {} flux depuis {}", count, path.display()),
158+ );
159+ state
160+ .notification_manager
161+ .success(format!("Import réussi: {} flux ajoutés", count));
128162 state.show_import_dialog = false;
129163 state.clear_export_import_error();
130164 }
@@ -6,45 +6,54 @@ use crate::config::AppState;
6 use egui::{Color32, Ui};6 use egui::{Color32, Ui};
7 use std::path::PathBuf;7 use std::path::PathBuf;
8 8
9-/// Draws the export dialog9+/// Draws the export dialog (modal)
10 pub fn draw_export_dialog(state: &mut AppState, ui: &mut Ui) {10 pub fn draw_export_dialog(state: &mut AppState, ui: &mut Ui) {
11 if !state.show_export_dialog {11 if !state.show_export_dialog {
12 return;12 return;
13 }13 }
14 14
15- egui::Window::new("Exporter les flux")15+ let ctx = ui.ctx().clone();
16- .resizable(false)16+ let modal = egui::Modal::new(egui::Id::new("export_dialog"));
17- .show(ui.ctx(), |ui| {17+ let response = modal.show(&ctx, |ui| {
18- ui.vertical(|ui| {18+ ui.set_min_width(360.0);
19- ui.label("Exporter les flux vers un fichier JSON");19+ ui.heading("Exporter les flux");
20- ui.label(format!("Nombre de flux à exporter: {}", state.feeds.len()));20+ ui.separator();
21- ui.separator();21+ ui.label("Exporter les flux vers un fichier JSON");
22-22+ ui.label(format!("Nombre de flux à exporter : {}", state.feeds.len()));
23- ui.horizontal(|ui| {23+ ui.separator();
24- if ui.button("Choisir un fichier").clicked() {24+
25- if let Some(path) = rfd::FileDialog::new()25+ let mut close = false;
26- .add_filter("JSON Files", &["json"])26+ ui.horizontal(|ui| {
27- .add_filter("All Files", &["*"])27+ if ui.button("Choisir un fichier").clicked() {
28- .set_title("Exporter les flux")28+ if let Some(path) = rfd::FileDialog::new()
29- .save_file()29+ .add_filter("JSON Files", &["json"])
30- {30+ .add_filter("All Files", &["*"])
31- export_feeds_to_path(state, path);31+ .set_title("Exporter les flux")
32- }32+ .save_file()
33- }33+ {
34-34+ export_feeds_to_path(state, path);
35- if ui.button("Annuler").clicked() {35+ close = true;
36- state.show_export_dialog = false;
37- state.clear_export_import_error();
38- }
39- });
40-
41- // Show error if any
42- if let Some(ref error) = state.export_import_error {
43- ui.separator();
44- ui.colored_label(Color32::RED, error);
45 }36 }
46- });37+ }
38+
39+ if ui.button("Annuler").clicked() {
40+ close = true;
41+ }
47 });42 });
43+
44+ // Show error if any
45+ if let Some(ref error) = state.export_import_error {
46+ ui.separator();
47+ ui.colored_label(Color32::RED, error);
48+ }
49+
50+ close
51+ });
52+
53+ if response.inner || response.should_close() {
54+ state.show_export_dialog = false;
55+ state.clear_export_import_error();
56+ }
48 }57 }
49 58
50 /// Exports feeds to the specified path59 /// Exports feeds to the specified path
@@ -52,7 +61,13 @@ fn export_feeds_to_path(state: &mut AppState, path: PathBuf) {
52 match crate::config::storage::export_feeds_pretty(&state.feeds, &path) {61 match crate::config::storage::export_feeds_pretty(&state.feeds, &path) {
53 Ok(_) => {62 Ok(_) => {
54 log::info!("Successfully exported feeds to {}", path.display());63 log::info!("Successfully exported feeds to {}", path.display());
55- state.notification_manager.success(format!("Export réussi: {}", path.display()));64+ state.record_history(
65+ crate::config::history::OperationType::ExportFeeds,
66+ format!("Export de {} flux vers {}", state.feeds.len(), path.display()),
67+ );
68+ state
69+ .notification_manager
70+ .success(format!("Export réussi: {}", path.display()));
56 state.show_export_dialog = false;71 state.show_export_dialog = false;
57 state.clear_export_import_error();72 state.clear_export_import_error();
58 }73 }
@@ -63,44 +78,53 @@ fn export_feeds_to_path(state: &mut AppState, path: PathBuf) {
63 }78 }
64 }79 }
65 80
66-/// Draws the import dialog81+/// Draws the import dialog (modal)
67 pub fn draw_import_dialog(state: &mut AppState, ui: &mut Ui) {82 pub fn draw_import_dialog(state: &mut AppState, ui: &mut Ui) {
68 if !state.show_import_dialog {83 if !state.show_import_dialog {
69 return;84 return;
70 }85 }
71 86
72- egui::Window::new("Importer les flux")87+ let ctx = ui.ctx().clone();
73- .resizable(false)88+ let modal = egui::Modal::new(egui::Id::new("import_dialog"));
74- .show(ui.ctx(), |ui| {89+ let response = modal.show(&ctx, |ui| {
75- ui.vertical(|ui| {90+ ui.set_min_width(360.0);
76- ui.label("Importer les flux depuis un fichier JSON");91+ ui.heading("Importer des flux");
77- ui.separator();92+ ui.separator();
78-93+ ui.label("Importer les flux depuis un fichier JSON");
79- ui.horizontal(|ui| {94+ ui.separator();
80- if ui.button("Choisir un fichier").clicked() {95+
81- if let Some(path) = rfd::FileDialog::new()96+ let mut close = false;
82- .add_filter("JSON Files", &["json"])97+ ui.horizontal(|ui| {
83- .add_filter("All Files", &["*"])98+ if ui.button("Choisir un fichier").clicked() {
84- .set_title("Importer les flux")99+ if let Some(path) = rfd::FileDialog::new()
85- .pick_file()100+ .add_filter("JSON Files", &["json"])
86- {101+ .add_filter("All Files", &["*"])
87- import_feeds_from_path(state, path);102+ .set_title("Importer les flux")
88- }103+ .pick_file()
89- }104+ {
90-105+ import_feeds_from_path(state, path);
91- if ui.button("Annuler").clicked() {106+ close = true;
92- state.show_import_dialog = false;
93- state.clear_export_import_error();
94- }
95- });
96-
97- // Show error if any
98- if let Some(ref error) = state.export_import_error {
99- ui.separator();
100- ui.colored_label(Color32::RED, error);
101 }107 }
102- });108+ }
109+
110+ if ui.button("Annuler").clicked() {
111+ close = true;
112+ }
103 });113 });
114+
115+ // Show error if any
116+ if let Some(ref error) = state.export_import_error {
117+ ui.separator();
118+ ui.colored_label(Color32::RED, error);
119+ }
120+
121+ close
122+ });
123+
124+ if response.inner || response.should_close() {
125+ state.show_import_dialog = false;
126+ state.clear_export_import_error();
127+ }
104 }128 }
105 129
106 /// Imports feeds from the specified path130 /// Imports feeds from the specified path
@@ -113,18 +137,28 @@ fn import_feeds_from_path(state: &mut AppState, path: PathBuf) {
113 feed.id = uuid::Uuid::new_v4().to_string();137 feed.id = uuid::Uuid::new_v4().to_string();
114 }138 }
115 }139 }
116- 140+
117 // Append imported feeds to existing feeds141 // Append imported feeds to existing feeds
118 let count = imported_feeds.len();142 let count = imported_feeds.len();
119 state.feeds.extend(imported_feeds);143 state.feeds.extend(imported_feeds);
120- 144+
121 // Save the updated feeds145 // Save the updated feeds
122 if let Err(e) = crate::config::storage::save_feeds_default(&state.feeds) {146 if let Err(e) = crate::config::storage::save_feeds_default(&state.feeds) {
123 log::error!("Failed to save imported feeds: {}", e);147 log::error!("Failed to save imported feeds: {}", e);
124 state.set_export_import_error(format!("Erreur de sauvegarde: {}", e));148 state.set_export_import_error(format!("Erreur de sauvegarde: {}", e));
125 } else {149 } else {
126- log::info!("Successfully imported {} feeds from {}", count, path.display());150+ log::info!(
127- state.notification_manager.success(format!("Import réussi: {} flux ajoutés", count));151+ "Successfully imported {} feeds from {}",
152+ count,
153+ path.display()
154+ );
155+ state.record_history(
156+ crate::config::history::OperationType::ImportFeeds,
157+ format!("Import de {} flux depuis {}", count, path.display()),
158+ );
159+ state
160+ .notification_manager
161+ .success(format!("Import réussi: {} flux ajoutés", count));
128 state.show_import_dialog = false;162 state.show_import_dialog = false;
129 state.clear_export_import_error();163 state.clear_export_import_error();
130 }164 }
added src/ui/feed_detail.rs +230 -0
new file mode 100644
@@ -0,0 +1,230 @@
1+//! Feed detail view UI component
2+//!
3+//! Affiche les épisodes d'un flux avec téléchargement individuel,
4+//! actualisation et filtrage par titre.
5+
6+use crate::config::AppState;
7+use crate::config::statistics::{format_bytes, format_duration};
8+use crate::ui::theme::ThemeColors;
9+use crate::ui::truncate;
10+use egui::{Align, Layout, RichText, Ui};
11+
12+/// Draws the detail view of a feed (episode list)
13+pub fn draw_feed_detail(state: &mut AppState, ui: &mut Ui) {
14+ let Some(feed_id) = state.detail_feed_id.clone() else {
15+ return;
16+ };
17+
18+ let Some(feed_index) = state.feeds.iter().position(|f| f.id == feed_id) else {
19+ // Le flux a été supprimé : fermer la vue détaillée
20+ state.close_feed_detail();
21+ return;
22+ };
23+
24+ let colors = ThemeColors::for_theme(state.get_theme());
25+ let feed_name = state.feeds[feed_index].name.clone();
26+ let feed_url = state.feeds[feed_index].url.clone();
27+ let feed_enabled = state.feeds[feed_index].enabled;
28+
29+ // En-tête : retour + nom + URL
30+ let mut go_back = false;
31+ ui.horizontal(|ui| {
32+ if ui
33+ .button("⬅ Retour")
34+ .on_hover_text("Retour à la liste des flux (Échap)")
35+ .clicked()
36+ {
37+ go_back = true;
38+ }
39+ ui.label(
40+ RichText::new(&feed_name)
41+ .size(18.0)
42+ .strong()
43+ .color(colors.text_primary),
44+ );
45+ if !feed_enabled {
46+ ui.label(RichText::new("(désactivé)").size(12.0).color(colors.warning));
47+ }
48+ });
49+ if go_back {
50+ state.close_feed_detail();
51+ return;
52+ }
53+ ui.label(
54+ RichText::new(format!("🔗 {}", feed_url))
55+ .size(12.0)
56+ .color(colors.text_secondary),
57+ );
58+
59+ ui.separator();
60+
61+ // Barre d'outils : actualiser + filtre + compteurs
62+ let downloaded_count = state
63+ .detail_episodes
64+ .iter()
65+ .filter(|e| e.downloaded)
66+ .count();
67+ let total_count = state.detail_episodes.len();
68+ let mut refresh = false;
69+ ui.horizontal(|ui| {
70+ if ui.button("🔄 Actualiser").clicked() {
71+ refresh = true;
72+ }
73+ ui.add(
74+ egui::TextEdit::singleline(&mut state.detail_episode_filter)
75+ .hint_text("Filtrer les épisodes…")
76+ .desired_width(200.0),
77+ );
78+ if !state.detail_episode_filter.is_empty() && ui.button("✕").clicked() {
79+ state.detail_episode_filter.clear();
80+ }
81+ ui.label(
82+ RichText::new(format!(
83+ "{} épisodes, {} téléchargés",
84+ total_count, downloaded_count
85+ ))
86+ .color(colors.text_secondary),
87+ );
88+ });
89+ if refresh {
90+ state.open_feed_detail(feed_id);
91+ return;
92+ }
93+
94+ ui.add_space(4.0);
95+
96+ // Chargement en cours
97+ if state.detail_loading {
98+ ui.horizontal(|ui| {
99+ ui.spinner();
100+ ui.label("Chargement des épisodes…");
101+ });
102+ return;
103+ }
104+
105+ // Erreur de chargement
106+ if let Some(error) = state.detail_error.clone() {
107+ ui.colored_label(colors.error, format!("Erreur lors du chargement des épisodes : {}", error));
108+ if ui.button("Réessayer").clicked() {
109+ state.open_feed_detail(feed_id);
110+ }
111+ return;
112+ }
113+
114+ if state.detail_episodes.is_empty() {
115+ ui.label(RichText::new("Aucun épisode trouvé dans ce flux.").color(colors.text_dim));
116+ return;
117+ }
118+
119+ // Indices des épisodes correspondant au filtre, triés du plus récent au plus ancien
120+ let filter = state.detail_episode_filter.to_lowercase();
121+ let mut indices: Vec<usize> = state
122+ .detail_episodes
123+ .iter()
124+ .enumerate()
125+ .filter(|(_, e)| e.title.to_lowercase().contains(&filter))
126+ .map(|(i, _)| i)
127+ .collect();
128+ indices.sort_by_key(|&i| std::cmp::Reverse(state.detail_episodes[i].pub_date));
129+
130+ if indices.is_empty() {
131+ ui.label(RichText::new("Aucun épisode correspondant au filtre.").color(colors.text_dim));
132+ return;
133+ }
134+
135+ for index in indices {
136+ draw_episode_item(state, ui, feed_index, index, &colors);
137+ }
138+}
139+
140+/// Draws a single episode row
141+fn draw_episode_item(
142+ state: &mut AppState,
143+ ui: &mut Ui,
144+ feed_index: usize,
145+ episode_index: usize,
146+ colors: &ThemeColors,
147+) {
148+ let episode = &state.detail_episodes[episode_index];
149+ let episode_id = episode.id.clone();
150+ let episode_title = episode.title.clone();
151+ let episode_downloaded = episode.downloaded;
152+ let is_downloading = state.downloading_episode_ids.contains(&episode_id);
153+
154+ egui::Frame {
155+ fill: if episode_downloaded {
156+ colors.bg_tertiary
157+ } else {
158+ colors.bg_secondary
159+ },
160+ stroke: egui::Stroke::new(1.0, colors.border),
161+ corner_radius: 6.0.into(),
162+ inner_margin: egui::Margin::symmetric(12, 6),
163+ ..Default::default()
164+ }
165+ .show(ui, |ui| {
166+ ui.horizontal(|ui| {
167+ // Titre + métadonnées
168+ ui.vertical(|ui| {
169+ let title_label = ui.label(
170+ RichText::new(truncate(&episode_title, 70))
171+ .size(14.0)
172+ .strong()
173+ .color(colors.text_primary),
174+ );
175+ if let Some(description) = &episode.description {
176+ title_label.on_hover_text(truncate(description, 300));
177+ }
178+
179+ ui.horizontal(|ui| {
180+ ui.label(
181+ RichText::new(episode.pub_date.format("%d/%m/%Y").to_string())
182+ .size(11.0)
183+ .color(colors.text_secondary),
184+ );
185+ if let Some(size) = episode.file_size {
186+ ui.label(
187+ RichText::new(format_bytes(size))
188+ .size(11.0)
189+ .color(colors.text_secondary),
190+ );
191+ }
192+ if let Some(duration) = episode.duration_seconds {
193+ ui.label(
194+ RichText::new(format_duration(duration))
195+ .size(11.0)
196+ .color(colors.text_secondary),
197+ );
198+ }
199+ });
200+ });
201+
202+ // Actions
203+ ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
204+ if episode_downloaded {
205+ ui.label(
206+ RichText::new("✅ Téléchargé")
207+ .size(12.0)
208+ .color(colors.success),
209+ );
210+ } else if is_downloading {
211+ ui.label(RichText::new("⏳ Téléchargement…").size(12.0).color(colors.accent));
212+ } else if ui
213+ .button(RichText::new("⬇ Télécharger").size(12.0))
214+ .on_hover_text(format!("Télécharger \"{}\"", truncate(&episode_title, 40)))
215+ .clicked()
216+ {
217+ let feed = state.feeds[feed_index].clone();
218+ let episode = state.detail_episodes[episode_index].clone();
219+ state.downloading_episode_ids.insert(episode.id.clone());
220+ state
221+ .notification_manager
222+ .info(format!("Téléchargement démarré : {}", truncate(&episode_title, 40)));
223+ state.sync_task_manager.start_download_episode(feed, episode);
224+ }
225+ });
226+ });
227+ });
228+
229+ ui.add_space(4.0);
230+}
new file mode 100644
@@ -0,0 +1,230 @@
1+//! Feed detail view UI component
2+//!
3+//! Affiche les épisodes d'un flux avec téléchargement individuel,
4+//! actualisation et filtrage par titre.
5+
6+use crate::config::AppState;
7+use crate::config::statistics::{format_bytes, format_duration};
8+use crate::ui::theme::ThemeColors;
9+use crate::ui::truncate;
10+use egui::{Align, Layout, RichText, Ui};
11+
12+/// Draws the detail view of a feed (episode list)
13+pub fn draw_feed_detail(state: &mut AppState, ui: &mut Ui) {
14+ let Some(feed_id) = state.detail_feed_id.clone() else {
15+ return;
16+ };
17+
18+ let Some(feed_index) = state.feeds.iter().position(|f| f.id == feed_id) else {
19+ // Le flux a été supprimé : fermer la vue détaillée
20+ state.close_feed_detail();
21+ return;
22+ };
23+
24+ let colors = ThemeColors::for_theme(state.get_theme());
25+ let feed_name = state.feeds[feed_index].name.clone();
26+ let feed_url = state.feeds[feed_index].url.clone();
27+ let feed_enabled = state.feeds[feed_index].enabled;
28+
29+ // En-tête : retour + nom + URL
30+ let mut go_back = false;
31+ ui.horizontal(|ui| {
32+ if ui
33+ .button("⬅ Retour")
34+ .on_hover_text("Retour à la liste des flux (Échap)")
35+ .clicked()
36+ {
37+ go_back = true;
38+ }
39+ ui.label(
40+ RichText::new(&feed_name)
41+ .size(18.0)
42+ .strong()
43+ .color(colors.text_primary),
44+ );
45+ if !feed_enabled {
46+ ui.label(RichText::new("(désactivé)").size(12.0).color(colors.warning));
47+ }
48+ });
49+ if go_back {
50+ state.close_feed_detail();
51+ return;
52+ }
53+ ui.label(
54+ RichText::new(format!("🔗 {}", feed_url))
55+ .size(12.0)
56+ .color(colors.text_secondary),
57+ );
58+
59+ ui.separator();
60+
61+ // Barre d'outils : actualiser + filtre + compteurs
62+ let downloaded_count = state
63+ .detail_episodes
64+ .iter()
65+ .filter(|e| e.downloaded)
66+ .count();
67+ let total_count = state.detail_episodes.len();
68+ let mut refresh = false;
69+ ui.horizontal(|ui| {
70+ if ui.button("🔄 Actualiser").clicked() {
71+ refresh = true;
72+ }
73+ ui.add(
74+ egui::TextEdit::singleline(&mut state.detail_episode_filter)
75+ .hint_text("Filtrer les épisodes…")
76+ .desired_width(200.0),
77+ );
78+ if !state.detail_episode_filter.is_empty() && ui.button("✕").clicked() {
79+ state.detail_episode_filter.clear();
80+ }
81+ ui.label(
82+ RichText::new(format!(
83+ "{} épisodes, {} téléchargés",
84+ total_count, downloaded_count
85+ ))
86+ .color(colors.text_secondary),
87+ );
88+ });
89+ if refresh {
90+ state.open_feed_detail(feed_id);
91+ return;
92+ }
93+
94+ ui.add_space(4.0);
95+
96+ // Chargement en cours
97+ if state.detail_loading {
98+ ui.horizontal(|ui| {
99+ ui.spinner();
100+ ui.label("Chargement des épisodes…");
101+ });
102+ return;
103+ }
104+
105+ // Erreur de chargement
106+ if let Some(error) = state.detail_error.clone() {
107+ ui.colored_label(colors.error, format!("Erreur lors du chargement des épisodes : {}", error));
108+ if ui.button("Réessayer").clicked() {
109+ state.open_feed_detail(feed_id);
110+ }
111+ return;
112+ }
113+
114+ if state.detail_episodes.is_empty() {
115+ ui.label(RichText::new("Aucun épisode trouvé dans ce flux.").color(colors.text_dim));
116+ return;
117+ }
118+
119+ // Indices des épisodes correspondant au filtre, triés du plus récent au plus ancien
120+ let filter = state.detail_episode_filter.to_lowercase();
121+ let mut indices: Vec<usize> = state
122+ .detail_episodes
123+ .iter()
124+ .enumerate()
125+ .filter(|(_, e)| e.title.to_lowercase().contains(&filter))
126+ .map(|(i, _)| i)
127+ .collect();
128+ indices.sort_by_key(|&i| std::cmp::Reverse(state.detail_episodes[i].pub_date));
129+
130+ if indices.is_empty() {
131+ ui.label(RichText::new("Aucun épisode correspondant au filtre.").color(colors.text_dim));
132+ return;
133+ }
134+
135+ for index in indices {
136+ draw_episode_item(state, ui, feed_index, index, &colors);
137+ }
138+}
139+
140+/// Draws a single episode row
141+fn draw_episode_item(
142+ state: &mut AppState,
143+ ui: &mut Ui,
144+ feed_index: usize,
145+ episode_index: usize,
146+ colors: &ThemeColors,
147+) {
148+ let episode = &state.detail_episodes[episode_index];
149+ let episode_id = episode.id.clone();
150+ let episode_title = episode.title.clone();
151+ let episode_downloaded = episode.downloaded;
152+ let is_downloading = state.downloading_episode_ids.contains(&episode_id);
153+
154+ egui::Frame {
155+ fill: if episode_downloaded {
156+ colors.bg_tertiary
157+ } else {
158+ colors.bg_secondary
159+ },
160+ stroke: egui::Stroke::new(1.0, colors.border),
161+ corner_radius: 6.0.into(),
162+ inner_margin: egui::Margin::symmetric(12, 6),
163+ ..Default::default()
164+ }
165+ .show(ui, |ui| {
166+ ui.horizontal(|ui| {
167+ // Titre + métadonnées
168+ ui.vertical(|ui| {
169+ let title_label = ui.label(
170+ RichText::new(truncate(&episode_title, 70))
171+ .size(14.0)
172+ .strong()
173+ .color(colors.text_primary),
174+ );
175+ if let Some(description) = &episode.description {
176+ title_label.on_hover_text(truncate(description, 300));
177+ }
178+
179+ ui.horizontal(|ui| {
180+ ui.label(
181+ RichText::new(episode.pub_date.format("%d/%m/%Y").to_string())
182+ .size(11.0)
183+ .color(colors.text_secondary),
184+ );
185+ if let Some(size) = episode.file_size {
186+ ui.label(
187+ RichText::new(format_bytes(size))
188+ .size(11.0)
189+ .color(colors.text_secondary),
190+ );
191+ }
192+ if let Some(duration) = episode.duration_seconds {
193+ ui.label(
194+ RichText::new(format_duration(duration))
195+ .size(11.0)
196+ .color(colors.text_secondary),
197+ );
198+ }
199+ });
200+ });
201+
202+ // Actions
203+ ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
204+ if episode_downloaded {
205+ ui.label(
206+ RichText::new("✅ Téléchargé")
207+ .size(12.0)
208+ .color(colors.success),
209+ );
210+ } else if is_downloading {
211+ ui.label(RichText::new("⏳ Téléchargement…").size(12.0).color(colors.accent));
212+ } else if ui
213+ .button(RichText::new("⬇ Télécharger").size(12.0))
214+ .on_hover_text(format!("Télécharger \"{}\"", truncate(&episode_title, 40)))
215+ .clicked()
216+ {
217+ let feed = state.feeds[feed_index].clone();
218+ let episode = state.detail_episodes[episode_index].clone();
219+ state.downloading_episode_ids.insert(episode.id.clone());
220+ state
221+ .notification_manager
222+ .info(format!("Téléchargement démarré : {}", truncate(&episode_title, 40)));
223+ state.sync_task_manager.start_download_episode(feed, episode);
224+ }
225+ });
226+ });
227+ });
228+
229+ ui.add_space(4.0);
230+}
modified src/ui/feed_edit_dialog.rs +100 -53
@@ -6,7 +6,7 @@ use crate::config::AppState;
66 use egui::Ui;
77 use std::path::PathBuf;
88
9-/// Draws the edit feed dialog
9+/// Draws the edit feed dialog (modal)
1010 pub fn draw_edit_feed_dialog(state: &mut AppState, ui: &mut Ui) {
1111 let editing_index = match state.editing_feed_index {
1212 Some(idx) => idx,
@@ -15,6 +15,7 @@ pub fn draw_edit_feed_dialog(state: &mut AppState, ui: &mut Ui) {
1515
1616 if editing_index >= state.feeds.len() {
1717 state.clear_edit_feed_form();
18+ state.show_edit_feed_dialog = false;
1819 return;
1920 }
2021
@@ -23,57 +24,103 @@ pub fn draw_edit_feed_dialog(state: &mut AppState, ui: &mut Ui) {
2324 state.load_edit_feed_form(editing_index);
2425 }
2526
26- egui::Window::new("Éditer le flux")
27- .resizable(false)
28- .show(ui.ctx(), |ui| {
29- ui.vertical(|ui| {
30- ui.label(format!("Éditer: {}", state.edit_feed_name));
31- ui.separator();
32-
33- ui.label("Nom du podcast");
34- ui.text_edit_singleline(&mut state.edit_feed_name);
35-
36- ui.label("URL du flux RSS");
37- ui.text_edit_singleline(&mut state.edit_feed_url);
38-
39- ui.label("Dossier de téléchargement");
40- ui.text_edit_singleline(&mut state.edit_feed_download_path);
41-
42- ui.checkbox(&mut state.edit_feed_enabled, "Activé");
43-
44- ui.label("Tags (séparés par des virgules)");
45- ui.text_edit_singleline(&mut state.edit_feed_tags);
46- ui.label("Exemple: Tech,Podcast,Français");
47-
48- ui.separator();
49-
50- ui.horizontal(|ui| {
51- if ui.button("Sauvegarder").clicked() {
52- log::info!("Updating feed at index {}", editing_index);
53- state.feeds[editing_index].name = state.edit_feed_name.clone();
54- state.feeds[editing_index].url = state.edit_feed_url.clone();
55- state.feeds[editing_index].download_path = PathBuf::from(state.edit_feed_download_path.clone());
56- state.feeds[editing_index].enabled = state.edit_feed_enabled;
57-
58- // Parse tags from comma-separated string
59- state.feeds[editing_index].tags = state.edit_feed_tags
60- .split(',')
61- .map(|s| s.trim().to_string())
62- .filter(|s| !s.is_empty())
63- .collect();
64-
65- super::super::config::storage::save_feeds_default(&state.feeds).ok();
66- log::info!("Feed updated successfully");
67- state.notification_manager.success("Flux modifié");
68- state.clear_edit_feed_form();
69- state.show_edit_feed_dialog = false;
70- }
71-
72- if ui.button("Annuler").clicked() {
73- state.clear_edit_feed_form();
74- state.show_edit_feed_dialog = false;
75- }
76- });
77- });
27+ let ctx = ui.ctx().clone();
28+ let modal = egui::Modal::new(egui::Id::new("edit_feed_dialog"));
29+ let response = modal.show(&ctx, |ui| {
30+ ui.set_min_width(420.0);
31+ ui.heading(format!("Éditer : {}", state.feeds[editing_index].name));
32+ ui.separator();
33+
34+ ui.label("Nom du podcast");
35+ ui.text_edit_singleline(&mut state.edit_feed_name);
36+
37+ ui.label("URL du flux RSS");
38+ let url_response = ui.text_edit_singleline(&mut state.edit_feed_url);
39+
40+ ui.label("Dossier de téléchargement");
41+ let download_width = (ui.available_width() - 110.0).max(180.0);
42+ ui.horizontal(|ui| {
43+ ui.add(
44+ egui::TextEdit::singleline(&mut state.edit_feed_download_path)
45+ .desired_width(download_width),
46+ );
47+ if ui.button("Parcourir…").clicked() {
48+ if let Some(path) = rfd::FileDialog::new()
49+ .set_title("Choisir le dossier de téléchargement")
50+ .pick_folder()
51+ {
52+ state.edit_feed_download_path = path.to_string_lossy().to_string();
53+ }
54+ }
55+ });
56+
57+ ui.checkbox(&mut state.edit_feed_enabled, "Activé");
58+
59+ ui.label("Tags (séparés par des virgules)");
60+ ui.text_edit_singleline(&mut state.edit_feed_tags);
61+ ui.label(
62+ egui::RichText::new("Exemple : Tech, Podcast, Français").weak().small(),
63+ );
64+
65+ ui.separator();
66+
67+ let valid =
68+ !state.edit_feed_name.trim().is_empty() && !state.edit_feed_url.trim().is_empty();
69+
70+ let mut submitted = false;
71+ ui.horizontal(|ui| {
72+ if ui.add_enabled(valid, egui::Button::new("Sauvegarder")).clicked() {
73+ submitted = true;
74+ }
75+ if ui.button("Annuler").clicked() {
76+ state.clear_edit_feed_form();
77+ state.show_edit_feed_dialog = false;
78+ }
7879 });
80+ if !valid {
81+ ui.label(
82+ egui::RichText::new("Le nom et l'URL du flux sont requis.")
83+ .weak()
84+ .small(),
85+ );
86+ }
87+
88+ // Entrée pour valider depuis le champ URL
89+ if url_response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && valid {
90+ submitted = true;
91+ }
92+
93+ submitted
94+ });
95+
96+ if response.inner {
97+ log::info!("Updating feed at index {}", editing_index);
98+ let feed = &mut state.feeds[editing_index];
99+ feed.name = state.edit_feed_name.trim().to_string();
100+ feed.url = state.edit_feed_url.trim().to_string();
101+ feed.download_path = PathBuf::from(state.edit_feed_download_path.trim());
102+ feed.enabled = state.edit_feed_enabled;
103+
104+ // Parse tags from comma-separated string
105+ feed.tags = state
106+ .edit_feed_tags
107+ .split(',')
108+ .map(|s| s.trim().to_string())
109+ .filter(|s| !s.is_empty())
110+ .collect();
111+
112+ let feed_name = feed.name.clone();
113+ super::super::config::storage::save_feeds_default(&state.feeds).ok();
114+ log::info!("Feed updated successfully");
115+ state.record_history(
116+ crate::config::history::OperationType::UpdateFeed,
117+ format!("Flux modifié : {}", feed_name),
118+ );
119+ state.notification_manager.success("Flux modifié");
120+ state.clear_edit_feed_form();
121+ state.show_edit_feed_dialog = false;
122+ } else if response.should_close() {
123+ state.clear_edit_feed_form();
124+ state.show_edit_feed_dialog = false;
125+ }
79126 }
@@ -6,7 +6,7 @@ use crate::config::AppState;
6 use egui::Ui;6 use egui::Ui;
7 use std::path::PathBuf;7 use std::path::PathBuf;
8 8
9-/// Draws the edit feed dialog9+/// Draws the edit feed dialog (modal)
10 pub fn draw_edit_feed_dialog(state: &mut AppState, ui: &mut Ui) {10 pub fn draw_edit_feed_dialog(state: &mut AppState, ui: &mut Ui) {
11 let editing_index = match state.editing_feed_index {11 let editing_index = match state.editing_feed_index {
12 Some(idx) => idx,12 Some(idx) => idx,
@@ -15,6 +15,7 @@ pub fn draw_edit_feed_dialog(state: &mut AppState, ui: &mut Ui) {
15 15
16 if editing_index >= state.feeds.len() {16 if editing_index >= state.feeds.len() {
17 state.clear_edit_feed_form();17 state.clear_edit_feed_form();
18+ state.show_edit_feed_dialog = false;
18 return;19 return;
19 }20 }
20 21
@@ -23,57 +24,103 @@ pub fn draw_edit_feed_dialog(state: &mut AppState, ui: &mut Ui) {
23 state.load_edit_feed_form(editing_index);24 state.load_edit_feed_form(editing_index);
24 }25 }
25 26
26- egui::Window::new("Éditer le flux")27+ let ctx = ui.ctx().clone();
27- .resizable(false)28+ let modal = egui::Modal::new(egui::Id::new("edit_feed_dialog"));
28- .show(ui.ctx(), |ui| {29+ let response = modal.show(&ctx, |ui| {
29- ui.vertical(|ui| {30+ ui.set_min_width(420.0);
30- ui.label(format!("Éditer: {}", state.edit_feed_name));31+ ui.heading(format!("Éditer : {}", state.feeds[editing_index].name));
31- ui.separator();32+ ui.separator();
32-33+
33- ui.label("Nom du podcast");34+ ui.label("Nom du podcast");
34- ui.text_edit_singleline(&mut state.edit_feed_name);35+ ui.text_edit_singleline(&mut state.edit_feed_name);
35-36+
36- ui.label("URL du flux RSS");37+ ui.label("URL du flux RSS");
37- ui.text_edit_singleline(&mut state.edit_feed_url);38+ let url_response = ui.text_edit_singleline(&mut state.edit_feed_url);
38-39+
39- ui.label("Dossier de téléchargement");40+ ui.label("Dossier de téléchargement");
40- ui.text_edit_singleline(&mut state.edit_feed_download_path);41+ let download_width = (ui.available_width() - 110.0).max(180.0);
41-42+ ui.horizontal(|ui| {
42- ui.checkbox(&mut state.edit_feed_enabled, "Activé");43+ ui.add(
43-44+ egui::TextEdit::singleline(&mut state.edit_feed_download_path)
44- ui.label("Tags (séparés par des virgules)");45+ .desired_width(download_width),
45- ui.text_edit_singleline(&mut state.edit_feed_tags);46+ );
46- ui.label("Exemple: Tech,Podcast,Français");47+ if ui.button("Parcourir…").clicked() {
47-48+ if let Some(path) = rfd::FileDialog::new()
48- ui.separator();49+ .set_title("Choisir le dossier de téléchargement")
49-50+ .pick_folder()
50- ui.horizontal(|ui| {51+ {
51- if ui.button("Sauvegarder").clicked() {52+ state.edit_feed_download_path = path.to_string_lossy().to_string();
52- log::info!("Updating feed at index {}", editing_index);53+ }
53- state.feeds[editing_index].name = state.edit_feed_name.clone();54+ }
54- state.feeds[editing_index].url = state.edit_feed_url.clone();55+ });
55- state.feeds[editing_index].download_path = PathBuf::from(state.edit_feed_download_path.clone());56+
56- state.feeds[editing_index].enabled = state.edit_feed_enabled;57+ ui.checkbox(&mut state.edit_feed_enabled, "Activé");
57- 58+
58- // Parse tags from comma-separated string59+ ui.label("Tags (séparés par des virgules)");
59- state.feeds[editing_index].tags = state.edit_feed_tags60+ ui.text_edit_singleline(&mut state.edit_feed_tags);
60- .split(',')61+ ui.label(
61- .map(|s| s.trim().to_string())62+ egui::RichText::new("Exemple : Tech, Podcast, Français").weak().small(),
62- .filter(|s| !s.is_empty())63+ );
63- .collect();64+
64- 65+ ui.separator();
65- super::super::config::storage::save_feeds_default(&state.feeds).ok();66+
66- log::info!("Feed updated successfully");67+ let valid =
67- state.notification_manager.success("Flux modifié");68+ !state.edit_feed_name.trim().is_empty() && !state.edit_feed_url.trim().is_empty();
68- state.clear_edit_feed_form();69+
69- state.show_edit_feed_dialog = false;70+ let mut submitted = false;
70- }71+ ui.horizontal(|ui| {
71-72+ if ui.add_enabled(valid, egui::Button::new("Sauvegarder")).clicked() {
72- if ui.button("Annuler").clicked() {73+ submitted = true;
73- state.clear_edit_feed_form();74+ }
74- state.show_edit_feed_dialog = false;75+ if ui.button("Annuler").clicked() {
75- }76+ state.clear_edit_feed_form();
76- });77+ state.show_edit_feed_dialog = false;
77- });78+ }
78 });79 });
80+ if !valid {
81+ ui.label(
82+ egui::RichText::new("Le nom et l'URL du flux sont requis.")
83+ .weak()
84+ .small(),
85+ );
86+ }
87+
88+ // Entrée pour valider depuis le champ URL
89+ if url_response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && valid {
90+ submitted = true;
91+ }
92+
93+ submitted
94+ });
95+
96+ if response.inner {
97+ log::info!("Updating feed at index {}", editing_index);
98+ let feed = &mut state.feeds[editing_index];
99+ feed.name = state.edit_feed_name.trim().to_string();
100+ feed.url = state.edit_feed_url.trim().to_string();
101+ feed.download_path = PathBuf::from(state.edit_feed_download_path.trim());
102+ feed.enabled = state.edit_feed_enabled;
103+
104+ // Parse tags from comma-separated string
105+ feed.tags = state
106+ .edit_feed_tags
107+ .split(',')
108+ .map(|s| s.trim().to_string())
109+ .filter(|s| !s.is_empty())
110+ .collect();
111+
112+ let feed_name = feed.name.clone();
113+ super::super::config::storage::save_feeds_default(&state.feeds).ok();
114+ log::info!("Feed updated successfully");
115+ state.record_history(
116+ crate::config::history::OperationType::UpdateFeed,
117+ format!("Flux modifié : {}", feed_name),
118+ );
119+ state.notification_manager.success("Flux modifié");
120+ state.clear_edit_feed_form();
121+ state.show_edit_feed_dialog = false;
122+ } else if response.should_close() {
123+ state.clear_edit_feed_form();
124+ state.show_edit_feed_dialog = false;
125+ }
79 }126 }
modified src/ui/feed_list.rs +165 -154
@@ -2,29 +2,45 @@
22 //!
33 //! This module contains the feed list display and interaction logic.
44
5-use crate::config::{AppState, Theme};
6-use egui::{Align, Color32, Layout, RichText, ScrollArea, Ui};
5+use crate::config::AppState;
6+use crate::ui::theme::ThemeColors;
7+use egui::{Align, Layout, RichText, Ui};
78
89 /// Draws the feed list
910 pub fn draw_feed_list(state: &mut AppState, ui: &mut Ui) {
1011 // Apply filters
1112 let filtered_indices = crate::ui::filter_panel::filter_feeds(&state.feeds, state);
12-
13+
14+ let searching = !state.search_query.is_empty();
15+ if searching {
16+ ui.label(
17+ RichText::new(format!(
18+ "{} flux trouvé(s) pour \"{}\"",
19+ filtered_indices.len(),
20+ state.search_query.query.trim()
21+ ))
22+ .weak(),
23+ );
24+ }
25+
1326 if filtered_indices.is_empty() {
14- ui.label("Aucun flux correspondant aux filtres.");
27+ if searching {
28+ ui.label("Aucun flux correspondant à la recherche.");
29+ } else {
30+ ui.label("Aucun flux correspondant aux filtres.");
31+ }
1532 return;
1633 }
1734
18- ScrollArea::vertical().show(ui, |ui| {
19- for &index in &filtered_indices {
20- draw_feed_item(state, ui, index);
21- }
22- });
35+ for &index in &filtered_indices {
36+ draw_feed_item(state, ui, index);
37+ }
2338 }
2439
25-/// Draws a single feed item with improved UI
40+/// Draws a single feed item
2641 fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) {
2742 let feed = &state.feeds[index];
43+ let feed_id = feed.id.clone();
2844 let feed_name = feed.name.clone();
2945 let feed_url = feed.url.clone();
3046 let feed_path = feed.download_path.to_string_lossy().to_string();
@@ -34,160 +50,195 @@ fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) {
3450 let feed_downloaded_count = feed.downloaded_episodes_count;
3551 let feed_last_downloaded_name = feed.last_downloaded_episode_name.clone();
3652 let feed_last_downloaded_date = feed.last_downloaded_episode_date;
37- let is_selected = state.current_feed == Some(index);
38- let theme = state.get_theme();
39-
40- // Theme-aware colors
41- let (text_primary, text_secondary, text_dim,
42- success_color, warning_color, info_color,
43- bg_selected, bg_normal, stroke_selected, stroke_normal) =
44- get_theme_colors(&theme, is_selected);
45-
46- let bg_color = if is_selected { bg_selected } else { bg_normal };
47- let stroke_color = if is_selected { stroke_selected } else { stroke_normal };
53+ let feed_sync_status = state
54+ .sync_task_manager
55+ .status
56+ .feed_sync_status
57+ .get(&feed_id)
58+ .cloned();
59+ let colors = ThemeColors::for_theme(state.get_theme());
4860
4961 // Frame avec style amélioré
5062 egui::Frame {
51- fill: bg_color,
52- stroke: egui::Stroke::new(1.0, stroke_color),
63+ fill: colors.bg_secondary,
64+ stroke: egui::Stroke::new(1.0, colors.border),
5365 corner_radius: 6.0.into(),
5466 inner_margin: egui::Margin::symmetric(12, 8),
5567 ..Default::default()
5668 }
5769 .show(ui, |ui| {
5870 ui.spacing_mut().item_spacing = egui::Vec2::new(16.0, 8.0);
59-
71+
6072 ui.horizontal(|ui| {
6173 // Section info principale
6274 ui.vertical(|ui| {
6375 // Nom du flux (plus gros et en gras)
64- ui.label(RichText::new(&feed_name)
65- .size(16.0)
66- .strong()
67- .color(text_primary));
68-
76+ ui.label(
77+ RichText::new(&feed_name)
78+ .size(16.0)
79+ .strong()
80+ .color(colors.text_primary),
81+ );
82+
6983 // URL (plus petite)
70- ui.label(RichText::new(format!("🔗 {}", feed_url))
71- .size(12.0)
72- .color(text_secondary));
73-
84+ ui.label(
85+ RichText::new(format!("🔗 {}", feed_url))
86+ .size(12.0)
87+ .color(colors.text_secondary),
88+ );
89+
7490 // Dossier
75- ui.label(RichText::new(format!("📁 {}", feed_path))
76- .size(12.0)
77- .color(text_secondary));
78-
91+ ui.label(
92+ RichText::new(format!("📁 {}", feed_path))
93+ .size(12.0)
94+ .color(colors.text_secondary),
95+ );
96+
7997 // Tags
8098 if !feed_tags.is_empty() {
8199 ui.horizontal_wrapped(|ui| {
82- ui.label(RichText::new("🏷️ ").size(12.0).color(text_dim));
100+ ui.label(RichText::new("🏷️ ").size(12.0).color(colors.text_dim));
83101 for tag in &feed_tags {
84- ui.label(RichText::new(format!("#{} ", tag))
85- .size(11.0)
86- .color(info_color));
102+ ui.label(
103+ RichText::new(format!("#{} ", tag))
104+ .size(11.0)
105+ .color(colors.accent),
106+ );
87107 }
88108 });
89109 }
90110 });
91-
111+
92112 // Section statut et statistiques
93113 ui.vertical(|ui| {
94114 ui.add_space(4.0);
95-
115+
96116 // Statut Activé/Désactivé
97- let status_text = if feed_enabled {
98- RichText::new("✅ Activé").color(success_color)
117+ let status_text = if feed_enabled {
118+ RichText::new("✅ Activé").color(colors.success)
99119 } else {
100- RichText::new("❌ Désactivé").color(warning_color)
120+ RichText::new("❌ Désactivé").color(colors.warning)
101121 };
102122 ui.label(status_text.size(12.0));
103-
104- // Dernière vérification
105- if let Some(date) = feed_last_checked {
106- ui.label(RichText::new(format!("🔍 Vérifié: {}", date.format("%d/%m %H:%M")))
107- .size(11.0)
108- .color(text_secondary));
109- } else {
110- ui.label(RichText::new("🔍 Jamais vérifié").size(11.0).color(text_dim));
123+
124+ // Statut de synchronisation de ce flux
125+ match feed_sync_status {
126+ Some(crate::config::sync_task::FeedSyncStatus::InProgress) => {
127+ ui.label(RichText::new("⏳ Synchronisation…").size(11.0).color(colors.accent));
128+ }
129+ Some(crate::config::sync_task::FeedSyncStatus::Failed { error }) => {
130+ ui.label(
131+ RichText::new("⚠️ Échec de synchro")
132+ .size(11.0)
133+ .color(colors.error),
134+ )
135+ .on_hover_text(&error);
136+ }
137+ _ => {
138+ // Dernière vérification
139+ if let Some(date) = feed_last_checked {
140+ ui.label(
141+ RichText::new(format!("🔍 Vérifié: {}", date.format("%d/%m %H:%M")))
142+ .size(11.0)
143+ .color(colors.text_secondary),
144+ );
145+ } else {
146+ ui.label(
147+ RichText::new("🔍 Jamais vérifié")
148+ .size(11.0)
149+ .color(colors.text_dim),
150+ );
151+ }
152+ }
111153 }
112-
154+
113155 // Séparateur visuel
114156 ui.add_space(6.0);
115157 ui.separator();
116158 ui.add_space(4.0);
117-
159+
118160 // Section Statistiques de téléchargement (mise en évidence)
119- ui.label(RichText::new("📊 Téléchargements").size(11.0).strong().color(info_color))
120- .on_hover_text("Nombre et dernier épisode téléchargé");
121-
161+ ui.label(
162+ RichText::new("📊 Téléchargements")
163+ .size(11.0)
164+ .strong()
165+ .color(colors.accent),
166+ )
167+ .on_hover_text("Nombre et dernier épisode téléchargé");
168+
122169 // Nombre de téléchargements
123170 if feed_downloaded_count > 0 {
124- ui.label(RichText::new(format!("📥 {} épisodes", feed_downloaded_count))
125- .size(11.0)
126- .color(success_color));
171+ ui.label(
172+ RichText::new(format!("📥 {} épisodes", feed_downloaded_count))
173+ .size(11.0)
174+ .color(colors.success),
175+ );
127176 } else {
128- ui.label(RichText::new("📥 Aucun téléchargement").size(11.0).color(text_dim));
177+ ui.label(
178+ RichText::new("📥 Aucun téléchargement")
179+ .size(11.0)
180+ .color(colors.text_dim),
181+ );
129182 }
130-
183+
131184 // Dernier épisode téléchargé
132185 if let Some(last_name) = feed_last_downloaded_name {
133- if let Some(last_date) = feed_last_downloaded_date {
134- // Tronquer le nom si trop long
135- let display_name = if last_name.len() > 25 {
136- format!("{}...", &last_name[..22])
137- } else {
138- last_name
139- };
140- ui.label(RichText::new(format!("🎵 {}", display_name))
141- .size(11.0)
142- .color(info_color));
143- ui.label(RichText::new(format!(" le {}", last_date.format("%d/%m %H:%M")))
144- .size(10.0)
145- .color(text_dim));
146- } else {
147- let display_name = if last_name.len() > 25 {
148- format!("{}...", &last_name[..22])
149- } else {
150- last_name
151- };
152- ui.label(RichText::new(format!("🎵 {}", display_name))
186+ let display_name = crate::ui::truncate(&last_name, 25);
187+ ui.label(
188+ RichText::new(format!("🎵 {}", display_name))
153189 .size(11.0)
154- .color(info_color));
190+ .color(colors.accent),
191+ );
192+ if let Some(last_date) = feed_last_downloaded_date {
193+ ui.label(
194+ RichText::new(format!(" le {}", last_date.format("%d/%m %H:%M")))
195+ .size(10.0)
196+ .color(colors.text_dim),
197+ );
155198 }
156199 }
157200 });
158-
201+
159202 // Section actions (à droite)
160203 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
161204 ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
162-
205+
206+ // Bouton Détails
207+ if ui
208+ .button(RichText::new("📖").size(18.0))
209+ .on_hover_text("Voir les épisodes de ce flux")
210+ .clicked()
211+ {
212+ state.open_feed_detail(feed_id.clone());
213+ }
214+
163215 // Bouton Synchroniser
164- if ui.button(RichText::new("🔄").size(18.0))
216+ if ui
217+ .button(RichText::new("🔄").size(18.0))
165218 .on_hover_text("Synchroniser ce flux")
166- .clicked() {
219+ .clicked()
220+ {
167221 sync_feed_async(state, index);
168222 }
169-
223+
170224 // Bouton Éditer
171- if ui.button(RichText::new("✏️").size(18.0))
225+ if ui
226+ .button(RichText::new("✏️").size(18.0))
172227 .on_hover_text("Éditer ce flux")
173- .clicked() {
228+ .clicked()
229+ {
174230 state.editing_feed_index = Some(index);
175231 state.load_edit_feed_form(index);
176232 state.show_edit_feed_dialog = true;
177233 }
178-
179- // Bouton Supprimer
180- if ui.button(RichText::new("🗑️").size(18.0))
234+
235+ // Bouton Supprimer (avec confirmation)
236+ if ui
237+ .button(RichText::new("🗑️").size(18.0))
181238 .on_hover_text("Supprimer ce flux")
182- .clicked() {
183- log::info!("Deleting feed at index {}: {}", index, feed_name);
184- state.feeds.remove(index);
185- super::super::config::storage::save_feeds_default(&state.feeds).ok();
186- log::info!("Feed deleted successfully");
187- state.notification_manager.success("Flux supprimé");
188- if state.current_feed == Some(index) {
189- state.select_feed(None);
190- }
239+ .clicked()
240+ {
241+ state.confirm_delete_feed_id = Some(feed_id.clone());
191242 }
192243 });
193244 });
@@ -198,51 +249,6 @@ fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) {
198249 ui.separator();
199250 }
200251
201-/// Returns theme-appropriate colors for feed list items
202-/// Colors are aligned with the main theme colors from app.rs
203-fn get_theme_colors(theme: &Theme, _is_selected: bool) -> (
204- Color32, Color32, Color32, // text_primary, text_secondary, text_dim
205- Color32, Color32, Color32, // success_color, warning_color, info_color
206- Color32, Color32, Color32, Color32 // bg_selected, bg_normal, stroke_selected, stroke_normal
207-) {
208- match theme {
209- Theme::Dark => {
210- let text_primary = Color32::from_rgb(220, 230, 245);
211- let text_secondary = Color32::from_rgb(180, 195, 210);
212- let text_dim = Color32::from_rgb(120, 140, 160);
213- let success_color = Color32::from_rgb(80, 220, 100);
214- let warning_color = Color32::from_rgb(245, 166, 35);
215- let info_color = Color32::from_rgb(130, 170, 250);
216- // Match theme colors from app.rs: bg_secondary = (46, 52, 72), bg_tertiary = (56, 62, 80)
217- let bg_normal = Color32::from_rgb(46, 52, 72);
218- let bg_selected = Color32::from_rgb(56, 62, 80);
219- let stroke_normal = Color32::from_rgb(80, 88, 106);
220- let stroke_selected = Color32::from_rgb(130, 170, 250);
221-
222- (text_primary, text_secondary, text_dim,
223- success_color, warning_color, info_color,
224- bg_selected, bg_normal, stroke_selected, stroke_normal)
225- }
226- Theme::Light => {
227- let text_primary = Color32::from_rgb(40, 42, 54);
228- let text_secondary = Color32::from_rgb(80, 90, 110);
229- let text_dim = Color32::from_rgb(120, 130, 150);
230- let success_color = Color32::from_rgb(20, 160, 80);
231- let warning_color = Color32::from_rgb(210, 120, 0);
232- let info_color = Color32::from_rgb(60, 100, 220);
233- // Match theme colors from app.rs: bg_secondary = (220, 230, 245), bg_tertiary = (200, 220, 240)
234- let bg_normal = Color32::from_rgb(220, 230, 245);
235- let bg_selected = Color32::from_rgb(200, 220, 240);
236- let stroke_normal = Color32::from_rgb(180, 195, 210);
237- let stroke_selected = Color32::from_rgb(100, 140, 220);
238-
239- (text_primary, text_secondary, text_dim,
240- success_color, warning_color, info_color,
241- bg_selected, bg_normal, stroke_selected, stroke_normal)
242- }
243- }
244-}
245-
246252 /// Synchronizes a single feed asynchronously
247253 fn sync_feed_async(state: &mut AppState, index: usize) {
248254 if index >= state.feeds.len() {
@@ -250,13 +256,18 @@ fn sync_feed_async(state: &mut AppState, index: usize) {
250256 return;
251257 }
252258
253- log::info!("Starting asynchronous synchronization of feed: {}", state.feeds[index].name);
254-
259+ log::info!(
260+ "Starting asynchronous synchronization of feed: {}",
261+ state.feeds[index].name
262+ );
263+
255264 // Clone the feed for async task
256265 let feed_to_sync = state.feeds[index].clone();
257-
266+
258267 // Start async sync
259268 state.sync_task_manager.start_sync_feed(feed_to_sync);
260-
261- state.notification_manager.info(format!("Synchronisation du flux en cours: {}", state.feeds[index].name));
269+
270+ state
271+ .notification_manager
272+ .info(format!("Synchronisation du flux en cours: {}", state.feeds[index].name));
262273 }
@@ -2,29 +2,45 @@
2 //!2 //!
3 //! This module contains the feed list display and interaction logic.3 //! This module contains the feed list display and interaction logic.
4 4
5-use crate::config::{AppState, Theme};5+use crate::config::AppState;
6-use egui::{Align, Color32, Layout, RichText, ScrollArea, Ui};6+use crate::ui::theme::ThemeColors;
7+use egui::{Align, Layout, RichText, Ui};
7 8
8 /// Draws the feed list9 /// Draws the feed list
9 pub fn draw_feed_list(state: &mut AppState, ui: &mut Ui) {10 pub fn draw_feed_list(state: &mut AppState, ui: &mut Ui) {
10 // Apply filters11 // Apply filters
11 let filtered_indices = crate::ui::filter_panel::filter_feeds(&state.feeds, state);12 let filtered_indices = crate::ui::filter_panel::filter_feeds(&state.feeds, state);
12- 13+
14+ let searching = !state.search_query.is_empty();
15+ if searching {
16+ ui.label(
17+ RichText::new(format!(
18+ "{} flux trouvé(s) pour \"{}\"",
19+ filtered_indices.len(),
20+ state.search_query.query.trim()
21+ ))
22+ .weak(),
23+ );
24+ }
25+
13 if filtered_indices.is_empty() {26 if filtered_indices.is_empty() {
14- ui.label("Aucun flux correspondant aux filtres.");27+ if searching {
28+ ui.label("Aucun flux correspondant à la recherche.");
29+ } else {
30+ ui.label("Aucun flux correspondant aux filtres.");
31+ }
15 return;32 return;
16 }33 }
17 34
18- ScrollArea::vertical().show(ui, |ui| {35+ for &index in &filtered_indices {
19- for &index in &filtered_indices {36+ draw_feed_item(state, ui, index);
20- draw_feed_item(state, ui, index);37+ }
21- }
22- });
23 }38 }
24 39
25-/// Draws a single feed item with improved UI40+/// Draws a single feed item
26 fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) {41 fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) {
27 let feed = &state.feeds[index];42 let feed = &state.feeds[index];
43+ let feed_id = feed.id.clone();
28 let feed_name = feed.name.clone();44 let feed_name = feed.name.clone();
29 let feed_url = feed.url.clone();45 let feed_url = feed.url.clone();
30 let feed_path = feed.download_path.to_string_lossy().to_string();46 let feed_path = feed.download_path.to_string_lossy().to_string();
@@ -34,160 +50,195 @@ fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) {
34 let feed_downloaded_count = feed.downloaded_episodes_count;50 let feed_downloaded_count = feed.downloaded_episodes_count;
35 let feed_last_downloaded_name = feed.last_downloaded_episode_name.clone();51 let feed_last_downloaded_name = feed.last_downloaded_episode_name.clone();
36 let feed_last_downloaded_date = feed.last_downloaded_episode_date;52 let feed_last_downloaded_date = feed.last_downloaded_episode_date;
37- let is_selected = state.current_feed == Some(index);53+ let feed_sync_status = state
38- let theme = state.get_theme();54+ .sync_task_manager
39- 55+ .status
40- // Theme-aware colors56+ .feed_sync_status
41- let (text_primary, text_secondary, text_dim, 57+ .get(&feed_id)
42- success_color, warning_color, info_color, 58+ .cloned();
43- bg_selected, bg_normal, stroke_selected, stroke_normal) = 59+ let colors = ThemeColors::for_theme(state.get_theme());
44- get_theme_colors(&theme, is_selected);
45-
46- let bg_color = if is_selected { bg_selected } else { bg_normal };
47- let stroke_color = if is_selected { stroke_selected } else { stroke_normal };
48 60
49 // Frame avec style amélioré61 // Frame avec style amélioré
50 egui::Frame {62 egui::Frame {
51- fill: bg_color,63+ fill: colors.bg_secondary,
52- stroke: egui::Stroke::new(1.0, stroke_color),64+ stroke: egui::Stroke::new(1.0, colors.border),
53 corner_radius: 6.0.into(),65 corner_radius: 6.0.into(),
54 inner_margin: egui::Margin::symmetric(12, 8),66 inner_margin: egui::Margin::symmetric(12, 8),
55 ..Default::default()67 ..Default::default()
56 }68 }
57 .show(ui, |ui| {69 .show(ui, |ui| {
58 ui.spacing_mut().item_spacing = egui::Vec2::new(16.0, 8.0);70 ui.spacing_mut().item_spacing = egui::Vec2::new(16.0, 8.0);
59- 71+
60 ui.horizontal(|ui| {72 ui.horizontal(|ui| {
61 // Section info principale73 // Section info principale
62 ui.vertical(|ui| {74 ui.vertical(|ui| {
63 // Nom du flux (plus gros et en gras)75 // Nom du flux (plus gros et en gras)
64- ui.label(RichText::new(&feed_name)76+ ui.label(
65- .size(16.0)77+ RichText::new(&feed_name)
66- .strong()78+ .size(16.0)
67- .color(text_primary));79+ .strong()
68- 80+ .color(colors.text_primary),
81+ );
82+
69 // URL (plus petite)83 // URL (plus petite)
70- ui.label(RichText::new(format!("🔗 {}", feed_url))84+ ui.label(
71- .size(12.0)85+ RichText::new(format!("🔗 {}", feed_url))
72- .color(text_secondary));86+ .size(12.0)
73- 87+ .color(colors.text_secondary),
88+ );
89+
74 // Dossier90 // Dossier
75- ui.label(RichText::new(format!("📁 {}", feed_path))91+ ui.label(
76- .size(12.0)92+ RichText::new(format!("📁 {}", feed_path))
77- .color(text_secondary));93+ .size(12.0)
78- 94+ .color(colors.text_secondary),
95+ );
96+
79 // Tags97 // Tags
80 if !feed_tags.is_empty() {98 if !feed_tags.is_empty() {
81 ui.horizontal_wrapped(|ui| {99 ui.horizontal_wrapped(|ui| {
82- ui.label(RichText::new("🏷️ ").size(12.0).color(text_dim));100+ ui.label(RichText::new("🏷️ ").size(12.0).color(colors.text_dim));
83 for tag in &feed_tags {101 for tag in &feed_tags {
84- ui.label(RichText::new(format!("#{} ", tag))102+ ui.label(
85- .size(11.0)103+ RichText::new(format!("#{} ", tag))
86- .color(info_color));104+ .size(11.0)
105+ .color(colors.accent),
106+ );
87 }107 }
88 });108 });
89 }109 }
90 });110 });
91- 111+
92 // Section statut et statistiques112 // Section statut et statistiques
93 ui.vertical(|ui| {113 ui.vertical(|ui| {
94 ui.add_space(4.0);114 ui.add_space(4.0);
95- 115+
96 // Statut Activé/Désactivé116 // Statut Activé/Désactivé
97- let status_text = if feed_enabled { 117+ let status_text = if feed_enabled {
98- RichText::new("✅ Activé").color(success_color)118+ RichText::new("✅ Activé").color(colors.success)
99 } else {119 } else {
100- RichText::new("❌ Désactivé").color(warning_color)120+ RichText::new("❌ Désactivé").color(colors.warning)
101 };121 };
102 ui.label(status_text.size(12.0));122 ui.label(status_text.size(12.0));
103- 123+
104- // Dernière vérification124+ // Statut de synchronisation de ce flux
105- if let Some(date) = feed_last_checked {125+ match feed_sync_status {
106- ui.label(RichText::new(format!("🔍 Vérifié: {}", date.format("%d/%m %H:%M")))126+ Some(crate::config::sync_task::FeedSyncStatus::InProgress) => {
107- .size(11.0)127+ ui.label(RichText::new("⏳ Synchronisation…").size(11.0).color(colors.accent));
108- .color(text_secondary));128+ }
109- } else {129+ Some(crate::config::sync_task::FeedSyncStatus::Failed { error }) => {
110- ui.label(RichText::new("🔍 Jamais vérifié").size(11.0).color(text_dim));130+ ui.label(
131+ RichText::new("⚠️ Échec de synchro")
132+ .size(11.0)
133+ .color(colors.error),
134+ )
135+ .on_hover_text(&error);
136+ }
137+ _ => {
138+ // Dernière vérification
139+ if let Some(date) = feed_last_checked {
140+ ui.label(
141+ RichText::new(format!("🔍 Vérifié: {}", date.format("%d/%m %H:%M")))
142+ .size(11.0)
143+ .color(colors.text_secondary),
144+ );
145+ } else {
146+ ui.label(
147+ RichText::new("🔍 Jamais vérifié")
148+ .size(11.0)
149+ .color(colors.text_dim),
150+ );
151+ }
152+ }
111 }153 }
112- 154+
113 // Séparateur visuel155 // Séparateur visuel
114 ui.add_space(6.0);156 ui.add_space(6.0);
115 ui.separator();157 ui.separator();
116 ui.add_space(4.0);158 ui.add_space(4.0);
117- 159+
118 // Section Statistiques de téléchargement (mise en évidence)160 // Section Statistiques de téléchargement (mise en évidence)
119- ui.label(RichText::new("📊 Téléchargements").size(11.0).strong().color(info_color))161+ ui.label(
120- .on_hover_text("Nombre et dernier épisode téléchargé");162+ RichText::new("📊 Téléchargements")
121- 163+ .size(11.0)
164+ .strong()
165+ .color(colors.accent),
166+ )
167+ .on_hover_text("Nombre et dernier épisode téléchargé");
168+
122 // Nombre de téléchargements169 // Nombre de téléchargements
123 if feed_downloaded_count > 0 {170 if feed_downloaded_count > 0 {
124- ui.label(RichText::new(format!("📥 {} épisodes", feed_downloaded_count))171+ ui.label(
125- .size(11.0)172+ RichText::new(format!("📥 {} épisodes", feed_downloaded_count))
126- .color(success_color));173+ .size(11.0)
174+ .color(colors.success),
175+ );
127 } else {176 } else {
128- ui.label(RichText::new("📥 Aucun téléchargement").size(11.0).color(text_dim));177+ ui.label(
178+ RichText::new("📥 Aucun téléchargement")
179+ .size(11.0)
180+ .color(colors.text_dim),
181+ );
129 }182 }
130- 183+
131 // Dernier épisode téléchargé184 // Dernier épisode téléchargé
132 if let Some(last_name) = feed_last_downloaded_name {185 if let Some(last_name) = feed_last_downloaded_name {
133- if let Some(last_date) = feed_last_downloaded_date {186+ let display_name = crate::ui::truncate(&last_name, 25);
134- // Tronquer le nom si trop long187+ ui.label(
135- let display_name = if last_name.len() > 25 {188+ RichText::new(format!("🎵 {}", display_name))
136- format!("{}...", &last_name[..22])
137- } else {
138- last_name
139- };
140- ui.label(RichText::new(format!("🎵 {}", display_name))
141- .size(11.0)
142- .color(info_color));
143- ui.label(RichText::new(format!(" le {}", last_date.format("%d/%m %H:%M")))
144- .size(10.0)
145- .color(text_dim));
146- } else {
147- let display_name = if last_name.len() > 25 {
148- format!("{}...", &last_name[..22])
149- } else {
150- last_name
151- };
152- ui.label(RichText::new(format!("🎵 {}", display_name))
153 .size(11.0)189 .size(11.0)
154- .color(info_color));190+ .color(colors.accent),
191+ );
192+ if let Some(last_date) = feed_last_downloaded_date {
193+ ui.label(
194+ RichText::new(format!(" le {}", last_date.format("%d/%m %H:%M")))
195+ .size(10.0)
196+ .color(colors.text_dim),
197+ );
155 }198 }
156 }199 }
157 });200 });
158- 201+
159 // Section actions (à droite)202 // Section actions (à droite)
160 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {203 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
161 ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);204 ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
162- 205+
206+ // Bouton Détails
207+ if ui
208+ .button(RichText::new("📖").size(18.0))
209+ .on_hover_text("Voir les épisodes de ce flux")
210+ .clicked()
211+ {
212+ state.open_feed_detail(feed_id.clone());
213+ }
214+
163 // Bouton Synchroniser215 // Bouton Synchroniser
164- if ui.button(RichText::new("🔄").size(18.0))216+ if ui
217+ .button(RichText::new("🔄").size(18.0))
165 .on_hover_text("Synchroniser ce flux")218 .on_hover_text("Synchroniser ce flux")
166- .clicked() {219+ .clicked()
220+ {
167 sync_feed_async(state, index);221 sync_feed_async(state, index);
168 }222 }
169- 223+
170 // Bouton Éditer224 // Bouton Éditer
171- if ui.button(RichText::new("✏️").size(18.0))225+ if ui
226+ .button(RichText::new("✏️").size(18.0))
172 .on_hover_text("Éditer ce flux")227 .on_hover_text("Éditer ce flux")
173- .clicked() {228+ .clicked()
229+ {
174 state.editing_feed_index = Some(index);230 state.editing_feed_index = Some(index);
175 state.load_edit_feed_form(index);231 state.load_edit_feed_form(index);
176 state.show_edit_feed_dialog = true;232 state.show_edit_feed_dialog = true;
177 }233 }
178- 234+
179- // Bouton Supprimer235+ // Bouton Supprimer (avec confirmation)
180- if ui.button(RichText::new("🗑️").size(18.0))236+ if ui
237+ .button(RichText::new("🗑️").size(18.0))
181 .on_hover_text("Supprimer ce flux")238 .on_hover_text("Supprimer ce flux")
182- .clicked() {239+ .clicked()
183- log::info!("Deleting feed at index {}: {}", index, feed_name);240+ {
184- state.feeds.remove(index);241+ state.confirm_delete_feed_id = Some(feed_id.clone());
185- super::super::config::storage::save_feeds_default(&state.feeds).ok();
186- log::info!("Feed deleted successfully");
187- state.notification_manager.success("Flux supprimé");
188- if state.current_feed == Some(index) {
189- state.select_feed(None);
190- }
191 }242 }
192 });243 });
193 });244 });
@@ -198,51 +249,6 @@ fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) {
198 ui.separator();249 ui.separator();
199 }250 }
200 251
201-/// Returns theme-appropriate colors for feed list items
202-/// Colors are aligned with the main theme colors from app.rs
203-fn get_theme_colors(theme: &Theme, _is_selected: bool) -> (
204- Color32, Color32, Color32, // text_primary, text_secondary, text_dim
205- Color32, Color32, Color32, // success_color, warning_color, info_color
206- Color32, Color32, Color32, Color32 // bg_selected, bg_normal, stroke_selected, stroke_normal
207-) {
208- match theme {
209- Theme::Dark => {
210- let text_primary = Color32::from_rgb(220, 230, 245);
211- let text_secondary = Color32::from_rgb(180, 195, 210);
212- let text_dim = Color32::from_rgb(120, 140, 160);
213- let success_color = Color32::from_rgb(80, 220, 100);
214- let warning_color = Color32::from_rgb(245, 166, 35);
215- let info_color = Color32::from_rgb(130, 170, 250);
216- // Match theme colors from app.rs: bg_secondary = (46, 52, 72), bg_tertiary = (56, 62, 80)
217- let bg_normal = Color32::from_rgb(46, 52, 72);
218- let bg_selected = Color32::from_rgb(56, 62, 80);
219- let stroke_normal = Color32::from_rgb(80, 88, 106);
220- let stroke_selected = Color32::from_rgb(130, 170, 250);
221-
222- (text_primary, text_secondary, text_dim,
223- success_color, warning_color, info_color,
224- bg_selected, bg_normal, stroke_selected, stroke_normal)
225- }
226- Theme::Light => {
227- let text_primary = Color32::from_rgb(40, 42, 54);
228- let text_secondary = Color32::from_rgb(80, 90, 110);
229- let text_dim = Color32::from_rgb(120, 130, 150);
230- let success_color = Color32::from_rgb(20, 160, 80);
231- let warning_color = Color32::from_rgb(210, 120, 0);
232- let info_color = Color32::from_rgb(60, 100, 220);
233- // Match theme colors from app.rs: bg_secondary = (220, 230, 245), bg_tertiary = (200, 220, 240)
234- let bg_normal = Color32::from_rgb(220, 230, 245);
235- let bg_selected = Color32::from_rgb(200, 220, 240);
236- let stroke_normal = Color32::from_rgb(180, 195, 210);
237- let stroke_selected = Color32::from_rgb(100, 140, 220);
238-
239- (text_primary, text_secondary, text_dim,
240- success_color, warning_color, info_color,
241- bg_selected, bg_normal, stroke_selected, stroke_normal)
242- }
243- }
244-}
245-
246 /// Synchronizes a single feed asynchronously252 /// Synchronizes a single feed asynchronously
247 fn sync_feed_async(state: &mut AppState, index: usize) {253 fn sync_feed_async(state: &mut AppState, index: usize) {
248 if index >= state.feeds.len() {254 if index >= state.feeds.len() {
@@ -250,13 +256,18 @@ fn sync_feed_async(state: &mut AppState, index: usize) {
250 return;256 return;
251 }257 }
252 258
253- log::info!("Starting asynchronous synchronization of feed: {}", state.feeds[index].name);259+ log::info!(
254- 260+ "Starting asynchronous synchronization of feed: {}",
261+ state.feeds[index].name
262+ );
263+
255 // Clone the feed for async task264 // Clone the feed for async task
256 let feed_to_sync = state.feeds[index].clone();265 let feed_to_sync = state.feeds[index].clone();
257- 266+
258 // Start async sync267 // Start async sync
259 state.sync_task_manager.start_sync_feed(feed_to_sync);268 state.sync_task_manager.start_sync_feed(feed_to_sync);
260- 269+
261- state.notification_manager.info(format!("Synchronisation du flux en cours: {}", state.feeds[index].name));270+ state
271+ .notification_manager
272+ .info(format!("Synchronisation du flux en cours: {}", state.feeds[index].name));
262 }273 }
modified src/ui/filter_panel.rs +80 -55
@@ -10,51 +10,46 @@ use egui::Ui;
1010 pub fn draw_filter_panel(state: &mut AppState, ui: &mut Ui) {
1111 ui.horizontal(|ui| {
1212 ui.label("Filtres:");
13-
13+
1414 // Date filter
1515 ui.label("Date de publication:");
16-
16+
1717 // Date from filter
1818 if let Some(date_from) = state.filter_date_from {
19- if ui.button(format!("Depuis: {}", date_from.format("%Y-%m-%d"))).clicked() {
20- state.show_date_picker_from = true;
21- }
22- } else {
23- if ui.button("Depuis...").clicked() {
19+ if ui
20+ .button(format!("Depuis: {}", date_from.format("%Y-%m-%d")))
21+ .clicked()
22+ {
2423 state.show_date_picker_from = true;
2524 }
25+ } else if ui.button("Depuis...").clicked() {
26+ state.show_date_picker_from = true;
2627 }
27-
28+
2829 ui.add_space(5.0);
29-
30+
3031 // Date to filter
3132 if let Some(date_to) = state.filter_date_to {
32- if ui.button(format!("Jusqu'à: {}", date_to.format("%Y-%m-%d"))).clicked() {
33- state.show_date_picker_to = true;
34- }
35- } else {
36- if ui.button("Jusqu'à...").clicked() {
33+ if ui
34+ .button(format!("Jusqu'à: {}", date_to.format("%Y-%m-%d")))
35+ .clicked()
36+ {
3737 state.show_date_picker_to = true;
3838 }
39+ } else if ui.button("Jusqu'à...").clicked() {
40+ state.show_date_picker_to = true;
3941 }
40-
41- ui.add_space(10.0);
42-
43- // Episode number filter
44- ui.label("Numéro d'épisode:");
45- ui.add(egui::TextEdit::singleline(&mut state.filter_episode_number)
46- .hint_text("ex: 10"));
47-
42+
4843 ui.add_space(10.0);
49-
50- // Clear filters button
51- if ui.button("Effacer les filtres").clicked() {
44+
45+ // Clear filters button (only when a filter is active)
46+ if (state.filter_date_from.is_some() || state.filter_date_to.is_some())
47+ && ui.button("Effacer les filtres").clicked()
48+ {
5249 state.clear_filters();
5350 }
5451 });
55-
56- ui.separator();
57-
52+
5853 // Draw date pickers
5954 draw_date_picker_from(state, ui);
6055 draw_date_picker_to(state, ui);
@@ -65,43 +60,55 @@ fn draw_date_picker_from(state: &mut AppState, ui: &mut Ui) {
6560 if !state.show_date_picker_from {
6661 return;
6762 }
68-
63+
6964 egui::Window::new("Sélectionner la date de début")
7065 .resizable(false)
7166 .show(ui.ctx(), |ui| {
7267 ui.vertical(|ui| {
7368 ui.label("Date de début (yyyy-mm-dd):");
74-
75- let mut date_str = state.filter_date_from
69+
70+ let mut date_str = state
71+ .filter_date_from
7672 .map(|d| d.format("%Y-%m-%d").to_string())
7773 .unwrap_or_default();
78-
79- if ui.text_edit_singleline(&mut date_str).changed() {
80- if let Ok(date) = chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") {
81- state.filter_date_from = Some(DateTime::<Utc>::from_naive_utc_and_offset(date.and_hms_opt(0, 0, 0).unwrap(), Utc));
74+
75+ let response = ui.text_edit_singleline(&mut date_str);
76+ if response.changed() {
77+ if let Ok(date) =
78+ chrono::NaiveDate::parse_from_str(date_str.trim(), "%Y-%m-%d")
79+ {
80+ state.filter_date_from = Some(DateTime::<Utc>::from_naive_utc_and_offset(
81+ date.and_hms_opt(0, 0, 0).unwrap(),
82+ Utc,
83+ ));
8284 state.show_date_picker_from = false;
8385 }
8486 }
85-
87+ if response.lost_focus()
88+ && ui.input(|i| i.key_pressed(egui::Key::Escape))
89+ {
90+ state.show_date_picker_from = false;
91+ }
92+
8693 ui.separator();
87-
94+
8895 ui.horizontal(|ui| {
8996 // Quick date buttons
9097 if ui.button("Aujourd'hui").clicked() {
9198 state.filter_date_from = Some(Utc::now());
9299 state.show_date_picker_from = false;
93100 }
94-
101+
95102 if ui.button("7 derniers jours").clicked() {
96103 state.filter_date_from = Some(Utc::now() - chrono::Duration::days(7));
97104 state.show_date_picker_from = false;
98105 }
99-
106+
100107 if ui.button("30 derniers jours").clicked() {
101108 state.filter_date_from = Some(Utc::now() - chrono::Duration::days(30));
102109 state.show_date_picker_from = false;
103110 }
104-
111+
105112 if ui.button("Annuler").clicked() {
106113 state.show_date_picker_from = false;
107114 }
@@ -115,39 +122,51 @@ fn draw_date_picker_to(state: &mut AppState, ui: &mut Ui) {
115122 if !state.show_date_picker_to {
116123 return;
117124 }
118-
125+
119126 egui::Window::new("Sélectionner la date de fin")
120127 .resizable(false)
121128 .show(ui.ctx(), |ui| {
122129 ui.vertical(|ui| {
123130 ui.label("Date de fin (yyyy-mm-dd):");
124-
125- let mut date_str = state.filter_date_to
131+
132+ let mut date_str = state
133+ .filter_date_to
126134 .map(|d| d.format("%Y-%m-%d").to_string())
127135 .unwrap_or_default();
128-
129- if ui.text_edit_singleline(&mut date_str).changed() {
130- if let Ok(date) = chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") {
131- state.filter_date_to = Some(DateTime::<Utc>::from_naive_utc_and_offset(date.and_hms_opt(23, 59, 59).unwrap(), Utc));
136+
137+ let response = ui.text_edit_singleline(&mut date_str);
138+ if response.changed() {
139+ if let Ok(date) =
140+ chrono::NaiveDate::parse_from_str(date_str.trim(), "%Y-%m-%d")
141+ {
142+ state.filter_date_to = Some(DateTime::<Utc>::from_naive_utc_and_offset(
143+ date.and_hms_opt(23, 59, 59).unwrap(),
144+ Utc,
145+ ));
132146 state.show_date_picker_to = false;
133147 }
134148 }
135-
149+ if response.lost_focus()
150+ && ui.input(|i| i.key_pressed(egui::Key::Escape))
151+ {
152+ state.show_date_picker_to = false;
153+ }
154+
136155 ui.separator();
137-
156+
138157 ui.horizontal(|ui| {
139158 // Quick date buttons
140159 if ui.button("Aujourd'hui").clicked() {
141160 state.filter_date_to = Some(Utc::now());
142161 state.show_date_picker_to = false;
143162 }
144-
163+
145164 if ui.button("7 derniers jours").clicked() {
146165 state.filter_date_to = Some(Utc::now());
147166 state.filter_date_from = Some(Utc::now() - chrono::Duration::days(7));
148167 state.show_date_picker_to = false;
149168 }
150-
169+
151170 if ui.button("Annuler").clicked() {
152171 state.show_date_picker_to = false;
153172 }
@@ -156,11 +175,17 @@ fn draw_date_picker_to(state: &mut AppState, ui: &mut Ui) {
156175 });
157176 }
158177
159-/// Filters feeds based on current filter criteria
178+/// Filters feeds based on current filter criteria (dates + search)
160179 pub fn filter_feeds(feeds: &[crate::config::feed::Feed], state: &AppState) -> Vec<usize> {
161- feeds.iter()
180+ feeds
181+ .iter()
162182 .enumerate()
163183 .filter(|(_, feed)| {
184+ // Search filter (name / url / tags)
185+ if !crate::ui::search_bar::feed_matches_search(state, feed) {
186+ return false;
187+ }
188+
164189 // Check date filter
165190 if let Some(date_from) = state.filter_date_from {
166191 if let Some(last_episode) = feed.last_episode_date {
@@ -172,7 +197,7 @@ pub fn filter_feeds(feeds: &[crate::config::feed::Feed], state: &AppState) -> Ve
172197 return false;
173198 }
174199 }
175-
200+
176201 if let Some(date_to) = state.filter_date_to {
177202 if let Some(last_episode) = feed.last_episode_date {
178203 if last_episode > date_to {
@@ -183,7 +208,7 @@ pub fn filter_feeds(feeds: &[crate::config::feed::Feed], state: &AppState) -> Ve
183208 return false;
184209 }
185210 }
186-
211+
187212 true
188213 })
189214 .map(|(i, _)| i)
@@ -10,51 +10,46 @@ use egui::Ui;
10 pub fn draw_filter_panel(state: &mut AppState, ui: &mut Ui) {10 pub fn draw_filter_panel(state: &mut AppState, ui: &mut Ui) {
11 ui.horizontal(|ui| {11 ui.horizontal(|ui| {
12 ui.label("Filtres:");12 ui.label("Filtres:");
13- 13+
14 // Date filter14 // Date filter
15 ui.label("Date de publication:");15 ui.label("Date de publication:");
16- 16+
17 // Date from filter17 // Date from filter
18 if let Some(date_from) = state.filter_date_from {18 if let Some(date_from) = state.filter_date_from {
19- if ui.button(format!("Depuis: {}", date_from.format("%Y-%m-%d"))).clicked() {19+ if ui
20- state.show_date_picker_from = true;20+ .button(format!("Depuis: {}", date_from.format("%Y-%m-%d")))
21- }21+ .clicked()
22- } else {22+ {
23- if ui.button("Depuis...").clicked() {
24 state.show_date_picker_from = true;23 state.show_date_picker_from = true;
25 }24 }
25+ } else if ui.button("Depuis...").clicked() {
26+ state.show_date_picker_from = true;
26 }27 }
27- 28+
28 ui.add_space(5.0);29 ui.add_space(5.0);
29- 30+
30 // Date to filter31 // Date to filter
31 if let Some(date_to) = state.filter_date_to {32 if let Some(date_to) = state.filter_date_to {
32- if ui.button(format!("Jusqu'à: {}", date_to.format("%Y-%m-%d"))).clicked() {33+ if ui
33- state.show_date_picker_to = true;34+ .button(format!("Jusqu'à: {}", date_to.format("%Y-%m-%d")))
34- }35+ .clicked()
35- } else {36+ {
36- if ui.button("Jusqu'à...").clicked() {
37 state.show_date_picker_to = true;37 state.show_date_picker_to = true;
38 }38 }
39+ } else if ui.button("Jusqu'à...").clicked() {
40+ state.show_date_picker_to = true;
39 }41 }
40- 42+
41- ui.add_space(10.0);
42-
43- // Episode number filter
44- ui.label("Numéro d'épisode:");
45- ui.add(egui::TextEdit::singleline(&mut state.filter_episode_number)
46- .hint_text("ex: 10"));
47-
48 ui.add_space(10.0);43 ui.add_space(10.0);
49- 44+
50- // Clear filters button45+ // Clear filters button (only when a filter is active)
51- if ui.button("Effacer les filtres").clicked() {46+ if (state.filter_date_from.is_some() || state.filter_date_to.is_some())
47+ && ui.button("Effacer les filtres").clicked()
48+ {
52 state.clear_filters();49 state.clear_filters();
53 }50 }
54 });51 });
55- 52+
56- ui.separator();
57-
58 // Draw date pickers53 // Draw date pickers
59 draw_date_picker_from(state, ui);54 draw_date_picker_from(state, ui);
60 draw_date_picker_to(state, ui);55 draw_date_picker_to(state, ui);
@@ -65,43 +60,55 @@ fn draw_date_picker_from(state: &mut AppState, ui: &mut Ui) {
65 if !state.show_date_picker_from {60 if !state.show_date_picker_from {
66 return;61 return;
67 }62 }
68- 63+
69 egui::Window::new("Sélectionner la date de début")64 egui::Window::new("Sélectionner la date de début")
70 .resizable(false)65 .resizable(false)
71 .show(ui.ctx(), |ui| {66 .show(ui.ctx(), |ui| {
72 ui.vertical(|ui| {67 ui.vertical(|ui| {
73 ui.label("Date de début (yyyy-mm-dd):");68 ui.label("Date de début (yyyy-mm-dd):");
74- 69+
75- let mut date_str = state.filter_date_from70+ let mut date_str = state
71+ .filter_date_from
76 .map(|d| d.format("%Y-%m-%d").to_string())72 .map(|d| d.format("%Y-%m-%d").to_string())
77 .unwrap_or_default();73 .unwrap_or_default();
78- 74+
79- if ui.text_edit_singleline(&mut date_str).changed() {75+ let response = ui.text_edit_singleline(&mut date_str);
80- if let Ok(date) = chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") {76+ if response.changed() {
81- state.filter_date_from = Some(DateTime::<Utc>::from_naive_utc_and_offset(date.and_hms_opt(0, 0, 0).unwrap(), Utc));77+ if let Ok(date) =
78+ chrono::NaiveDate::parse_from_str(date_str.trim(), "%Y-%m-%d")
79+ {
80+ state.filter_date_from = Some(DateTime::<Utc>::from_naive_utc_and_offset(
81+ date.and_hms_opt(0, 0, 0).unwrap(),
82+ Utc,
83+ ));
82 state.show_date_picker_from = false;84 state.show_date_picker_from = false;
83 }85 }
84 }86 }
85- 87+ if response.lost_focus()
88+ && ui.input(|i| i.key_pressed(egui::Key::Escape))
89+ {
90+ state.show_date_picker_from = false;
91+ }
92+
86 ui.separator();93 ui.separator();
87- 94+
88 ui.horizontal(|ui| {95 ui.horizontal(|ui| {
89 // Quick date buttons96 // Quick date buttons
90 if ui.button("Aujourd'hui").clicked() {97 if ui.button("Aujourd'hui").clicked() {
91 state.filter_date_from = Some(Utc::now());98 state.filter_date_from = Some(Utc::now());
92 state.show_date_picker_from = false;99 state.show_date_picker_from = false;
93 }100 }
94- 101+
95 if ui.button("7 derniers jours").clicked() {102 if ui.button("7 derniers jours").clicked() {
96 state.filter_date_from = Some(Utc::now() - chrono::Duration::days(7));103 state.filter_date_from = Some(Utc::now() - chrono::Duration::days(7));
97 state.show_date_picker_from = false;104 state.show_date_picker_from = false;
98 }105 }
99- 106+
100 if ui.button("30 derniers jours").clicked() {107 if ui.button("30 derniers jours").clicked() {
101 state.filter_date_from = Some(Utc::now() - chrono::Duration::days(30));108 state.filter_date_from = Some(Utc::now() - chrono::Duration::days(30));
102 state.show_date_picker_from = false;109 state.show_date_picker_from = false;
103 }110 }
104- 111+
105 if ui.button("Annuler").clicked() {112 if ui.button("Annuler").clicked() {
106 state.show_date_picker_from = false;113 state.show_date_picker_from = false;
107 }114 }
@@ -115,39 +122,51 @@ fn draw_date_picker_to(state: &mut AppState, ui: &mut Ui) {
115 if !state.show_date_picker_to {122 if !state.show_date_picker_to {
116 return;123 return;
117 }124 }
118- 125+
119 egui::Window::new("Sélectionner la date de fin")126 egui::Window::new("Sélectionner la date de fin")
120 .resizable(false)127 .resizable(false)
121 .show(ui.ctx(), |ui| {128 .show(ui.ctx(), |ui| {
122 ui.vertical(|ui| {129 ui.vertical(|ui| {
123 ui.label("Date de fin (yyyy-mm-dd):");130 ui.label("Date de fin (yyyy-mm-dd):");
124- 131+
125- let mut date_str = state.filter_date_to132+ let mut date_str = state
133+ .filter_date_to
126 .map(|d| d.format("%Y-%m-%d").to_string())134 .map(|d| d.format("%Y-%m-%d").to_string())
127 .unwrap_or_default();135 .unwrap_or_default();
128- 136+
129- if ui.text_edit_singleline(&mut date_str).changed() {137+ let response = ui.text_edit_singleline(&mut date_str);
130- if let Ok(date) = chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") {138+ if response.changed() {
131- state.filter_date_to = Some(DateTime::<Utc>::from_naive_utc_and_offset(date.and_hms_opt(23, 59, 59).unwrap(), Utc));139+ if let Ok(date) =
140+ chrono::NaiveDate::parse_from_str(date_str.trim(), "%Y-%m-%d")
141+ {
142+ state.filter_date_to = Some(DateTime::<Utc>::from_naive_utc_and_offset(
143+ date.and_hms_opt(23, 59, 59).unwrap(),
144+ Utc,
145+ ));
132 state.show_date_picker_to = false;146 state.show_date_picker_to = false;
133 }147 }
134 }148 }
135- 149+ if response.lost_focus()
150+ && ui.input(|i| i.key_pressed(egui::Key::Escape))
151+ {
152+ state.show_date_picker_to = false;
153+ }
154+
136 ui.separator();155 ui.separator();
137- 156+
138 ui.horizontal(|ui| {157 ui.horizontal(|ui| {
139 // Quick date buttons158 // Quick date buttons
140 if ui.button("Aujourd'hui").clicked() {159 if ui.button("Aujourd'hui").clicked() {
141 state.filter_date_to = Some(Utc::now());160 state.filter_date_to = Some(Utc::now());
142 state.show_date_picker_to = false;161 state.show_date_picker_to = false;
143 }162 }
144- 163+
145 if ui.button("7 derniers jours").clicked() {164 if ui.button("7 derniers jours").clicked() {
146 state.filter_date_to = Some(Utc::now());165 state.filter_date_to = Some(Utc::now());
147 state.filter_date_from = Some(Utc::now() - chrono::Duration::days(7));166 state.filter_date_from = Some(Utc::now() - chrono::Duration::days(7));
148 state.show_date_picker_to = false;167 state.show_date_picker_to = false;
149 }168 }
150- 169+
151 if ui.button("Annuler").clicked() {170 if ui.button("Annuler").clicked() {
152 state.show_date_picker_to = false;171 state.show_date_picker_to = false;
153 }172 }
@@ -156,11 +175,17 @@ fn draw_date_picker_to(state: &mut AppState, ui: &mut Ui) {
156 });175 });
157 }176 }
158 177
159-/// Filters feeds based on current filter criteria178+/// Filters feeds based on current filter criteria (dates + search)
160 pub fn filter_feeds(feeds: &[crate::config::feed::Feed], state: &AppState) -> Vec<usize> {179 pub fn filter_feeds(feeds: &[crate::config::feed::Feed], state: &AppState) -> Vec<usize> {
161- feeds.iter()180+ feeds
181+ .iter()
162 .enumerate()182 .enumerate()
163 .filter(|(_, feed)| {183 .filter(|(_, feed)| {
184+ // Search filter (name / url / tags)
185+ if !crate::ui::search_bar::feed_matches_search(state, feed) {
186+ return false;
187+ }
188+
164 // Check date filter189 // Check date filter
165 if let Some(date_from) = state.filter_date_from {190 if let Some(date_from) = state.filter_date_from {
166 if let Some(last_episode) = feed.last_episode_date {191 if let Some(last_episode) = feed.last_episode_date {
@@ -172,7 +197,7 @@ pub fn filter_feeds(feeds: &[crate::config::feed::Feed], state: &AppState) -> Ve
172 return false;197 return false;
173 }198 }
174 }199 }
175- 200+
176 if let Some(date_to) = state.filter_date_to {201 if let Some(date_to) = state.filter_date_to {
177 if let Some(last_episode) = feed.last_episode_date {202 if let Some(last_episode) = feed.last_episode_date {
178 if last_episode > date_to {203 if last_episode > date_to {
@@ -183,7 +208,7 @@ pub fn filter_feeds(feeds: &[crate::config::feed::Feed], state: &AppState) -> Ve
183 return false;208 return false;
184 }209 }
185 }210 }
186- 211+
187 true212 true
188 })213 })
189 .map(|(i, _)| i)214 .map(|(i, _)| i)
modified src/ui/history_panel.rs +173 -211
@@ -1,226 +1,188 @@
1-#![allow(dead_code)] // Fonctionnalité prévue dans la spec (Phase 9+), pas encore branchée dans l'UI
2-use crate::config::history::{HistoryEntry, HistoryManager, OperationStatus, OperationType};
3-use eframe::egui;
4-
5-/// Panneau d'affichage de l'historique
6-pub struct HistoryPanel {
7- /// Visibilité du panneau
8- visible: bool,
9- /// Filtre de type d'opération
10- filter_type: Option<OperationType>,
11- /// Filtre de statut
12- filter_status: Option<OperationStatus>,
13- /// Nombre d'entrées à afficher
14- entries_to_show: usize,
15-}
16-
17-impl Default for HistoryPanel {
18- fn default() -> Self {
19- Self {
20- visible: false,
21- filter_type: None,
22- filter_status: None,
23- entries_to_show: 50,
24- }
25- }
26-}
27-
28-impl HistoryPanel {
29- /// Créer un nouveau panneau d'historique
30- pub fn new() -> Self {
31- Self::default()
32- }
33-
34- /// Définir la visibilité
35- pub fn set_visible(&mut self, visible: bool) {
36- self.visible = visible;
37- }
38-
39- /// Basculer la visibilité
40- pub fn toggle(&mut self) {
41- self.visible = !self.visible;
42- }
43-
44- /// Vérifier si le panneau est visible
45- pub fn is_visible(&self) -> bool {
46- self.visible
1+//! History Panel UI component
2+//!
3+//! Fenêtre flottante affichant l'historique des opérations
4+//! (synchronisations, téléchargements, ajouts/suppressions, import/export),
5+//! avec filtres par type et par statut.
6+
7+use crate::config::history::{OperationStatus, OperationType};
8+use crate::config::AppState;
9+use crate::ui::theme::ThemeColors;
10+use crate::ui::truncate;
11+use egui::Ui;
12+
13+/// Draws the history window (toggled from the menu)
14+pub fn draw_history_window(state: &mut AppState, ui: &mut Ui) {
15+ if !state.show_history_window {
16+ return;
4717 }
4818
49- /// Définir le filtre de type
50- pub fn set_filter_type(&mut self, op_type: Option<OperationType>) {
51- self.filter_type = op_type;
52- }
53-
54- /// Définir le filtre de statut
55- pub fn set_filter_status(&mut self, status: Option<OperationStatus>) {
56- self.filter_status = status;
57- }
58-
59- /// Afficher le panneau d'historique
60- pub fn show(&mut self, ui: &mut egui::Ui, history: &HistoryManager) {
61- if !self.visible {
62- return;
63- }
19+ let mut open = state.show_history_window;
20+ egui::Window::new("📜 Historique")
21+ .open(&mut open)
22+ .default_size([640.0, 400.0])
23+ .show(ui.ctx(), |ui| {
24+ draw_history_content(state, ui);
25+ });
26+ state.show_history_window = open;
27+}
6428
65- egui::CollapsingHeader::new("📜 Historique")
66- .default_open(true)
67- .show(ui, |ui| {
68- self.show_content(ui, history);
29+/// Draws the history window content
30+fn draw_history_content(state: &mut AppState, ui: &mut Ui) {
31+ let colors = ThemeColors::for_theme(state.get_theme());
32+
33+ // Filtres
34+ ui.horizontal(|ui| {
35+ egui::ComboBox::from_id_salt("history_filter_type")
36+ .width(160.0)
37+ .selected_text(operation_type_label(state.history_filter_type))
38+ .show_ui(ui, |ui| {
39+ ui.selectable_value(&mut state.history_filter_type, None, "Tous les types");
40+ ui.selectable_value(
41+ &mut state.history_filter_type,
42+ Some(OperationType::SyncFeed),
43+ "Synchronisation flux",
44+ );
45+ ui.selectable_value(
46+ &mut state.history_filter_type,
47+ Some(OperationType::SyncAll),
48+ "Sync. tous flux",
49+ );
50+ ui.selectable_value(
51+ &mut state.history_filter_type,
52+ Some(OperationType::DownloadEpisode),
53+ "Téléchargement",
54+ );
55+ ui.selectable_value(
56+ &mut state.history_filter_type,
57+ Some(OperationType::AddFeed),
58+ "Ajout flux",
59+ );
60+ ui.selectable_value(
61+ &mut state.history_filter_type,
62+ Some(OperationType::RemoveFeed),
63+ "Suppr. flux",
64+ );
65+ ui.selectable_value(
66+ &mut state.history_filter_type,
67+ Some(OperationType::UpdateFeed),
68+ "Modif. flux",
69+ );
70+ ui.selectable_value(
71+ &mut state.history_filter_type,
72+ Some(OperationType::ImportFeeds),
73+ "Import flux",
74+ );
75+ ui.selectable_value(
76+ &mut state.history_filter_type,
77+ Some(OperationType::ExportFeeds),
78+ "Export flux",
79+ );
6980 });
70- }
7181
72- /// Afficher le contenu du panneau
73- fn show_content(&mut self, ui: &mut egui::Ui, history: &HistoryManager) {
74- // Contrôles de filtre
75- self.show_filters(ui);
76- ui.separator();
82+ egui::ComboBox::from_id_salt("history_filter_status")
83+ .width(120.0)
84+ .selected_text(operation_status_label(state.history_filter_status))
85+ .show_ui(ui, |ui| {
86+ ui.selectable_value(
87+ &mut state.history_filter_status,
88+ None,
89+ "Tous les statuts",
90+ );
91+ ui.selectable_value(
92+ &mut state.history_filter_status,
93+ Some(OperationStatus::Success),
94+ "Succès",
95+ );
96+ ui.selectable_value(
97+ &mut state.history_filter_status,
98+ Some(OperationStatus::Failed),
99+ "Échec",
100+ );
101+ });
77102
78- // Afficher les entrées
79- self.show_entries(ui, history);
103+ if (state.history_filter_type.is_some() || state.history_filter_status.is_some())
104+ && ui.button("Effacer les filtres").clicked()
105+ {
106+ state.history_filter_type = None;
107+ state.history_filter_status = None;
108+ }
109+ });
110+
111+ ui.separator();
112+
113+ // Entrées filtrées, de la plus récente à la plus ancienne
114+ let entries: Vec<_> = state
115+ .history_manager
116+ .get_all_entries()
117+ .into_iter()
118+ .filter(|e| {
119+ state
120+ .history_filter_type
121+ .is_none_or(|t| e.operation_type == t)
122+ })
123+ .filter(|e| {
124+ state
125+ .history_filter_status
126+ .is_none_or(|s| e.status == s)
127+ })
128+ .collect();
129+
130+ if entries.is_empty() {
131+ ui.label("Aucune entrée d'historique.");
132+ return;
80133 }
81134
82- /// Afficher les contrôles de filtre
83- fn show_filters(&mut self, ui: &mut egui::Ui) {
84- ui.horizontal(|ui| {
85- // Filtre par type
86- egui::ComboBox::from_label("Type")
87- .width(150.0)
88- .selected_text(match self.filter_type {
89- Some(t) => format!("{}", t),
90- None => "Tous les types".to_string(),
91- })
92- .show_ui(ui, |ui| {
93- ui.selectable_value(&mut self.filter_type, None, "Tous les types");
94- ui.selectable_value(&mut self.filter_type, Some(OperationType::SyncFeed), format!("{}", OperationType::SyncFeed));
95- ui.selectable_value(&mut self.filter_type, Some(OperationType::SyncAll), format!("{}", OperationType::SyncAll));
96- ui.selectable_value(&mut self.filter_type, Some(OperationType::DownloadEpisode), format!("{}", OperationType::DownloadEpisode));
97- ui.selectable_value(&mut self.filter_type, Some(OperationType::AddFeed), format!("{}", OperationType::AddFeed));
98- ui.selectable_value(&mut self.filter_type, Some(OperationType::RemoveFeed), format!("{}", OperationType::RemoveFeed));
99- ui.selectable_value(&mut self.filter_type, Some(OperationType::ImportFeeds), format!("{}", OperationType::ImportFeeds));
100- ui.selectable_value(&mut self.filter_type, Some(OperationType::ExportFeeds), format!("{}", OperationType::ExportFeeds));
101- ui.selectable_value(&mut self.filter_type, Some(OperationType::DownloadError), format!("{}", OperationType::DownloadError));
102- ui.selectable_value(&mut self.filter_type, Some(OperationType::SyncError), format!("{}", OperationType::SyncError));
103- });
104-
105- // Filtre par statut
106- egui::ComboBox::from_label("Statut")
107- .width(120.0)
108- .selected_text(match self.filter_status {
109- Some(s) => format!("{}", s),
110- None => "Tous les statuts".to_string(),
111- })
112- .show_ui(ui, |ui| {
113- ui.selectable_value(&mut self.filter_status, None, "Tous les statuts");
114- ui.selectable_value(&mut self.filter_status, Some(OperationStatus::InProgress), format!("{}", OperationStatus::InProgress));
115- ui.selectable_value(&mut self.filter_status, Some(OperationStatus::Success), format!("{}", OperationStatus::Success));
116- ui.selectable_value(&mut self.filter_status, Some(OperationStatus::Failed), format!("{}", OperationStatus::Failed));
117- });
118-
119- // Bouton pour effacer les filtres
120- if (self.filter_type.is_some() || self.filter_status.is_some())
121- && ui.button("Effacer filtres").clicked() {
122- self.filter_type = None;
123- self.filter_status = None;
135+ egui::ScrollArea::vertical().show(ui, |ui| {
136+ egui::Grid::new("history_grid")
137+ .striped(true)
138+ .show(ui, |ui| {
139+ ui.strong("Date/Heure");
140+ ui.strong("Type");
141+ ui.strong("Description");
142+ ui.strong("Statut");
143+ ui.end_row();
144+
145+ for entry in entries.iter().rev() {
146+ ui.label(entry.timestamp.format("%d/%m/%Y %H:%M").to_string());
147+ ui.label(entry.operation_type.to_string());
148+ ui.label(truncate(&entry.description, 50))
149+ .on_hover_text(&entry.description);
150+ let (status_text, status_color) = match entry.status {
151+ OperationStatus::Success => ("Succès", colors.success),
152+ OperationStatus::Failed => ("Échec", colors.error),
153+ OperationStatus::InProgress => ("En cours", colors.warning),
154+ };
155+ let label = ui.colored_label(status_color, status_text);
156+ if let Some(error) = &entry.error_message {
157+ label.on_hover_text(error);
158+ }
159+ ui.end_row();
124160 }
125- });
126- }
127-
128- /// Afficher les entrées d'historique
129- fn show_entries(&mut self, ui: &mut egui::Ui, history: &HistoryManager) {
130- let mut entries: Vec<&HistoryEntry> = history.get_all_entries();
131-
132- // Appliquer les filtres
133- if let Some(op_type) = self.filter_type {
134- entries.retain(|e| e.operation_type == op_type);
135- }
136- if let Some(status) = self.filter_status {
137- entries.retain(|e| e.status == status);
138- }
139-
140- // Limiter le nombre d'entrées affichées
141- entries.truncate(self.entries_to_show);
142-
143- if entries.is_empty() {
144- ui.label("Aucune entrée d'historique trouvée");
145- return;
161+ });
162+ });
163+
164+ ui.separator();
165+ ui.horizontal(|ui| {
166+ ui.label(format!("Total : {} entrées", state.history_manager.len()));
167+ if ui.button("Effacer l'historique").clicked() {
168+ state.history_manager.clear();
169+ state.notification_manager.info("Historique effacé");
146170 }
171+ });
172+}
147173
148- // Tableau des entrées
149- egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
150- egui::Grid::new("history_grid")
151- .striped(true)
152- .show(ui, |ui| {
153- // En-têtes
154- ui.strong("Date/Heure");
155- ui.strong("Type");
156- ui.strong("Description");
157- ui.strong("Statut");
158- ui.strong("Durée/Taille");
159- ui.end_row();
160-
161- // Lignes de données (afficher de la plus récente à la plus ancienne)
162- for entry in entries.iter().rev() {
163- ui.label(format!("{}", entry.timestamp.format("%Y-%m-%d %H:%M:%S")));
164- ui.label(format!("{}", entry.operation_type));
165-
166- // Limiter la longueur de la description
167- let desc = if entry.description.len() > 40 {
168- format!("{}...", &entry.description[..37])
169- } else {
170- entry.description.clone()
171- };
172- ui.label(desc);
173-
174- // Afficher le statut avec une couleur
175- let status_text = format!("{}", entry.status);
176- let color = match entry.status {
177- OperationStatus::Success => egui::Color32::GREEN,
178- OperationStatus::Failed => egui::Color32::RED,
179- OperationStatus::InProgress => egui::Color32::YELLOW,
180- };
181- ui.colored_label(color, &status_text);
182-
183- // Afficher durée ou taille
184- let info = if entry.duration_seconds.is_some() {
185- entry.get_formatted_duration()
186- } else {
187- entry.get_formatted_size()
188- };
189- ui.label(info);
190- ui.end_row();
191- }
192- });
193- });
194-
195- // Statistiques de l'historique
196- ui.separator();
197- ui.horizontal(|ui| {
198- ui.label(format!("Total: {} entrées", history.len()));
199- if ui.button("Effacer historique").clicked() {
200- // L'appelant doit effacer l'historique
201- }
202- });
174+/// Libellé du filtre par type d'opération
175+fn operation_type_label(filter: Option<OperationType>) -> String {
176+ match filter {
177+ None => "Tous les types".to_string(),
178+ Some(t) => t.to_string(),
203179 }
180+}
204181
205- /// Afficher une version simplifiée (dernières opérations)
206- pub fn show_recent(ui: &mut egui::Ui, history: &HistoryManager, count: usize) {
207- let entries = history.get_recent_entries(count);
208-
209- if entries.is_empty() {
210- ui.label("Aucune opération récente");
211- return;
212- }
213-
214- ui.label("📜 Dernières opérations:");
215- egui::ScrollArea::horizontal().show(ui, |ui| {
216- for entry in entries.iter().rev() {
217- let color = match entry.status {
218- OperationStatus::Success => egui::Color32::GREEN,
219- OperationStatus::Failed => egui::Color32::RED,
220- OperationStatus::InProgress => egui::Color32::YELLOW,
221- };
222- ui.colored_label(color, format!("{} - {}", entry.timestamp.format("%H:%M"), entry.description));
223- }
224- });
182+/// Libellé du filtre par statut
183+fn operation_status_label(filter: Option<OperationStatus>) -> String {
184+ match filter {
185+ None => "Tous les statuts".to_string(),
186+ Some(s) => s.to_string(),
225187 }
226188 }
@@ -1,226 +1,188 @@
1-#![allow(dead_code)] // Fonctionnalité prévue dans la spec (Phase 9+), pas encore branchée dans l'UI1+//! History Panel UI component
2-use crate::config::history::{HistoryEntry, HistoryManager, OperationStatus, OperationType};2+//!
3-use eframe::egui;3+//! Fenêtre flottante affichant l'historique des opérations
4-4+//! (synchronisations, téléchargements, ajouts/suppressions, import/export),
5-/// Panneau d'affichage de l'historique5+//! avec filtres par type et par statut.
6-pub struct HistoryPanel {6+
7- /// Visibilité du panneau7+use crate::config::history::{OperationStatus, OperationType};
8- visible: bool,8+use crate::config::AppState;
9- /// Filtre de type d'opération9+use crate::ui::theme::ThemeColors;
10- filter_type: Option<OperationType>,10+use crate::ui::truncate;
11- /// Filtre de statut11+use egui::Ui;
12- filter_status: Option<OperationStatus>,12+
13- /// Nombre d'entrées à afficher13+/// Draws the history window (toggled from the menu)
14- entries_to_show: usize,14+pub fn draw_history_window(state: &mut AppState, ui: &mut Ui) {
15-}15+ if !state.show_history_window {
16-16+ return;
17-impl Default for HistoryPanel {
18- fn default() -> Self {
19- Self {
20- visible: false,
21- filter_type: None,
22- filter_status: None,
23- entries_to_show: 50,
24- }
25- }
26-}
27-
28-impl HistoryPanel {
29- /// Créer un nouveau panneau d'historique
30- pub fn new() -> Self {
31- Self::default()
32- }
33-
34- /// Définir la visibilité
35- pub fn set_visible(&mut self, visible: bool) {
36- self.visible = visible;
37- }
38-
39- /// Basculer la visibilité
40- pub fn toggle(&mut self) {
41- self.visible = !self.visible;
42- }
43-
44- /// Vérifier si le panneau est visible
45- pub fn is_visible(&self) -> bool {
46- self.visible
47 }17 }
48 18
49- /// Définir le filtre de type19+ let mut open = state.show_history_window;
50- pub fn set_filter_type(&mut self, op_type: Option<OperationType>) {20+ egui::Window::new("📜 Historique")
51- self.filter_type = op_type;21+ .open(&mut open)
52- }22+ .default_size([640.0, 400.0])
53-23+ .show(ui.ctx(), |ui| {
54- /// Définir le filtre de statut24+ draw_history_content(state, ui);
55- pub fn set_filter_status(&mut self, status: Option<OperationStatus>) {25+ });
56- self.filter_status = status;26+ state.show_history_window = open;
57- }27+}
58-
59- /// Afficher le panneau d'historique
60- pub fn show(&mut self, ui: &mut egui::Ui, history: &HistoryManager) {
61- if !self.visible {
62- return;
63- }
64 28
65- egui::CollapsingHeader::new("📜 Historique")29+/// Draws the history window content
66- .default_open(true)30+fn draw_history_content(state: &mut AppState, ui: &mut Ui) {
67- .show(ui, |ui| {31+ let colors = ThemeColors::for_theme(state.get_theme());
68- self.show_content(ui, history);32+
33+ // Filtres
34+ ui.horizontal(|ui| {
35+ egui::ComboBox::from_id_salt("history_filter_type")
36+ .width(160.0)
37+ .selected_text(operation_type_label(state.history_filter_type))
38+ .show_ui(ui, |ui| {
39+ ui.selectable_value(&mut state.history_filter_type, None, "Tous les types");
40+ ui.selectable_value(
41+ &mut state.history_filter_type,
42+ Some(OperationType::SyncFeed),
43+ "Synchronisation flux",
44+ );
45+ ui.selectable_value(
46+ &mut state.history_filter_type,
47+ Some(OperationType::SyncAll),
48+ "Sync. tous flux",
49+ );
50+ ui.selectable_value(
51+ &mut state.history_filter_type,
52+ Some(OperationType::DownloadEpisode),
53+ "Téléchargement",
54+ );
55+ ui.selectable_value(
56+ &mut state.history_filter_type,
57+ Some(OperationType::AddFeed),
58+ "Ajout flux",
59+ );
60+ ui.selectable_value(
61+ &mut state.history_filter_type,
62+ Some(OperationType::RemoveFeed),
63+ "Suppr. flux",
64+ );
65+ ui.selectable_value(
66+ &mut state.history_filter_type,
67+ Some(OperationType::UpdateFeed),
68+ "Modif. flux",
69+ );
70+ ui.selectable_value(
71+ &mut state.history_filter_type,
72+ Some(OperationType::ImportFeeds),
73+ "Import flux",
74+ );
75+ ui.selectable_value(
76+ &mut state.history_filter_type,
77+ Some(OperationType::ExportFeeds),
78+ "Export flux",
79+ );
69 });80 });
70- }
71 81
72- /// Afficher le contenu du panneau82+ egui::ComboBox::from_id_salt("history_filter_status")
73- fn show_content(&mut self, ui: &mut egui::Ui, history: &HistoryManager) {83+ .width(120.0)
74- // Contrôles de filtre84+ .selected_text(operation_status_label(state.history_filter_status))
75- self.show_filters(ui);85+ .show_ui(ui, |ui| {
76- ui.separator();86+ ui.selectable_value(
87+ &mut state.history_filter_status,
88+ None,
89+ "Tous les statuts",
90+ );
91+ ui.selectable_value(
92+ &mut state.history_filter_status,
93+ Some(OperationStatus::Success),
94+ "Succès",
95+ );
96+ ui.selectable_value(
97+ &mut state.history_filter_status,
98+ Some(OperationStatus::Failed),
99+ "Échec",
100+ );
101+ });
77 102
78- // Afficher les entrées103+ if (state.history_filter_type.is_some() || state.history_filter_status.is_some())
79- self.show_entries(ui, history);104+ && ui.button("Effacer les filtres").clicked()
105+ {
106+ state.history_filter_type = None;
107+ state.history_filter_status = None;
108+ }
109+ });
110+
111+ ui.separator();
112+
113+ // Entrées filtrées, de la plus récente à la plus ancienne
114+ let entries: Vec<_> = state
115+ .history_manager
116+ .get_all_entries()
117+ .into_iter()
118+ .filter(|e| {
119+ state
120+ .history_filter_type
121+ .is_none_or(|t| e.operation_type == t)
122+ })
123+ .filter(|e| {
124+ state
125+ .history_filter_status
126+ .is_none_or(|s| e.status == s)
127+ })
128+ .collect();
129+
130+ if entries.is_empty() {
131+ ui.label("Aucune entrée d'historique.");
132+ return;
80 }133 }
81 134
82- /// Afficher les contrôles de filtre135+ egui::ScrollArea::vertical().show(ui, |ui| {
83- fn show_filters(&mut self, ui: &mut egui::Ui) {136+ egui::Grid::new("history_grid")
84- ui.horizontal(|ui| {137+ .striped(true)
85- // Filtre par type138+ .show(ui, |ui| {
86- egui::ComboBox::from_label("Type")139+ ui.strong("Date/Heure");
87- .width(150.0)140+ ui.strong("Type");
88- .selected_text(match self.filter_type {141+ ui.strong("Description");
89- Some(t) => format!("{}", t),142+ ui.strong("Statut");
90- None => "Tous les types".to_string(),143+ ui.end_row();
91- })144+
92- .show_ui(ui, |ui| {145+ for entry in entries.iter().rev() {
93- ui.selectable_value(&mut self.filter_type, None, "Tous les types");146+ ui.label(entry.timestamp.format("%d/%m/%Y %H:%M").to_string());
94- ui.selectable_value(&mut self.filter_type, Some(OperationType::SyncFeed), format!("{}", OperationType::SyncFeed));147+ ui.label(entry.operation_type.to_string());
95- ui.selectable_value(&mut self.filter_type, Some(OperationType::SyncAll), format!("{}", OperationType::SyncAll));148+ ui.label(truncate(&entry.description, 50))
96- ui.selectable_value(&mut self.filter_type, Some(OperationType::DownloadEpisode), format!("{}", OperationType::DownloadEpisode));149+ .on_hover_text(&entry.description);
97- ui.selectable_value(&mut self.filter_type, Some(OperationType::AddFeed), format!("{}", OperationType::AddFeed));150+ let (status_text, status_color) = match entry.status {
98- ui.selectable_value(&mut self.filter_type, Some(OperationType::RemoveFeed), format!("{}", OperationType::RemoveFeed));151+ OperationStatus::Success => ("Succès", colors.success),
99- ui.selectable_value(&mut self.filter_type, Some(OperationType::ImportFeeds), format!("{}", OperationType::ImportFeeds));152+ OperationStatus::Failed => ("Échec", colors.error),
100- ui.selectable_value(&mut self.filter_type, Some(OperationType::ExportFeeds), format!("{}", OperationType::ExportFeeds));153+ OperationStatus::InProgress => ("En cours", colors.warning),
101- ui.selectable_value(&mut self.filter_type, Some(OperationType::DownloadError), format!("{}", OperationType::DownloadError));154+ };
102- ui.selectable_value(&mut self.filter_type, Some(OperationType::SyncError), format!("{}", OperationType::SyncError));155+ let label = ui.colored_label(status_color, status_text);
103- });156+ if let Some(error) = &entry.error_message {
104-157+ label.on_hover_text(error);
105- // Filtre par statut158+ }
106- egui::ComboBox::from_label("Statut")159+ ui.end_row();
107- .width(120.0)
108- .selected_text(match self.filter_status {
109- Some(s) => format!("{}", s),
110- None => "Tous les statuts".to_string(),
111- })
112- .show_ui(ui, |ui| {
113- ui.selectable_value(&mut self.filter_status, None, "Tous les statuts");
114- ui.selectable_value(&mut self.filter_status, Some(OperationStatus::InProgress), format!("{}", OperationStatus::InProgress));
115- ui.selectable_value(&mut self.filter_status, Some(OperationStatus::Success), format!("{}", OperationStatus::Success));
116- ui.selectable_value(&mut self.filter_status, Some(OperationStatus::Failed), format!("{}", OperationStatus::Failed));
117- });
118-
119- // Bouton pour effacer les filtres
120- if (self.filter_type.is_some() || self.filter_status.is_some())
121- && ui.button("Effacer filtres").clicked() {
122- self.filter_type = None;
123- self.filter_status = None;
124 }160 }
125- });161+ });
126- }162+ });
127-163+
128- /// Afficher les entrées d'historique164+ ui.separator();
129- fn show_entries(&mut self, ui: &mut egui::Ui, history: &HistoryManager) {165+ ui.horizontal(|ui| {
130- let mut entries: Vec<&HistoryEntry> = history.get_all_entries();166+ ui.label(format!("Total : {} entrées", state.history_manager.len()));
131-167+ if ui.button("Effacer l'historique").clicked() {
132- // Appliquer les filtres168+ state.history_manager.clear();
133- if let Some(op_type) = self.filter_type {169+ state.notification_manager.info("Historique effacé");
134- entries.retain(|e| e.operation_type == op_type);
135- }
136- if let Some(status) = self.filter_status {
137- entries.retain(|e| e.status == status);
138- }
139-
140- // Limiter le nombre d'entrées affichées
141- entries.truncate(self.entries_to_show);
142-
143- if entries.is_empty() {
144- ui.label("Aucune entrée d'historique trouvée");
145- return;
146 }170 }
171+ });
172+}
147 173
148- // Tableau des entrées174+/// Libellé du filtre par type d'opération
149- egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {175+fn operation_type_label(filter: Option<OperationType>) -> String {
150- egui::Grid::new("history_grid")176+ match filter {
151- .striped(true)177+ None => "Tous les types".to_string(),
152- .show(ui, |ui| {178+ Some(t) => t.to_string(),
153- // En-têtes
154- ui.strong("Date/Heure");
155- ui.strong("Type");
156- ui.strong("Description");
157- ui.strong("Statut");
158- ui.strong("Durée/Taille");
159- ui.end_row();
160-
161- // Lignes de données (afficher de la plus récente à la plus ancienne)
162- for entry in entries.iter().rev() {
163- ui.label(format!("{}", entry.timestamp.format("%Y-%m-%d %H:%M:%S")));
164- ui.label(format!("{}", entry.operation_type));
165-
166- // Limiter la longueur de la description
167- let desc = if entry.description.len() > 40 {
168- format!("{}...", &entry.description[..37])
169- } else {
170- entry.description.clone()
171- };
172- ui.label(desc);
173-
174- // Afficher le statut avec une couleur
175- let status_text = format!("{}", entry.status);
176- let color = match entry.status {
177- OperationStatus::Success => egui::Color32::GREEN,
178- OperationStatus::Failed => egui::Color32::RED,
179- OperationStatus::InProgress => egui::Color32::YELLOW,
180- };
181- ui.colored_label(color, &status_text);
182-
183- // Afficher durée ou taille
184- let info = if entry.duration_seconds.is_some() {
185- entry.get_formatted_duration()
186- } else {
187- entry.get_formatted_size()
188- };
189- ui.label(info);
190- ui.end_row();
191- }
192- });
193- });
194-
195- // Statistiques de l'historique
196- ui.separator();
197- ui.horizontal(|ui| {
198- ui.label(format!("Total: {} entrées", history.len()));
199- if ui.button("Effacer historique").clicked() {
200- // L'appelant doit effacer l'historique
201- }
202- });
203 }179 }
180+}
204 181
205- /// Afficher une version simplifiée (dernières opérations)182+/// Libellé du filtre par statut
206- pub fn show_recent(ui: &mut egui::Ui, history: &HistoryManager, count: usize) {183+fn operation_status_label(filter: Option<OperationStatus>) -> String {
207- let entries = history.get_recent_entries(count);184+ match filter {
208-185+ None => "Tous les statuts".to_string(),
209- if entries.is_empty() {186+ Some(s) => s.to_string(),
210- ui.label("Aucune opération récente");
211- return;
212- }
213-
214- ui.label("📜 Dernières opérations:");
215- egui::ScrollArea::horizontal().show(ui, |ui| {
216- for entry in entries.iter().rev() {
217- let color = match entry.status {
218- OperationStatus::Success => egui::Color32::GREEN,
219- OperationStatus::Failed => egui::Color32::RED,
220- OperationStatus::InProgress => egui::Color32::YELLOW,
221- };
222- ui.colored_label(color, format!("{} - {}", entry.timestamp.format("%H:%M"), entry.description));
223- }
224- });
225 }187 }
226 }188 }
modified src/ui/home.rs +238 -92
@@ -3,25 +3,109 @@
33 //! This module contains the main home screen layout.
44
55 use crate::config::AppState;
6+use crate::config::sync_task::SyncMessage;
7+use crate::ui::theme::ThemeColors;
68 use egui::Ui;
79
810 /// Draws the home screen
911 pub fn draw_home(state: &mut AppState, ui: &mut Ui) {
1012 // Process sync messages first to update feeds with latest download statistics
11- // This ensures the feed list displays up-to-date information
13+ process_sync_messages(state);
14+
15+ // Header avec titre et version
16+ let colors = ThemeColors::for_theme(state.get_theme());
17+ ui.horizontal(|ui| {
18+ ui.heading(
19+ egui::RichText::new("🎙️ RSS Podcast Downloader").color(colors.accent),
20+ );
21+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
22+ ui.label(
23+ egui::RichText::new(format!("v{}", env!("CARGO_PKG_VERSION")))
24+ .color(colors.accent),
25+ );
26+ });
27+ });
28+
29+ ui.separator();
30+
31+ // Barre d'outils : recherche + filtres
32+ ui.horizontal(|ui| {
33+ ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
34+ crate::ui::search_bar::draw_search_bar(state, ui);
35+ });
36+
37+ ui.horizontal(|ui| {
38+ crate::ui::filter_panel::draw_filter_panel(state, ui);
39+ });
40+
41+ ui.separator();
42+
43+ // Boutons d'action principaux
44+ ui.horizontal(|ui| {
45+ ui.spacing_mut().item_spacing = egui::Vec2::new(16.0, 0.0);
46+ draw_action_buttons(state, ui);
47+ });
48+
49+ ui.add_space(16.0);
50+ ui.separator();
51+
52+ if state.detail_feed_id.is_some() {
53+ // Vue détaillée d'un flux : remplace la liste des flux et les playlists
54+ crate::ui::feed_detail::draw_feed_detail(state, ui);
55+ } else {
56+ // Section des flux (ouverte par défaut)
57+ egui::CollapsingHeader::new("📻 Mes Flux RSS")
58+ .default_open(true)
59+ .show(ui, |ui| {
60+ if state.has_feeds() {
61+ crate::ui::feed_list::draw_feed_list(state, ui);
62+ } else {
63+ draw_empty_state(ui, &colors);
64+ }
65+ });
66+
67+ ui.separator();
68+
69+ // Section des playlists
70+ egui::CollapsingHeader::new("📋 Playlists")
71+ .default_open(true)
72+ .show(ui, |ui| {
73+ crate::ui::playlist_list::draw_playlist_list(state, ui);
74+ });
75+ }
76+
77+ // Fenêtres Statistiques et Historique
78+ crate::ui::stats_panel::draw_stats_window(state, ui);
79+ crate::ui::history_panel::draw_history_window(state, ui);
80+
81+ // Dialogues
82+ crate::ui::add_feed_dialog::draw_add_feed_dialog(state, ui);
83+ crate::ui::feed_edit_dialog::draw_edit_feed_dialog(state, ui);
84+ crate::ui::playlist_list::draw_add_playlist_dialog(state, ui);
85+ crate::ui::playlist_list::draw_edit_playlist_dialog(state, ui);
86+ crate::ui::export_import::draw_export_dialog(state, ui);
87+ crate::ui::export_import::draw_import_dialog(state, ui);
88+}
89+
90+/// Processes messages from the async sync tasks and updates the UI state
91+fn process_sync_messages(state: &mut AppState) {
1292 let messages = state.sync_task_manager.process_messages();
1393 let mut needs_save = false;
1494 for message in messages {
1595 match message {
16- crate::config::sync_task::SyncMessage::SyncCompleted {
96+ SyncMessage::SyncCompleted {
1797 feed_id,
1898 downloaded_episodes_count,
1999 last_downloaded_episode_name,
20100 last_downloaded_episode_date,
21101 last_checked,
22- ..
23102 } => {
24103 // Find the feed and update its statistics
104+ let feed_name = state
105+ .feeds
106+ .iter()
107+ .find(|f| f.id == feed_id)
108+ .map(|f| f.name.clone());
25109 if let Some(feed) = state.feeds.iter_mut().find(|f| f.id == feed_id) {
26110 feed.downloaded_episodes_count = downloaded_episodes_count;
27111 feed.last_downloaded_episode_name = last_downloaded_episode_name;
@@ -29,8 +113,31 @@ pub fn draw_home(state: &mut AppState, ui: &mut Ui) {
29113 feed.last_checked = last_checked;
30114 needs_save = true;
31115 }
116+ if let Some(name) = feed_name {
117+ state.record_history(
118+ crate::config::history::OperationType::SyncFeed,
119+ format!("Synchronisation terminée : {}", name),
120+ );
121+ }
122+ }
123+ SyncMessage::SyncFailed { feed_id, error } => {
124+ let feed_name = state
125+ .feeds
126+ .iter()
127+ .find(|f| f.id == feed_id)
128+ .map(|f| f.name.clone())
129+ .unwrap_or_else(|| feed_id.clone());
130+ state.notification_manager.error(format!(
131+ "Échec de la synchronisation de \"{}\" : {}",
132+ feed_name, error
133+ ));
134+ state.record_history_failure(
135+ crate::config::history::OperationType::SyncError,
136+ format!("Échec de synchronisation : {}", feed_name),
137+ error,
138+ );
32139 }
33- crate::config::sync_task::SyncMessage::EpisodeDownloadFailed {
140+ SyncMessage::EpisodeDownloadFailed {
34141 feed_id,
35142 episode_title,
36143 error,
@@ -41,98 +148,148 @@ pub fn draw_home(state: &mut AppState, ui: &mut Ui) {
41148 .find(|f| f.id == feed_id)
42149 .map(|f| f.name.clone())
43150 .unwrap_or_else(|| feed_id.clone());
151+
152+ // Retirer l'épisode des téléchargements en cours (identifié par son titre)
153+ if state.detail_feed_id.as_deref() == Some(feed_id.as_str()) {
154+ if let Some(episode) = state
155+ .detail_episodes
156+ .iter()
157+ .find(|e| e.title == episode_title)
158+ {
159+ let id = episode.id.clone();
160+ state.downloading_episode_ids.remove(&id);
161+ }
162+ }
163+
44164 state.notification_manager.error(format!(
45165 "Échec du téléchargement de \"{}\" ({}) : {}",
46166 episode_title, feed_name, error
47167 ));
168+ state.record_history_failure(
169+ crate::config::history::OperationType::DownloadError,
170+ format!("Échec de téléchargement : {}", episode_title),
171+ error,
172+ );
173+ }
174+ SyncMessage::EpisodesLoaded { feed_id, episodes } => {
175+ if state.detail_feed_id.as_deref() == Some(feed_id.as_str()) {
176+ // Nettoyer les téléchargements obsolètes (épisodes disparus du flux)
177+ let loaded_ids: std::collections::HashSet<String> =
178+ episodes.iter().map(|e| e.id.clone()).collect();
179+ state
180+ .downloading_episode_ids
181+ .retain(|id| loaded_ids.contains(id));
182+
183+ if let Some(feed) = state.feeds.iter().find(|f| f.id == feed_id) {
184+ let download_path = feed.download_path.clone();
185+ let mut episodes = episodes;
186+ for episode in &mut episodes {
187+ episode.downloaded = !crate::rss::downloader::needs_download(
188+ episode,
189+ std::path::Path::new(&download_path),
190+ );
191+ }
192+ state.detail_episodes = episodes;
193+ state.detail_loading = false;
194+ }
195+ }
196+ }
197+ SyncMessage::EpisodesLoadFailed { feed_id, error } => {
198+ if state.detail_feed_id.as_deref() == Some(feed_id.as_str()) {
199+ state.detail_loading = false;
200+ state.detail_error = Some(error);
201+ }
202+ }
203+ SyncMessage::EpisodeDownloadCompleted {
204+ feed_id,
205+ episode_id,
206+ episode_title,
207+ size,
208+ } => {
209+ state.downloading_episode_ids.remove(&episode_id);
210+ let feed_name = state
211+ .feeds
212+ .iter()
213+ .find(|f| f.id == feed_id)
214+ .map(|f| f.name.clone())
215+ .unwrap_or_else(|| feed_id.clone());
216+
217+ // Update feed statistics
218+ if let Some(feed) = state.feeds.iter_mut().find(|f| f.id == feed_id) {
219+ feed.downloaded_episodes_count += 1;
220+ feed.last_downloaded_episode_name = Some(episode_title.clone());
221+ feed.last_downloaded_episode_date = Some(chrono::Utc::now());
222+ needs_save = true;
223+ }
224+
225+ // Mark the episode as downloaded in the detail view
226+ if state.detail_feed_id.as_deref() == Some(feed_id.as_str()) {
227+ if let Some(episode) = state
228+ .detail_episodes
229+ .iter_mut()
230+ .find(|e| e.id == episode_id)
231+ {
232+ episode.downloaded = true;
233+ if let Some(size) = size {
234+ episode.file_size = Some(size);
235+ }
236+ }
237+ }
238+
239+ state
240+ .notification_manager
241+ .success(format!("Épisode téléchargé : {}", episode_title));
242+ state.record_history(
243+ crate::config::history::OperationType::DownloadEpisode,
244+ format!("Épisode téléchargé ({}), flux : {}", episode_title, feed_name),
245+ );
48246 }
49- _ => {}
247+ SyncMessage::SyncStarted { .. } => {}
50248 }
51249 }
52-
250+
53251 // Save feeds if any download statistics were updated
54252 if needs_save {
55253 if let Err(e) = crate::config::storage::save_feeds_default(&state.feeds) {
56254 log::error!("Failed to save feeds after download: {}", e);
57255 }
58256 }
59-
60- // Header avec titre et icône
61- ui.horizontal(|ui| {
62- ui.heading(egui::RichText::new("🎙️ RSS Podcast Downloader").color(egui::Color32::from_rgb(130, 170, 250)));
63- ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
64- ui.label(egui::RichText::new(format!("v{}", env!("CARGO_PKG_VERSION"))).color(egui::Color32::from_rgb(150, 190, 255)));
65- });
66- });
67-
68- ui.separator();
69-
70- // Barre d'outils avec filtres et actions
71- ui.horizontal(|ui| {
72- ui.vertical(|ui| {
73- // Filtres
74- crate::ui::filter_panel::draw_filter_panel(state, ui);
75- });
76- });
77-
78- ui.separator();
79-
80- // Boutons d'action principaux (améliorés)
81- ui.horizontal(|ui| {
82- ui.spacing_mut().item_spacing = egui::Vec2::new(16.0, 0.0);
83- draw_action_buttons(state, ui);
84- });
85-
86- ui.add_space(16.0);
87- ui.separator();
88-
89- // Section des flux
90- ui.collapsing("📻 Mes Flux RSS", |ui| {
91- if state.has_feeds() {
92- draw_feed_list(state, ui);
93- } else {
94- draw_empty_state(ui);
95- }
96- });
97-
98- ui.separator();
99-
100- // Section des playlists
101- ui.collapsing("📋 Playlists", |ui| {
102- crate::ui::playlist_list::draw_playlist_list(state, ui);
103- });
104-
105- // Dialogues
106- crate::ui::add_feed_dialog::draw_add_feed_dialog(state, ui);
107- crate::ui::feed_edit_dialog::draw_edit_feed_dialog(state, ui);
108- crate::ui::playlist_list::draw_add_playlist_dialog(state, ui);
109- crate::ui::playlist_list::draw_edit_playlist_dialog(state, ui);
110- crate::ui::export_import::draw_export_dialog(state, ui);
111- crate::ui::export_import::draw_import_dialog(state, ui);
112257 }
113258
114259 /// Draws the action buttons
115260 fn draw_action_buttons(state: &mut AppState, ui: &mut Ui) {
116261 ui.horizontal(|ui| {
117- // Boutons principaux avec icônes et style amélioré
118- if ui.button(egui::RichText::new("➕ Ajouter un flux").size(14.0)).clicked() {
262+ // Boutons principaux
263+ if ui
264+ .button(egui::RichText::new("➕ Ajouter un flux").size(14.0))
265+ .clicked()
266+ {
119267 state.clear_new_feed_form();
120268 state.show_add_feed_dialog = true;
121269 }
122270
123- if ui.button(egui::RichText::new("🔄 Synchroniser tous").size(14.0)).clicked() {
124- sync_all_feeds(state);
271+ if ui
272+ .button(egui::RichText::new("🔄 Synchroniser tous").size(14.0))
273+ .clicked()
274+ {
275+ state.start_sync_all_feeds();
125276 }
126277
127278 ui.add_space(10.0);
128279
129280 // Boutons secondaires
130- if ui.button(egui::RichText::new("📤 Exporter").size(14.0)).clicked() {
281+ if ui
282+ .button(egui::RichText::new("📤 Exporter").size(14.0))
283+ .clicked()
284+ {
131285 state.clear_export_import_error();
132286 state.show_export_dialog = true;
133287 }
134288
135- if ui.button(egui::RichText::new("📥 Importer").size(14.0)).clicked() {
289+ if ui
290+ .button(egui::RichText::new("📥 Importer").size(14.0))
291+ .clicked()
292+ {
136293 state.clear_export_import_error();
137294 state.show_import_dialog = true;
138295 }
@@ -140,39 +297,28 @@ fn draw_action_buttons(state: &mut AppState, ui: &mut Ui) {
140297 }
141298
142299 /// Draws the empty state message
143-fn draw_empty_state(ui: &mut Ui) {
300+fn draw_empty_state(ui: &mut Ui, colors: &ThemeColors) {
144301 ui.vertical_centered_justified(|ui| {
145302 ui.add_space(40.0);
146303 ui.label(egui::RichText::new("🎧").size(48.0));
147304 ui.add_space(16.0);
148- ui.label(egui::RichText::new("Bienvenue dans RSS Podcast Downloader").size(20.0).color(egui::Color32::from_rgb(220, 230, 245)));
305+ ui.label(
306+ egui::RichText::new("Bienvenue dans RSS Podcast Downloader")
307+ .size(20.0)
308+ .color(colors.text_primary),
309+ );
149310 ui.add_space(8.0);
150- ui.label(egui::RichText::new("Aucun flux configuré pour le moment").size(16.0).color(egui::Color32::from_rgb(180, 190, 210)));
311+ ui.label(
312+ egui::RichText::new("Aucun flux configuré pour le moment")
313+ .size(16.0)
314+ .color(colors.text_secondary),
315+ );
151316 ui.add_space(16.0);
152- ui.label(egui::RichText::new("👉 Cliquez sur '➕ Ajouter un flux' pour commencer").size(14.0).color(egui::Color32::from_rgb(150, 190, 255)));
317+ ui.label(
318+ egui::RichText::new("👉 Cliquez sur '➕ Ajouter un flux' pour commencer")
319+ .size(14.0)
320+ .color(colors.accent),
321+ );
153322 ui.add_space(40.0);
154323 });
155324 }
156-
157-/// Draws the feed list (delegates to feed_list module)
158-pub fn draw_feed_list(state: &mut AppState, ui: &mut Ui) {
159- crate::ui::feed_list::draw_feed_list(state, ui);
160-}
161-
162-/// Synchronizes all feeds asynchronously
163-fn sync_all_feeds(state: &mut AppState) {
164- if !state.has_feeds() {
165- state.notification_manager.info("Aucun flux à synchroniser");
166- return;
167- }
168-
169- log::info!("Starting asynchronous synchronization of all feeds");
170-
171- // Clone feeds for async task (we'll update state.feeds from messages)
172- let feeds_to_sync = state.feeds.clone();
173-
174- // Start async sync
175- state.sync_task_manager.start_sync_all_feeds(feeds_to_sync);
176-
177- state.notification_manager.info("Synchronisation de tous les flux en cours...");
178-}
@@ -3,25 +3,109 @@
3 //! This module contains the main home screen layout.3 //! This module contains the main home screen layout.
4 4
5 use crate::config::AppState;5 use crate::config::AppState;
6+use crate::config::sync_task::SyncMessage;
7+use crate::ui::theme::ThemeColors;
6 use egui::Ui;8 use egui::Ui;
7 9
8 /// Draws the home screen10 /// Draws the home screen
9 pub fn draw_home(state: &mut AppState, ui: &mut Ui) {11 pub fn draw_home(state: &mut AppState, ui: &mut Ui) {
10 // Process sync messages first to update feeds with latest download statistics12 // Process sync messages first to update feeds with latest download statistics
11- // This ensures the feed list displays up-to-date information13+ process_sync_messages(state);
14+
15+ // Header avec titre et version
16+ let colors = ThemeColors::for_theme(state.get_theme());
17+ ui.horizontal(|ui| {
18+ ui.heading(
19+ egui::RichText::new("🎙️ RSS Podcast Downloader").color(colors.accent),
20+ );
21+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
22+ ui.label(
23+ egui::RichText::new(format!("v{}", env!("CARGO_PKG_VERSION")))
24+ .color(colors.accent),
25+ );
26+ });
27+ });
28+
29+ ui.separator();
30+
31+ // Barre d'outils : recherche + filtres
32+ ui.horizontal(|ui| {
33+ ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
34+ crate::ui::search_bar::draw_search_bar(state, ui);
35+ });
36+
37+ ui.horizontal(|ui| {
38+ crate::ui::filter_panel::draw_filter_panel(state, ui);
39+ });
40+
41+ ui.separator();
42+
43+ // Boutons d'action principaux
44+ ui.horizontal(|ui| {
45+ ui.spacing_mut().item_spacing = egui::Vec2::new(16.0, 0.0);
46+ draw_action_buttons(state, ui);
47+ });
48+
49+ ui.add_space(16.0);
50+ ui.separator();
51+
52+ if state.detail_feed_id.is_some() {
53+ // Vue détaillée d'un flux : remplace la liste des flux et les playlists
54+ crate::ui::feed_detail::draw_feed_detail(state, ui);
55+ } else {
56+ // Section des flux (ouverte par défaut)
57+ egui::CollapsingHeader::new("📻 Mes Flux RSS")
58+ .default_open(true)
59+ .show(ui, |ui| {
60+ if state.has_feeds() {
61+ crate::ui::feed_list::draw_feed_list(state, ui);
62+ } else {
63+ draw_empty_state(ui, &colors);
64+ }
65+ });
66+
67+ ui.separator();
68+
69+ // Section des playlists
70+ egui::CollapsingHeader::new("📋 Playlists")
71+ .default_open(true)
72+ .show(ui, |ui| {
73+ crate::ui::playlist_list::draw_playlist_list(state, ui);
74+ });
75+ }
76+
77+ // Fenêtres Statistiques et Historique
78+ crate::ui::stats_panel::draw_stats_window(state, ui);
79+ crate::ui::history_panel::draw_history_window(state, ui);
80+
81+ // Dialogues
82+ crate::ui::add_feed_dialog::draw_add_feed_dialog(state, ui);
83+ crate::ui::feed_edit_dialog::draw_edit_feed_dialog(state, ui);
84+ crate::ui::playlist_list::draw_add_playlist_dialog(state, ui);
85+ crate::ui::playlist_list::draw_edit_playlist_dialog(state, ui);
86+ crate::ui::export_import::draw_export_dialog(state, ui);
87+ crate::ui::export_import::draw_import_dialog(state, ui);
88+}
89+
90+/// Processes messages from the async sync tasks and updates the UI state
91+fn process_sync_messages(state: &mut AppState) {
12 let messages = state.sync_task_manager.process_messages();92 let messages = state.sync_task_manager.process_messages();
13 let mut needs_save = false;93 let mut needs_save = false;
14 for message in messages {94 for message in messages {
15 match message {95 match message {
16- crate::config::sync_task::SyncMessage::SyncCompleted {96+ SyncMessage::SyncCompleted {
17 feed_id,97 feed_id,
18 downloaded_episodes_count,98 downloaded_episodes_count,
19 last_downloaded_episode_name,99 last_downloaded_episode_name,
20 last_downloaded_episode_date,100 last_downloaded_episode_date,
21 last_checked,101 last_checked,
22- ..
23 } => {102 } => {
24 // Find the feed and update its statistics103 // Find the feed and update its statistics
104+ let feed_name = state
105+ .feeds
106+ .iter()
107+ .find(|f| f.id == feed_id)
108+ .map(|f| f.name.clone());
25 if let Some(feed) = state.feeds.iter_mut().find(|f| f.id == feed_id) {109 if let Some(feed) = state.feeds.iter_mut().find(|f| f.id == feed_id) {
26 feed.downloaded_episodes_count = downloaded_episodes_count;110 feed.downloaded_episodes_count = downloaded_episodes_count;
27 feed.last_downloaded_episode_name = last_downloaded_episode_name;111 feed.last_downloaded_episode_name = last_downloaded_episode_name;
@@ -29,8 +113,31 @@ pub fn draw_home(state: &mut AppState, ui: &mut Ui) {
29 feed.last_checked = last_checked;113 feed.last_checked = last_checked;
30 needs_save = true;114 needs_save = true;
31 }115 }
116+ if let Some(name) = feed_name {
117+ state.record_history(
118+ crate::config::history::OperationType::SyncFeed,
119+ format!("Synchronisation terminée : {}", name),
120+ );
121+ }
122+ }
123+ SyncMessage::SyncFailed { feed_id, error } => {
124+ let feed_name = state
125+ .feeds
126+ .iter()
127+ .find(|f| f.id == feed_id)
128+ .map(|f| f.name.clone())
129+ .unwrap_or_else(|| feed_id.clone());
130+ state.notification_manager.error(format!(
131+ "Échec de la synchronisation de \"{}\" : {}",
132+ feed_name, error
133+ ));
134+ state.record_history_failure(
135+ crate::config::history::OperationType::SyncError,
136+ format!("Échec de synchronisation : {}", feed_name),
137+ error,
138+ );
32 }139 }
33- crate::config::sync_task::SyncMessage::EpisodeDownloadFailed {140+ SyncMessage::EpisodeDownloadFailed {
34 feed_id,141 feed_id,
35 episode_title,142 episode_title,
36 error,143 error,
@@ -41,98 +148,148 @@ pub fn draw_home(state: &mut AppState, ui: &mut Ui) {
41 .find(|f| f.id == feed_id)148 .find(|f| f.id == feed_id)
42 .map(|f| f.name.clone())149 .map(|f| f.name.clone())
43 .unwrap_or_else(|| feed_id.clone());150 .unwrap_or_else(|| feed_id.clone());
151+
152+ // Retirer l'épisode des téléchargements en cours (identifié par son titre)
153+ if state.detail_feed_id.as_deref() == Some(feed_id.as_str()) {
154+ if let Some(episode) = state
155+ .detail_episodes
156+ .iter()
157+ .find(|e| e.title == episode_title)
158+ {
159+ let id = episode.id.clone();
160+ state.downloading_episode_ids.remove(&id);
161+ }
162+ }
163+
44 state.notification_manager.error(format!(164 state.notification_manager.error(format!(
45 "Échec du téléchargement de \"{}\" ({}) : {}",165 "Échec du téléchargement de \"{}\" ({}) : {}",
46 episode_title, feed_name, error166 episode_title, feed_name, error
47 ));167 ));
168+ state.record_history_failure(
169+ crate::config::history::OperationType::DownloadError,
170+ format!("Échec de téléchargement : {}", episode_title),
171+ error,
172+ );
173+ }
174+ SyncMessage::EpisodesLoaded { feed_id, episodes } => {
175+ if state.detail_feed_id.as_deref() == Some(feed_id.as_str()) {
176+ // Nettoyer les téléchargements obsolètes (épisodes disparus du flux)
177+ let loaded_ids: std::collections::HashSet<String> =
178+ episodes.iter().map(|e| e.id.clone()).collect();
179+ state
180+ .downloading_episode_ids
181+ .retain(|id| loaded_ids.contains(id));
182+
183+ if let Some(feed) = state.feeds.iter().find(|f| f.id == feed_id) {
184+ let download_path = feed.download_path.clone();
185+ let mut episodes = episodes;
186+ for episode in &mut episodes {
187+ episode.downloaded = !crate::rss::downloader::needs_download(
188+ episode,
189+ std::path::Path::new(&download_path),
190+ );
191+ }
192+ state.detail_episodes = episodes;
193+ state.detail_loading = false;
194+ }
195+ }
196+ }
197+ SyncMessage::EpisodesLoadFailed { feed_id, error } => {
198+ if state.detail_feed_id.as_deref() == Some(feed_id.as_str()) {
199+ state.detail_loading = false;
200+ state.detail_error = Some(error);
201+ }
202+ }
203+ SyncMessage::EpisodeDownloadCompleted {
204+ feed_id,
205+ episode_id,
206+ episode_title,
207+ size,
208+ } => {
209+ state.downloading_episode_ids.remove(&episode_id);
210+ let feed_name = state
211+ .feeds
212+ .iter()
213+ .find(|f| f.id == feed_id)
214+ .map(|f| f.name.clone())
215+ .unwrap_or_else(|| feed_id.clone());
216+
217+ // Update feed statistics
218+ if let Some(feed) = state.feeds.iter_mut().find(|f| f.id == feed_id) {
219+ feed.downloaded_episodes_count += 1;
220+ feed.last_downloaded_episode_name = Some(episode_title.clone());
221+ feed.last_downloaded_episode_date = Some(chrono::Utc::now());
222+ needs_save = true;
223+ }
224+
225+ // Mark the episode as downloaded in the detail view
226+ if state.detail_feed_id.as_deref() == Some(feed_id.as_str()) {
227+ if let Some(episode) = state
228+ .detail_episodes
229+ .iter_mut()
230+ .find(|e| e.id == episode_id)
231+ {
232+ episode.downloaded = true;
233+ if let Some(size) = size {
234+ episode.file_size = Some(size);
235+ }
236+ }
237+ }
238+
239+ state
240+ .notification_manager
241+ .success(format!("Épisode téléchargé : {}", episode_title));
242+ state.record_history(
243+ crate::config::history::OperationType::DownloadEpisode,
244+ format!("Épisode téléchargé ({}), flux : {}", episode_title, feed_name),
245+ );
48 }246 }
49- _ => {}247+ SyncMessage::SyncStarted { .. } => {}
50 }248 }
51 }249 }
52- 250+
53 // Save feeds if any download statistics were updated251 // Save feeds if any download statistics were updated
54 if needs_save {252 if needs_save {
55 if let Err(e) = crate::config::storage::save_feeds_default(&state.feeds) {253 if let Err(e) = crate::config::storage::save_feeds_default(&state.feeds) {
56 log::error!("Failed to save feeds after download: {}", e);254 log::error!("Failed to save feeds after download: {}", e);
57 }255 }
58 }256 }
59-
60- // Header avec titre et icône
61- ui.horizontal(|ui| {
62- ui.heading(egui::RichText::new("🎙️ RSS Podcast Downloader").color(egui::Color32::from_rgb(130, 170, 250)));
63- ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
64- ui.label(egui::RichText::new(format!("v{}", env!("CARGO_PKG_VERSION"))).color(egui::Color32::from_rgb(150, 190, 255)));
65- });
66- });
67-
68- ui.separator();
69-
70- // Barre d'outils avec filtres et actions
71- ui.horizontal(|ui| {
72- ui.vertical(|ui| {
73- // Filtres
74- crate::ui::filter_panel::draw_filter_panel(state, ui);
75- });
76- });
77-
78- ui.separator();
79-
80- // Boutons d'action principaux (améliorés)
81- ui.horizontal(|ui| {
82- ui.spacing_mut().item_spacing = egui::Vec2::new(16.0, 0.0);
83- draw_action_buttons(state, ui);
84- });
85-
86- ui.add_space(16.0);
87- ui.separator();
88-
89- // Section des flux
90- ui.collapsing("📻 Mes Flux RSS", |ui| {
91- if state.has_feeds() {
92- draw_feed_list(state, ui);
93- } else {
94- draw_empty_state(ui);
95- }
96- });
97-
98- ui.separator();
99-
100- // Section des playlists
101- ui.collapsing("📋 Playlists", |ui| {
102- crate::ui::playlist_list::draw_playlist_list(state, ui);
103- });
104-
105- // Dialogues
106- crate::ui::add_feed_dialog::draw_add_feed_dialog(state, ui);
107- crate::ui::feed_edit_dialog::draw_edit_feed_dialog(state, ui);
108- crate::ui::playlist_list::draw_add_playlist_dialog(state, ui);
109- crate::ui::playlist_list::draw_edit_playlist_dialog(state, ui);
110- crate::ui::export_import::draw_export_dialog(state, ui);
111- crate::ui::export_import::draw_import_dialog(state, ui);
112 }257 }
113 258
114 /// Draws the action buttons259 /// Draws the action buttons
115 fn draw_action_buttons(state: &mut AppState, ui: &mut Ui) {260 fn draw_action_buttons(state: &mut AppState, ui: &mut Ui) {
116 ui.horizontal(|ui| {261 ui.horizontal(|ui| {
117- // Boutons principaux avec icônes et style amélioré262+ // Boutons principaux
118- if ui.button(egui::RichText::new("➕ Ajouter un flux").size(14.0)).clicked() {263+ if ui
264+ .button(egui::RichText::new("➕ Ajouter un flux").size(14.0))
265+ .clicked()
266+ {
119 state.clear_new_feed_form();267 state.clear_new_feed_form();
120 state.show_add_feed_dialog = true;268 state.show_add_feed_dialog = true;
121 }269 }
122 270
123- if ui.button(egui::RichText::new("🔄 Synchroniser tous").size(14.0)).clicked() {271+ if ui
124- sync_all_feeds(state);272+ .button(egui::RichText::new("🔄 Synchroniser tous").size(14.0))
273+ .clicked()
274+ {
275+ state.start_sync_all_feeds();
125 }276 }
126 277
127 ui.add_space(10.0);278 ui.add_space(10.0);
128 279
129 // Boutons secondaires280 // Boutons secondaires
130- if ui.button(egui::RichText::new("📤 Exporter").size(14.0)).clicked() {281+ if ui
282+ .button(egui::RichText::new("📤 Exporter").size(14.0))
283+ .clicked()
284+ {
131 state.clear_export_import_error();285 state.clear_export_import_error();
132 state.show_export_dialog = true;286 state.show_export_dialog = true;
133 }287 }
134 288
135- if ui.button(egui::RichText::new("📥 Importer").size(14.0)).clicked() {289+ if ui
290+ .button(egui::RichText::new("📥 Importer").size(14.0))
291+ .clicked()
292+ {
136 state.clear_export_import_error();293 state.clear_export_import_error();
137 state.show_import_dialog = true;294 state.show_import_dialog = true;
138 }295 }
@@ -140,39 +297,28 @@ fn draw_action_buttons(state: &mut AppState, ui: &mut Ui) {
140 }297 }
141 298
142 /// Draws the empty state message299 /// Draws the empty state message
143-fn draw_empty_state(ui: &mut Ui) {300+fn draw_empty_state(ui: &mut Ui, colors: &ThemeColors) {
144 ui.vertical_centered_justified(|ui| {301 ui.vertical_centered_justified(|ui| {
145 ui.add_space(40.0);302 ui.add_space(40.0);
146 ui.label(egui::RichText::new("🎧").size(48.0));303 ui.label(egui::RichText::new("🎧").size(48.0));
147 ui.add_space(16.0);304 ui.add_space(16.0);
148- ui.label(egui::RichText::new("Bienvenue dans RSS Podcast Downloader").size(20.0).color(egui::Color32::from_rgb(220, 230, 245)));305+ ui.label(
306+ egui::RichText::new("Bienvenue dans RSS Podcast Downloader")
307+ .size(20.0)
308+ .color(colors.text_primary),
309+ );
149 ui.add_space(8.0);310 ui.add_space(8.0);
150- ui.label(egui::RichText::new("Aucun flux configuré pour le moment").size(16.0).color(egui::Color32::from_rgb(180, 190, 210)));311+ ui.label(
312+ egui::RichText::new("Aucun flux configuré pour le moment")
313+ .size(16.0)
314+ .color(colors.text_secondary),
315+ );
151 ui.add_space(16.0);316 ui.add_space(16.0);
152- ui.label(egui::RichText::new("👉 Cliquez sur '➕ Ajouter un flux' pour commencer").size(14.0).color(egui::Color32::from_rgb(150, 190, 255)));317+ ui.label(
318+ egui::RichText::new("👉 Cliquez sur '➕ Ajouter un flux' pour commencer")
319+ .size(14.0)
320+ .color(colors.accent),
321+ );
153 ui.add_space(40.0);322 ui.add_space(40.0);
154 });323 });
155 }324 }
156-
157-/// Draws the feed list (delegates to feed_list module)
158-pub fn draw_feed_list(state: &mut AppState, ui: &mut Ui) {
159- crate::ui::feed_list::draw_feed_list(state, ui);
160-}
161-
162-/// Synchronizes all feeds asynchronously
163-fn sync_all_feeds(state: &mut AppState) {
164- if !state.has_feeds() {
165- state.notification_manager.info("Aucun flux à synchroniser");
166- return;
167- }
168-
169- log::info!("Starting asynchronous synchronization of all feeds");
170-
171- // Clone feeds for async task (we'll update state.feeds from messages)
172- let feeds_to_sync = state.feeds.clone();
173-
174- // Start async sync
175- state.sync_task_manager.start_sync_all_feeds(feeds_to_sync);
176-
177- state.notification_manager.info("Synchronisation de tous les flux en cours...");
178-}
added src/ui/menu_bar.rs +109 -0
new file mode 100644
@@ -0,0 +1,109 @@
1+//! Menu bar UI component
2+//!
3+//! Barre de menus principale de l'application : Fichier, Actions, Affichage.
4+
5+use crate::config::AppState;
6+use crate::config::Theme;
7+use egui::Ui;
8+
9+/// Draws the application menu bar
10+pub fn draw_menu(state: &mut AppState, ui: &mut Ui) {
11+ egui::MenuBar::new().ui(ui, |ui| {
12+ ui.menu_button("Fichier", |ui| {
13+ if ui.button("Ajouter un flux…\tCtrl+N").clicked() {
14+ state.clear_new_feed_form();
15+ state.show_add_feed_dialog = true;
16+ }
17+ ui.separator();
18+ if ui.button("Importer…").clicked() {
19+ state.clear_export_import_error();
20+ state.show_import_dialog = true;
21+ }
22+ if ui.button("Exporter…").clicked() {
23+ state.clear_export_import_error();
24+ state.show_export_dialog = true;
25+ }
26+ ui.separator();
27+ if ui.button("Quitter").clicked() {
28+ ui.ctx()
29+ .send_viewport_cmd(egui::ViewportCommand::Close);
30+ }
31+ });
32+
33+ ui.menu_button("Actions", |ui| {
34+ if ui.button("Synchroniser tous les flux\tF5").clicked() {
35+ state.start_sync_all_feeds();
36+ }
37+ ui.separator();
38+ if ui.button("Statistiques").clicked() {
39+ state.show_stats_window = !state.show_stats_window;
40+ }
41+ if ui.button("Historique").clicked() {
42+ state.show_history_window = !state.show_history_window;
43+ }
44+ });
45+
46+ ui.menu_button("Affichage", |ui| {
47+ let (label, icon) = match state.get_theme() {
48+ Theme::Dark => ("Thème clair", "☀️"),
49+ Theme::Light => ("Thème sombre", "🌙"),
50+ };
51+ if ui.button(format!("{} {}", icon, label)).clicked() {
52+ state.toggle_theme();
53+ }
54+ });
55+ });
56+}
57+
58+/// Handles global keyboard shortcuts
59+///
60+/// - Ctrl+N : ajouter un flux
61+/// - F5 : synchroniser tous les flux
62+/// - Ctrl+F : focus sur la recherche
63+/// - Échap : fermer la vue détaillée (si aucun dialogue ouvert)
64+pub fn handle_shortcuts(state: &mut AppState, ctx: &egui::Context) {
65+ let mut open_add_dialog = false;
66+ let mut sync_all = false;
67+ let mut focus_search = false;
68+ ctx.input(|i| {
69+ if i.modifiers.ctrl && i.key_pressed(egui::Key::N) {
70+ open_add_dialog = true;
71+ }
72+ if i.key_pressed(egui::Key::F5) {
73+ sync_all = true;
74+ }
75+ if i.modifiers.ctrl && i.key_pressed(egui::Key::F) {
76+ focus_search = true;
77+ }
78+ });
79+
80+ if open_add_dialog {
81+ state.clear_new_feed_form();
82+ state.show_add_feed_dialog = true;
83+ }
84+ if sync_all {
85+ state.start_sync_all_feeds();
86+ }
87+ if focus_search {
88+ state.focus_search = true;
89+ }
90+
91+ // Échap : fermer la vue détaillée uniquement si aucun dialogue n'est ouvert
92+ let any_dialog_open = state.show_add_feed_dialog
93+ || state.show_edit_feed_dialog
94+ || state.show_add_playlist_dialog
95+ || state.show_edit_playlist_dialog
96+ || state.show_export_dialog
97+ || state.show_import_dialog
98+ || state.show_date_picker_from
99+ || state.show_date_picker_to
100+ || state.confirm_delete_feed_id.is_some()
101+ || state.confirm_delete_playlist_id.is_some();
102+
103+ if !any_dialog_open
104+ && state.detail_feed_id.is_some()
105+ && ctx.input(|i| i.key_pressed(egui::Key::Escape))
106+ {
107+ state.close_feed_detail();
108+ }
109+}
new file mode 100644
@@ -0,0 +1,109 @@
1+//! Menu bar UI component
2+//!
3+//! Barre de menus principale de l'application : Fichier, Actions, Affichage.
4+
5+use crate::config::AppState;
6+use crate::config::Theme;
7+use egui::Ui;
8+
9+/// Draws the application menu bar
10+pub fn draw_menu(state: &mut AppState, ui: &mut Ui) {
11+ egui::MenuBar::new().ui(ui, |ui| {
12+ ui.menu_button("Fichier", |ui| {
13+ if ui.button("Ajouter un flux…\tCtrl+N").clicked() {
14+ state.clear_new_feed_form();
15+ state.show_add_feed_dialog = true;
16+ }
17+ ui.separator();
18+ if ui.button("Importer…").clicked() {
19+ state.clear_export_import_error();
20+ state.show_import_dialog = true;
21+ }
22+ if ui.button("Exporter…").clicked() {
23+ state.clear_export_import_error();
24+ state.show_export_dialog = true;
25+ }
26+ ui.separator();
27+ if ui.button("Quitter").clicked() {
28+ ui.ctx()
29+ .send_viewport_cmd(egui::ViewportCommand::Close);
30+ }
31+ });
32+
33+ ui.menu_button("Actions", |ui| {
34+ if ui.button("Synchroniser tous les flux\tF5").clicked() {
35+ state.start_sync_all_feeds();
36+ }
37+ ui.separator();
38+ if ui.button("Statistiques").clicked() {
39+ state.show_stats_window = !state.show_stats_window;
40+ }
41+ if ui.button("Historique").clicked() {
42+ state.show_history_window = !state.show_history_window;
43+ }
44+ });
45+
46+ ui.menu_button("Affichage", |ui| {
47+ let (label, icon) = match state.get_theme() {
48+ Theme::Dark => ("Thème clair", "☀️"),
49+ Theme::Light => ("Thème sombre", "🌙"),
50+ };
51+ if ui.button(format!("{} {}", icon, label)).clicked() {
52+ state.toggle_theme();
53+ }
54+ });
55+ });
56+}
57+
58+/// Handles global keyboard shortcuts
59+///
60+/// - Ctrl+N : ajouter un flux
61+/// - F5 : synchroniser tous les flux
62+/// - Ctrl+F : focus sur la recherche
63+/// - Échap : fermer la vue détaillée (si aucun dialogue ouvert)
64+pub fn handle_shortcuts(state: &mut AppState, ctx: &egui::Context) {
65+ let mut open_add_dialog = false;
66+ let mut sync_all = false;
67+ let mut focus_search = false;
68+ ctx.input(|i| {
69+ if i.modifiers.ctrl && i.key_pressed(egui::Key::N) {
70+ open_add_dialog = true;
71+ }
72+ if i.key_pressed(egui::Key::F5) {
73+ sync_all = true;
74+ }
75+ if i.modifiers.ctrl && i.key_pressed(egui::Key::F) {
76+ focus_search = true;
77+ }
78+ });
79+
80+ if open_add_dialog {
81+ state.clear_new_feed_form();
82+ state.show_add_feed_dialog = true;
83+ }
84+ if sync_all {
85+ state.start_sync_all_feeds();
86+ }
87+ if focus_search {
88+ state.focus_search = true;
89+ }
90+
91+ // Échap : fermer la vue détaillée uniquement si aucun dialogue n'est ouvert
92+ let any_dialog_open = state.show_add_feed_dialog
93+ || state.show_edit_feed_dialog
94+ || state.show_add_playlist_dialog
95+ || state.show_edit_playlist_dialog
96+ || state.show_export_dialog
97+ || state.show_import_dialog
98+ || state.show_date_picker_from
99+ || state.show_date_picker_to
100+ || state.confirm_delete_feed_id.is_some()
101+ || state.confirm_delete_playlist_id.is_some();
102+
103+ if !any_dialog_open
104+ && state.detail_feed_id.is_some()
105+ && ctx.input(|i| i.key_pressed(egui::Key::Escape))
106+ {
107+ state.close_feed_detail();
108+ }
109+}
modified src/ui/mod.rs +15 -0
@@ -4,6 +4,7 @@
44
55 pub mod home;
66 pub mod feed_list;
7+pub mod feed_detail;
78 pub mod add_feed_dialog;
89 pub mod feed_edit_dialog;
910 pub mod status_bar;
@@ -15,3 +16,17 @@ pub mod history_panel;
1516 pub mod search_bar;
1617 pub mod stats_panel;
1718 pub mod sync_status;
19+pub mod confirm_dialog;
20+pub mod menu_bar;
21+pub mod theme;
22+
23+/// Tronque une chaîne à `max_chars` caractères, sans couper au milieu
24+/// d'un caractère multi-octets (évite tout panic sur l'UTF-8).
25+pub fn truncate(text: &str, max_chars: usize) -> String {
26+ if text.chars().count() <= max_chars {
27+ return text.to_string();
28+ }
29+ let mut truncated: String = text.chars().take(max_chars.saturating_sub(1)).collect();
30+ truncated.push('…');
31+ truncated
32+}
@@ -4,6 +4,7 @@
4 4
5 pub mod home;5 pub mod home;
6 pub mod feed_list;6 pub mod feed_list;
7+pub mod feed_detail;
7 pub mod add_feed_dialog;8 pub mod add_feed_dialog;
8 pub mod feed_edit_dialog;9 pub mod feed_edit_dialog;
9 pub mod status_bar;10 pub mod status_bar;
@@ -15,3 +16,17 @@ pub mod history_panel;
15 pub mod search_bar;16 pub mod search_bar;
16 pub mod stats_panel;17 pub mod stats_panel;
17 pub mod sync_status;18 pub mod sync_status;
19+pub mod confirm_dialog;
20+pub mod menu_bar;
21+pub mod theme;
22+
23+/// Tronque une chaîne à `max_chars` caractères, sans couper au milieu
24+/// d'un caractère multi-octets (évite tout panic sur l'UTF-8).
25+pub fn truncate(text: &str, max_chars: usize) -> String {
26+ if text.chars().count() <= max_chars {
27+ return text.to_string();
28+ }
29+ let mut truncated: String = text.chars().take(max_chars.saturating_sub(1)).collect();
30+ truncated.push('…');
31+ truncated
32+}
modified src/ui/notifications.rs +116 -104
@@ -1,14 +1,18 @@
11 //! Notifications module for RSS Podcast Downloader
22 //!
33 //! This module handles displaying download notifications to the user.
4+//! Les notifications s'affichent en overlay flottant (coin haut droit)
5+//! et n'affectent pas la mise en page.
46
5-use egui::{Color32, Frame, RichText, Ui};
7+use egui::{Align, Color32, Context, Frame, Layout, RichText, Ui};
68 use std::collections::VecDeque;
79 use std::time::{Duration, Instant};
810
911 /// A notification to display to the user
1012 #[derive(Debug, Clone)]
1113 pub struct Notification {
14+ /// Unique id, used for dismissal
15+ pub id: u64,
1216 /// The message to display
1317 pub message: String,
1418 /// The type/severity of the notification
@@ -30,36 +34,6 @@ pub enum NotificationType {
3034 }
3135
3236 impl Notification {
33- /// Creates a new info notification
34- pub fn info(message: impl Into<String>) -> Self {
35- Self {
36- message: message.into(),
37- notification_type: NotificationType::Info,
38- created_at: Instant::now(),
39- duration: Duration::from_secs(5),
40- }
41- }
42-
43- /// Creates a new success notification
44- pub fn success(message: impl Into<String>) -> Self {
45- Self {
46- message: message.into(),
47- notification_type: NotificationType::Success,
48- created_at: Instant::now(),
49- duration: Duration::from_secs(5),
50- }
51- }
52-
53- /// Creates a new error notification
54- pub fn error(message: impl Into<String>) -> Self {
55- Self {
56- message: message.into(),
57- notification_type: NotificationType::Error,
58- created_at: Instant::now(),
59- duration: Duration::from_secs(7), // Longer for errors
60- }
61- }
62-
6337 /// Returns true if the notification should still be displayed
6438 pub fn is_visible(&self) -> bool {
6539 self.created_at.elapsed() < self.duration
@@ -68,9 +42,9 @@ impl Notification {
6842 /// Returns the color for this notification type
6943 pub fn color(&self) -> Color32 {
7044 match self.notification_type {
71- NotificationType::Info => Color32::from_rgb(70, 130, 180), // Steel blue
72- NotificationType::Success => Color32::from_rgb(34, 139, 34), // Forest green
73- NotificationType::Error => Color32::from_rgb(220, 53, 69), // Red
45+ NotificationType::Info => Color32::from_rgb(70, 130, 220),
46+ NotificationType::Success => Color32::from_rgb(50, 180, 80),
47+ NotificationType::Error => Color32::from_rgb(220, 80, 80),
7448 }
7549 }
7650
@@ -88,6 +62,8 @@ impl Notification {
8862 #[derive(Debug, Default)]
8963 pub struct NotificationManager {
9064 notifications: VecDeque<Notification>,
65+ /// Counter used to generate unique notification ids
66+ next_id: u64,
9167 }
9268
9369 impl NotificationManager {
@@ -97,25 +73,41 @@ impl NotificationManager {
9773 }
9874
9975 /// Adds a new notification
100- pub fn add_notification(&mut self, notification: Notification) {
101- // Remove expired notifications first
76+ fn push(&mut self, notification_type: NotificationType, message: String) {
10277 self.cleanup();
103- self.notifications.push_back(notification);
78+ let duration = match notification_type {
79+ NotificationType::Error => Duration::from_secs(7), // Longer for errors
80+ _ => Duration::from_secs(5),
81+ };
82+ let id = self.next_id;
83+ self.next_id += 1;
84+ self.notifications.push_back(Notification {
85+ id,
86+ message,
87+ notification_type,
88+ created_at: Instant::now(),
89+ duration,
90+ });
10491 }
10592
10693 /// Adds an info notification
10794 pub fn info(&mut self, message: impl Into<String>) {
108- self.add_notification(Notification::info(message));
95+ self.push(NotificationType::Info, message.into());
10996 }
11097
11198 /// Adds a success notification
11299 pub fn success(&mut self, message: impl Into<String>) {
113- self.add_notification(Notification::success(message));
100+ self.push(NotificationType::Success, message.into());
114101 }
115102
116103 /// Adds an error notification
117104 pub fn error(&mut self, message: impl Into<String>) {
118- self.add_notification(Notification::error(message));
105+ self.push(NotificationType::Error, message.into());
106+ }
107+
108+ /// Dismisses a notification by id (e.g. via its close button)
109+ pub fn dismiss(&mut self, id: u64) {
110+ self.notifications.retain(|n| n.id != id);
119111 }
120112
121113 /// Removes expired notifications
@@ -123,75 +115,95 @@ impl NotificationManager {
123115 self.notifications.retain(|n| n.is_visible());
124116 }
125117
126- /// Returns the list of active notifications
127- pub fn get_active_notifications(&self) -> Vec<&Notification> {
128- self.notifications.iter().filter(|n| n.is_visible()).collect()
129- }
130-
131- /// Draws all active notifications in the UI
132- pub fn draw(&mut self, ui: &mut Ui) {
118+ /// Draws all active notifications as a floating overlay in the top-right corner
119+ pub fn draw_overlay(&mut self, ctx: &Context) {
133120 self.cleanup();
134-
135- let active_notifications = self.get_active_notifications();
136-
137- if active_notifications.is_empty() {
121+
122+ if self.notifications.is_empty() {
138123 return;
139124 }
140125
141- // Draw notifications in a vertical stack
142- egui::ScrollArea::vertical().show(ui, |ui| {
143- for notification in active_notifications {
144- let progress = notification.created_at.elapsed().as_secs_f32() / notification.duration.as_secs_f32();
145-
146- let frame = Frame {
147- fill: notification.color().linear_multiply(0.2),
148- corner_radius: 5.0.into(),
149- inner_margin: egui::Margin::symmetric(10, 5),
150- ..Default::default()
151- };
152-
153- frame.show(ui, |ui| {
154- ui.horizontal(|ui| {
155- // Icon
156- ui.label(RichText::new(notification.icon()).color(notification.color()));
157- ui.add_space(8.0);
158-
159- // Message
160- ui.label(RichText::new(&notification.message).color(Color32::WHITE));
161-
162- // Progress bar (optional) - shows time remaining
163- if notification.duration.as_secs() > 0 {
164- ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
165- let bar_width = 100.0;
166- let bar_height = 4.0;
167- let fill_width = bar_width * (1.0 - progress.clamp(0.0, 1.0));
168-
169- ui.add_space(10.0);
170- // Background
171- let (_, bg_rect) = ui.allocate_space(egui::vec2(bar_width, bar_height));
172- ui.painter().rect_filled(
173- bg_rect,
174- 5.0,
175- Color32::from_black_alpha(128)
176- );
177- // Foreground (remaining time)
178- let fg_rect = egui::Rect {
179- min: bg_rect.min,
180- max: egui::Pos2::new(
181- bg_rect.min.x + fill_width,
182- bg_rect.max.y
183- )
184- };
185- ui.painter().rect_filled(
186- fg_rect,
187- 5.0,
188- notification.color()
189- );
190- });
126+ let notifications: Vec<Notification> = self.notifications.iter().cloned().collect();
127+ let mut dismissed: Vec<u64> = Vec::new();
128+
129+ egui::Area::new(egui::Id::new("notifications_overlay"))
130+ .anchor(egui::Align2::RIGHT_TOP, egui::vec2(-12.0, 12.0))
131+ .order(egui::Order::Foreground)
132+ .interactable(true)
133+ .show(ctx, |ui| {
134+ ui.set_width(360.0);
135+ ui.spacing_mut().item_spacing = egui::vec2(0.0, 6.0);
136+
137+ Frame::default()
138+ .inner_margin(egui::Margin::same(0))
139+ .show(ui, |ui| {
140+ for notification in &notifications {
141+ let progress = notification.created_at.elapsed().as_secs_f32()
142+ / notification.duration.as_secs_f32();
143+
144+ Frame::popup(ui.style())
145+ .inner_margin(egui::Margin::symmetric(10, 6))
146+ .stroke(egui::Stroke::new(1.0, notification.color()))
147+ .show(ui, |ui| {
148+ ui.horizontal(|ui| {
149+ ui.label(RichText::new(notification.icon()));
150+ ui.label(
151+ RichText::new(&notification.message)
152+ .color(notification.color())
153+ .strong(),
154+ );
155+
156+ ui.with_layout(
157+ Layout::right_to_left(Align::Center),
158+ |ui| {
159+ if ui.small_button("✕").clicked() {
160+ dismissed.push(notification.id);
161+ }
162+
163+ // Petite barre de temps restant
164+ let bar_width = 60.0;
165+ let (_, bg_rect) =
166+ ui.allocate_space(egui::vec2(bar_width, 4.0));
167+ ui.painter().rect_filled(
168+ bg_rect,
169+ 2.0,
170+ Color32::from_black_alpha(120),
171+ );
172+ let fill_width =
173+ bar_width * (1.0 - progress.clamp(0.0, 1.0));
174+ let fg_rect = egui::Rect {
175+ min: bg_rect.min,
176+ max: egui::Pos2::new(
177+ bg_rect.min.x + fill_width,
178+ bg_rect.max.y,
179+ ),
180+ };
181+ ui.painter().rect_filled(
182+ fg_rect,
183+ 2.0,
184+ notification.color(),
185+ );
186+ },
187+ );
188+ });
189+ });
191190 }
192191 });
193- });
194- }
195- });
192+ });
193+
194+ for id in dismissed {
195+ self.dismiss(id);
196+ }
197+ }
198+
199+ /// Draws notifications inline (used by the history of this API, e.g. tests)
200+ #[allow(dead_code)]
201+ pub fn draw(&mut self, ui: &mut Ui) {
202+ self.cleanup();
203+ let active: Vec<&Notification> =
204+ self.notifications.iter().filter(|n| n.is_visible()).collect();
205+ for notification in active {
206+ ui.label(format!("{} {}", notification.icon(), notification.message));
207+ }
196208 }
197209 }
@@ -1,14 +1,18 @@
1 //! Notifications module for RSS Podcast Downloader1 //! Notifications module for RSS Podcast Downloader
2 //!2 //!
3 //! This module handles displaying download notifications to the user.3 //! This module handles displaying download notifications to the user.
4+//! Les notifications s'affichent en overlay flottant (coin haut droit)
5+//! et n'affectent pas la mise en page.
4 6
5-use egui::{Color32, Frame, RichText, Ui};7+use egui::{Align, Color32, Context, Frame, Layout, RichText, Ui};
6 use std::collections::VecDeque;8 use std::collections::VecDeque;
7 use std::time::{Duration, Instant};9 use std::time::{Duration, Instant};
8 10
9 /// A notification to display to the user11 /// A notification to display to the user
10 #[derive(Debug, Clone)]12 #[derive(Debug, Clone)]
11 pub struct Notification {13 pub struct Notification {
14+ /// Unique id, used for dismissal
15+ pub id: u64,
12 /// The message to display16 /// The message to display
13 pub message: String,17 pub message: String,
14 /// The type/severity of the notification18 /// The type/severity of the notification
@@ -30,36 +34,6 @@ pub enum NotificationType {
30 }34 }
31 35
32 impl Notification {36 impl Notification {
33- /// Creates a new info notification
34- pub fn info(message: impl Into<String>) -> Self {
35- Self {
36- message: message.into(),
37- notification_type: NotificationType::Info,
38- created_at: Instant::now(),
39- duration: Duration::from_secs(5),
40- }
41- }
42-
43- /// Creates a new success notification
44- pub fn success(message: impl Into<String>) -> Self {
45- Self {
46- message: message.into(),
47- notification_type: NotificationType::Success,
48- created_at: Instant::now(),
49- duration: Duration::from_secs(5),
50- }
51- }
52-
53- /// Creates a new error notification
54- pub fn error(message: impl Into<String>) -> Self {
55- Self {
56- message: message.into(),
57- notification_type: NotificationType::Error,
58- created_at: Instant::now(),
59- duration: Duration::from_secs(7), // Longer for errors
60- }
61- }
62-
63 /// Returns true if the notification should still be displayed37 /// Returns true if the notification should still be displayed
64 pub fn is_visible(&self) -> bool {38 pub fn is_visible(&self) -> bool {
65 self.created_at.elapsed() < self.duration39 self.created_at.elapsed() < self.duration
@@ -68,9 +42,9 @@ impl Notification {
68 /// Returns the color for this notification type42 /// Returns the color for this notification type
69 pub fn color(&self) -> Color32 {43 pub fn color(&self) -> Color32 {
70 match self.notification_type {44 match self.notification_type {
71- NotificationType::Info => Color32::from_rgb(70, 130, 180), // Steel blue45+ NotificationType::Info => Color32::from_rgb(70, 130, 220),
72- NotificationType::Success => Color32::from_rgb(34, 139, 34), // Forest green46+ NotificationType::Success => Color32::from_rgb(50, 180, 80),
73- NotificationType::Error => Color32::from_rgb(220, 53, 69), // Red47+ NotificationType::Error => Color32::from_rgb(220, 80, 80),
74 }48 }
75 }49 }
76 50
@@ -88,6 +62,8 @@ impl Notification {
88 #[derive(Debug, Default)]62 #[derive(Debug, Default)]
89 pub struct NotificationManager {63 pub struct NotificationManager {
90 notifications: VecDeque<Notification>,64 notifications: VecDeque<Notification>,
65+ /// Counter used to generate unique notification ids
66+ next_id: u64,
91 }67 }
92 68
93 impl NotificationManager {69 impl NotificationManager {
@@ -97,25 +73,41 @@ impl NotificationManager {
97 }73 }
98 74
99 /// Adds a new notification75 /// Adds a new notification
100- pub fn add_notification(&mut self, notification: Notification) {76+ fn push(&mut self, notification_type: NotificationType, message: String) {
101- // Remove expired notifications first
102 self.cleanup();77 self.cleanup();
103- self.notifications.push_back(notification);78+ let duration = match notification_type {
79+ NotificationType::Error => Duration::from_secs(7), // Longer for errors
80+ _ => Duration::from_secs(5),
81+ };
82+ let id = self.next_id;
83+ self.next_id += 1;
84+ self.notifications.push_back(Notification {
85+ id,
86+ message,
87+ notification_type,
88+ created_at: Instant::now(),
89+ duration,
90+ });
104 }91 }
105 92
106 /// Adds an info notification93 /// Adds an info notification
107 pub fn info(&mut self, message: impl Into<String>) {94 pub fn info(&mut self, message: impl Into<String>) {
108- self.add_notification(Notification::info(message));95+ self.push(NotificationType::Info, message.into());
109 }96 }
110 97
111 /// Adds a success notification98 /// Adds a success notification
112 pub fn success(&mut self, message: impl Into<String>) {99 pub fn success(&mut self, message: impl Into<String>) {
113- self.add_notification(Notification::success(message));100+ self.push(NotificationType::Success, message.into());
114 }101 }
115 102
116 /// Adds an error notification103 /// Adds an error notification
117 pub fn error(&mut self, message: impl Into<String>) {104 pub fn error(&mut self, message: impl Into<String>) {
118- self.add_notification(Notification::error(message));105+ self.push(NotificationType::Error, message.into());
106+ }
107+
108+ /// Dismisses a notification by id (e.g. via its close button)
109+ pub fn dismiss(&mut self, id: u64) {
110+ self.notifications.retain(|n| n.id != id);
119 }111 }
120 112
121 /// Removes expired notifications113 /// Removes expired notifications
@@ -123,75 +115,95 @@ impl NotificationManager {
123 self.notifications.retain(|n| n.is_visible());115 self.notifications.retain(|n| n.is_visible());
124 }116 }
125 117
126- /// Returns the list of active notifications118+ /// Draws all active notifications as a floating overlay in the top-right corner
127- pub fn get_active_notifications(&self) -> Vec<&Notification> {119+ pub fn draw_overlay(&mut self, ctx: &Context) {
128- self.notifications.iter().filter(|n| n.is_visible()).collect()
129- }
130-
131- /// Draws all active notifications in the UI
132- pub fn draw(&mut self, ui: &mut Ui) {
133 self.cleanup();120 self.cleanup();
134- 121+
135- let active_notifications = self.get_active_notifications();122+ if self.notifications.is_empty() {
136-
137- if active_notifications.is_empty() {
138 return;123 return;
139 }124 }
140 125
141- // Draw notifications in a vertical stack126+ let notifications: Vec<Notification> = self.notifications.iter().cloned().collect();
142- egui::ScrollArea::vertical().show(ui, |ui| {127+ let mut dismissed: Vec<u64> = Vec::new();
143- for notification in active_notifications {128+
144- let progress = notification.created_at.elapsed().as_secs_f32() / notification.duration.as_secs_f32();129+ egui::Area::new(egui::Id::new("notifications_overlay"))
145- 130+ .anchor(egui::Align2::RIGHT_TOP, egui::vec2(-12.0, 12.0))
146- let frame = Frame {131+ .order(egui::Order::Foreground)
147- fill: notification.color().linear_multiply(0.2),132+ .interactable(true)
148- corner_radius: 5.0.into(),133+ .show(ctx, |ui| {
149- inner_margin: egui::Margin::symmetric(10, 5),134+ ui.set_width(360.0);
150- ..Default::default()135+ ui.spacing_mut().item_spacing = egui::vec2(0.0, 6.0);
151- };136+
152-137+ Frame::default()
153- frame.show(ui, |ui| {138+ .inner_margin(egui::Margin::same(0))
154- ui.horizontal(|ui| {139+ .show(ui, |ui| {
155- // Icon140+ for notification in &notifications {
156- ui.label(RichText::new(notification.icon()).color(notification.color()));141+ let progress = notification.created_at.elapsed().as_secs_f32()
157- ui.add_space(8.0);142+ / notification.duration.as_secs_f32();
158- 143+
159- // Message144+ Frame::popup(ui.style())
160- ui.label(RichText::new(&notification.message).color(Color32::WHITE));145+ .inner_margin(egui::Margin::symmetric(10, 6))
161- 146+ .stroke(egui::Stroke::new(1.0, notification.color()))
162- // Progress bar (optional) - shows time remaining147+ .show(ui, |ui| {
163- if notification.duration.as_secs() > 0 {148+ ui.horizontal(|ui| {
164- ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {149+ ui.label(RichText::new(notification.icon()));
165- let bar_width = 100.0;150+ ui.label(
166- let bar_height = 4.0;151+ RichText::new(&notification.message)
167- let fill_width = bar_width * (1.0 - progress.clamp(0.0, 1.0));152+ .color(notification.color())
168- 153+ .strong(),
169- ui.add_space(10.0);154+ );
170- // Background155+
171- let (_, bg_rect) = ui.allocate_space(egui::vec2(bar_width, bar_height));156+ ui.with_layout(
172- ui.painter().rect_filled(157+ Layout::right_to_left(Align::Center),
173- bg_rect,158+ |ui| {
174- 5.0,159+ if ui.small_button("✕").clicked() {
175- Color32::from_black_alpha(128)160+ dismissed.push(notification.id);
176- );161+ }
177- // Foreground (remaining time)162+
178- let fg_rect = egui::Rect {163+ // Petite barre de temps restant
179- min: bg_rect.min,164+ let bar_width = 60.0;
180- max: egui::Pos2::new(165+ let (_, bg_rect) =
181- bg_rect.min.x + fill_width,166+ ui.allocate_space(egui::vec2(bar_width, 4.0));
182- bg_rect.max.y167+ ui.painter().rect_filled(
183- )168+ bg_rect,
184- };169+ 2.0,
185- ui.painter().rect_filled(170+ Color32::from_black_alpha(120),
186- fg_rect,171+ );
187- 5.0,172+ let fill_width =
188- notification.color()173+ bar_width * (1.0 - progress.clamp(0.0, 1.0));
189- );174+ let fg_rect = egui::Rect {
190- });175+ min: bg_rect.min,
176+ max: egui::Pos2::new(
177+ bg_rect.min.x + fill_width,
178+ bg_rect.max.y,
179+ ),
180+ };
181+ ui.painter().rect_filled(
182+ fg_rect,
183+ 2.0,
184+ notification.color(),
185+ );
186+ },
187+ );
188+ });
189+ });
191 }190 }
192 });191 });
193- });192+ });
194- }193+
195- });194+ for id in dismissed {
195+ self.dismiss(id);
196+ }
197+ }
198+
199+ /// Draws notifications inline (used by the history of this API, e.g. tests)
200+ #[allow(dead_code)]
201+ pub fn draw(&mut self, ui: &mut Ui) {
202+ self.cleanup();
203+ let active: Vec<&Notification> =
204+ self.notifications.iter().filter(|n| n.is_visible()).collect();
205+ for notification in active {
206+ ui.label(format!("{} {}", notification.icon(), notification.message));
207+ }
196 }208 }
197 }209 }
modified src/ui/playlist_list.rs +111 -93
@@ -4,12 +4,12 @@
44
55 use crate::config::playlist::Playlist;
66 use crate::config::AppState;
7-use egui::{Align, Layout, ScrollArea, Ui};
7+use egui::{Align, Layout, Ui};
88
99 /// Draws the playlist list
1010 pub fn draw_playlist_list(state: &mut AppState, ui: &mut Ui) {
1111 ui.horizontal(|ui| {
12- ui.label("Playlists:");
12+ ui.strong("Playlists:");
1313 if ui.button("➕ Ajouter").clicked() {
1414 state.show_add_playlist_dialog = true;
1515 }
@@ -22,25 +22,24 @@ pub fn draw_playlist_list(state: &mut AppState, ui: &mut Ui) {
2222 return;
2323 }
2424
25- ScrollArea::vertical().show(ui, |ui| {
26- let playlist_count = state.playlists.len();
27- for index in 0..playlist_count {
28- draw_playlist_item(state, ui, index);
29- }
30- });
25+ let playlist_count = state.playlists.len();
26+ for index in 0..playlist_count {
27+ draw_playlist_item(state, ui, index);
28+ }
3129 }
3230
3331 /// Draws a single playlist item
3432 fn draw_playlist_item(state: &mut AppState, ui: &mut Ui, index: usize) {
3533 let playlist = &state.playlists[index];
34+ let playlist_id = playlist.id.clone();
3635 let playlist_name = playlist.name.clone();
3736 let feed_count = playlist.feed_count();
3837
3938 ui.horizontal(|ui| {
4039 // Playlist name and info
4140 ui.vertical(|ui| {
42- ui.label(&playlist_name);
43- ui.label(format!("{} flux", feed_count));
41+ ui.label(egui::RichText::new(&playlist_name).strong());
42+ ui.label(egui::RichText::new(format!("{} flux", feed_count)).small().weak());
4443 });
4544
4645 // Actions
@@ -52,12 +51,9 @@ fn draw_playlist_item(state: &mut AppState, ui: &mut Ui, index: usize) {
5251 state.show_edit_playlist_dialog = true;
5352 }
5453
55- // Delete button
54+ // Delete button (with confirmation)
5655 if ui.button("🗑️").on_hover_text("Supprimer").clicked() {
57- if let Some(playlist) = state.remove_playlist(index) {
58- log::info!("Deleted playlist: {}", playlist.name);
59- super::super::config::storage::save_playlists_default(&state.playlists).ok();
60- }
56+ state.confirm_delete_playlist_id = Some(playlist_id.clone());
6157 }
6258 });
6359 });
@@ -65,53 +61,63 @@ fn draw_playlist_item(state: &mut AppState, ui: &mut Ui, index: usize) {
6561 ui.separator();
6662 }
6763
68-/// Draws the add playlist dialog
64+/// Draws the add playlist dialog (modal)
6965 pub fn draw_add_playlist_dialog(state: &mut AppState, ui: &mut Ui) {
7066 if !state.show_add_playlist_dialog {
7167 return;
7268 }
7369
74- egui::Window::new("Ajouter une playlist")
75- .resizable(false)
76- .show(ui.ctx(), |ui| {
77- ui.vertical(|ui| {
78- ui.label("Nouvelle playlist");
79- ui.separator();
80-
81- ui.label("Nom de la playlist");
82- ui.text_edit_singleline(&mut state.add_playlist_name);
83-
84- ui.label("Description (optionnel)");
85- ui.text_edit_multiline(&mut state.add_playlist_description);
86-
87- ui.separator();
88-
89- ui.horizontal(|ui| {
90- if !state.add_playlist_name.is_empty() && ui.button("Ajouter").clicked() {
91- log::info!("Adding new playlist: {}", state.add_playlist_name);
92- let mut playlist = Playlist::new(state.add_playlist_name.clone());
93- if !state.add_playlist_description.is_empty() {
94- playlist.set_description(state.add_playlist_description.clone());
95- }
96- state.add_playlist(playlist);
97- super::super::config::storage::save_playlists_default(&state.playlists).ok();
98- log::info!("Playlist added successfully");
99- state.clear_add_playlist_form();
100- state.show_add_playlist_dialog = false;
101- } else {
102- ui.label("Veuillez entrer un nom");
103- }
104-
105- if ui.button("Annuler").clicked() {
106- state.clear_add_playlist_form();
107- state.show_add_playlist_dialog = false;
108- }
109- });
110- });
70+ let ctx = ui.ctx().clone();
71+ let modal = egui::Modal::new(egui::Id::new("add_playlist_dialog"));
72+ let response = modal.show(&ctx, |ui| {
73+ ui.set_min_width(380.0);
74+ ui.heading("Ajouter une playlist");
75+ ui.separator();
76+
77+ ui.label("Nom de la playlist");
78+ ui.text_edit_singleline(&mut state.add_playlist_name);
79+
80+ ui.label("Description (optionnel)");
81+ ui.text_edit_multiline(&mut state.add_playlist_description);
82+
83+ ui.separator();
84+
85+ let valid = !state.add_playlist_name.trim().is_empty();
86+ let mut submitted = false;
87+ ui.horizontal(|ui| {
88+ if ui.add_enabled(valid, egui::Button::new("Ajouter")).clicked() {
89+ submitted = true;
90+ }
91+ if ui.button("Annuler").clicked() {
92+ state.clear_add_playlist_form();
93+ state.show_add_playlist_dialog = false;
94+ }
11195 });
96+ if !valid {
97+ ui.label(egui::RichText::new("Veuillez entrer un nom.").weak().small());
98+ }
99+
100+ submitted
101+ });
102+
103+ if response.inner {
104+ log::info!("Adding new playlist: {}", state.add_playlist_name);
105+ let mut playlist = Playlist::new(state.add_playlist_name.trim().to_string());
106+ if !state.add_playlist_description.is_empty() {
107+ playlist.set_description(state.add_playlist_description.clone());
108+ }
109+ state.add_playlist(playlist);
110+ super::super::config::storage::save_playlists_default(&state.playlists).ok();
111+ log::info!("Playlist added successfully");
112+ state.clear_add_playlist_form();
113+ state.show_add_playlist_dialog = false;
114+ } else if response.should_close() {
115+ state.clear_add_playlist_form();
116+ state.show_add_playlist_dialog = false;
117+ }
112118 }
113119
114-/// Draws the edit playlist dialog
120+/// Draws the edit playlist dialog (modal)
115121 pub fn draw_edit_playlist_dialog(state: &mut AppState, ui: &mut Ui) {
116122 let editing_index = match state.editing_playlist_index {
117123 Some(idx) => idx,
@@ -120,47 +126,59 @@ pub fn draw_edit_playlist_dialog(state: &mut AppState, ui: &mut Ui) {
120126
121127 if editing_index >= state.playlists.len() {
122128 state.clear_edit_playlist_form();
129+ state.show_edit_playlist_dialog = false;
123130 return;
124131 }
125132
126- egui::Window::new("Éditer la playlist")
127- .resizable(false)
128- .show(ui.ctx(), |ui| {
129- ui.vertical(|ui| {
130- ui.label(format!("Éditer: {}", state.edit_playlist_name));
131- ui.separator();
132-
133- ui.label("Nom de la playlist");
134- ui.text_edit_singleline(&mut state.edit_playlist_name);
135-
136- ui.label("Description (optionnel)");
137- ui.text_edit_multiline(&mut state.edit_playlist_description);
138-
139- ui.separator();
140-
141- ui.horizontal(|ui| {
142- if !state.edit_playlist_name.is_empty() && ui.button("Sauvegarder").clicked() {
143- log::info!("Updating playlist at index {}", editing_index);
144- state.playlists[editing_index].name = state.edit_playlist_name.clone();
145- if !state.edit_playlist_description.is_empty() {
146- state.playlists[editing_index].set_description(state.edit_playlist_description.clone());
147- } else {
148- state.playlists[editing_index].clear_description();
149- }
150- super::super::config::storage::save_playlists_default(&state.playlists).ok();
151- log::info!("Playlist updated successfully");
152- state.notification_manager.success("Playlist modifiée");
153- state.clear_edit_playlist_form();
154- state.show_edit_playlist_dialog = false;
155- } else {
156- ui.label("Veuillez entrer un nom");
157- }
158-
159- if ui.button("Annuler").clicked() {
160- state.clear_edit_playlist_form();
161- state.show_edit_playlist_dialog = false;
162- }
163- });
164- });
133+ let ctx = ui.ctx().clone();
134+ let modal = egui::Modal::new(egui::Id::new("edit_playlist_dialog"));
135+ let response = modal.show(&ctx, |ui| {
136+ ui.set_min_width(380.0);
137+ ui.heading("Éditer la playlist");
138+ ui.separator();
139+
140+ ui.label("Nom de la playlist");
141+ ui.text_edit_singleline(&mut state.edit_playlist_name);
142+
143+ ui.label("Description (optionnel)");
144+ ui.text_edit_multiline(&mut state.edit_playlist_description);
145+
146+ ui.separator();
147+
148+ let valid = !state.edit_playlist_name.trim().is_empty();
149+ let mut submitted = false;
150+ ui.horizontal(|ui| {
151+ if ui.add_enabled(valid, egui::Button::new("Sauvegarder")).clicked() {
152+ submitted = true;
153+ }
154+ if ui.button("Annuler").clicked() {
155+ state.clear_edit_playlist_form();
156+ state.show_edit_playlist_dialog = false;
157+ }
165158 });
159+ if !valid {
160+ ui.label(egui::RichText::new("Veuillez entrer un nom.").weak().small());
161+ }
162+
163+ submitted
164+ });
165+
166+ if response.inner {
167+ log::info!("Updating playlist at index {}", editing_index);
168+ let playlist = &mut state.playlists[editing_index];
169+ playlist.name = state.edit_playlist_name.trim().to_string();
170+ if !state.edit_playlist_description.is_empty() {
171+ playlist.set_description(state.edit_playlist_description.clone());
172+ } else {
173+ playlist.clear_description();
174+ }
175+ super::super::config::storage::save_playlists_default(&state.playlists).ok();
176+ log::info!("Playlist updated successfully");
177+ state.notification_manager.success("Playlist modifiée");
178+ state.clear_edit_playlist_form();
179+ state.show_edit_playlist_dialog = false;
180+ } else if response.should_close() {
181+ state.clear_edit_playlist_form();
182+ state.show_edit_playlist_dialog = false;
183+ }
166184 }
@@ -4,12 +4,12 @@
4 4
5 use crate::config::playlist::Playlist;5 use crate::config::playlist::Playlist;
6 use crate::config::AppState;6 use crate::config::AppState;
7-use egui::{Align, Layout, ScrollArea, Ui};7+use egui::{Align, Layout, Ui};
8 8
9 /// Draws the playlist list9 /// Draws the playlist list
10 pub fn draw_playlist_list(state: &mut AppState, ui: &mut Ui) {10 pub fn draw_playlist_list(state: &mut AppState, ui: &mut Ui) {
11 ui.horizontal(|ui| {11 ui.horizontal(|ui| {
12- ui.label("Playlists:");12+ ui.strong("Playlists:");
13 if ui.button("➕ Ajouter").clicked() {13 if ui.button("➕ Ajouter").clicked() {
14 state.show_add_playlist_dialog = true;14 state.show_add_playlist_dialog = true;
15 }15 }
@@ -22,25 +22,24 @@ pub fn draw_playlist_list(state: &mut AppState, ui: &mut Ui) {
22 return;22 return;
23 }23 }
24 24
25- ScrollArea::vertical().show(ui, |ui| {25+ let playlist_count = state.playlists.len();
26- let playlist_count = state.playlists.len();26+ for index in 0..playlist_count {
27- for index in 0..playlist_count {27+ draw_playlist_item(state, ui, index);
28- draw_playlist_item(state, ui, index);28+ }
29- }
30- });
31 }29 }
32 30
33 /// Draws a single playlist item31 /// Draws a single playlist item
34 fn draw_playlist_item(state: &mut AppState, ui: &mut Ui, index: usize) {32 fn draw_playlist_item(state: &mut AppState, ui: &mut Ui, index: usize) {
35 let playlist = &state.playlists[index];33 let playlist = &state.playlists[index];
34+ let playlist_id = playlist.id.clone();
36 let playlist_name = playlist.name.clone();35 let playlist_name = playlist.name.clone();
37 let feed_count = playlist.feed_count();36 let feed_count = playlist.feed_count();
38 37
39 ui.horizontal(|ui| {38 ui.horizontal(|ui| {
40 // Playlist name and info39 // Playlist name and info
41 ui.vertical(|ui| {40 ui.vertical(|ui| {
42- ui.label(&playlist_name);41+ ui.label(egui::RichText::new(&playlist_name).strong());
43- ui.label(format!("{} flux", feed_count));42+ ui.label(egui::RichText::new(format!("{} flux", feed_count)).small().weak());
44 });43 });
45 44
46 // Actions45 // Actions
@@ -52,12 +51,9 @@ fn draw_playlist_item(state: &mut AppState, ui: &mut Ui, index: usize) {
52 state.show_edit_playlist_dialog = true;51 state.show_edit_playlist_dialog = true;
53 }52 }
54 53
55- // Delete button54+ // Delete button (with confirmation)
56 if ui.button("🗑️").on_hover_text("Supprimer").clicked() {55 if ui.button("🗑️").on_hover_text("Supprimer").clicked() {
57- if let Some(playlist) = state.remove_playlist(index) {56+ state.confirm_delete_playlist_id = Some(playlist_id.clone());
58- log::info!("Deleted playlist: {}", playlist.name);
59- super::super::config::storage::save_playlists_default(&state.playlists).ok();
60- }
61 }57 }
62 });58 });
63 });59 });
@@ -65,53 +61,63 @@ fn draw_playlist_item(state: &mut AppState, ui: &mut Ui, index: usize) {
65 ui.separator();61 ui.separator();
66 }62 }
67 63
68-/// Draws the add playlist dialog64+/// Draws the add playlist dialog (modal)
69 pub fn draw_add_playlist_dialog(state: &mut AppState, ui: &mut Ui) {65 pub fn draw_add_playlist_dialog(state: &mut AppState, ui: &mut Ui) {
70 if !state.show_add_playlist_dialog {66 if !state.show_add_playlist_dialog {
71 return;67 return;
72 }68 }
73 69
74- egui::Window::new("Ajouter une playlist")70+ let ctx = ui.ctx().clone();
75- .resizable(false)71+ let modal = egui::Modal::new(egui::Id::new("add_playlist_dialog"));
76- .show(ui.ctx(), |ui| {72+ let response = modal.show(&ctx, |ui| {
77- ui.vertical(|ui| {73+ ui.set_min_width(380.0);
78- ui.label("Nouvelle playlist");74+ ui.heading("Ajouter une playlist");
79- ui.separator();75+ ui.separator();
80-76+
81- ui.label("Nom de la playlist");77+ ui.label("Nom de la playlist");
82- ui.text_edit_singleline(&mut state.add_playlist_name);78+ ui.text_edit_singleline(&mut state.add_playlist_name);
83-79+
84- ui.label("Description (optionnel)");80+ ui.label("Description (optionnel)");
85- ui.text_edit_multiline(&mut state.add_playlist_description);81+ ui.text_edit_multiline(&mut state.add_playlist_description);
86-82+
87- ui.separator();83+ ui.separator();
88-84+
89- ui.horizontal(|ui| {85+ let valid = !state.add_playlist_name.trim().is_empty();
90- if !state.add_playlist_name.is_empty() && ui.button("Ajouter").clicked() {86+ let mut submitted = false;
91- log::info!("Adding new playlist: {}", state.add_playlist_name);87+ ui.horizontal(|ui| {
92- let mut playlist = Playlist::new(state.add_playlist_name.clone());88+ if ui.add_enabled(valid, egui::Button::new("Ajouter")).clicked() {
93- if !state.add_playlist_description.is_empty() {89+ submitted = true;
94- playlist.set_description(state.add_playlist_description.clone());90+ }
95- }91+ if ui.button("Annuler").clicked() {
96- state.add_playlist(playlist);92+ state.clear_add_playlist_form();
97- super::super::config::storage::save_playlists_default(&state.playlists).ok();93+ state.show_add_playlist_dialog = false;
98- log::info!("Playlist added successfully");94+ }
99- state.clear_add_playlist_form();
100- state.show_add_playlist_dialog = false;
101- } else {
102- ui.label("Veuillez entrer un nom");
103- }
104-
105- if ui.button("Annuler").clicked() {
106- state.clear_add_playlist_form();
107- state.show_add_playlist_dialog = false;
108- }
109- });
110- });
111 });95 });
96+ if !valid {
97+ ui.label(egui::RichText::new("Veuillez entrer un nom.").weak().small());
98+ }
99+
100+ submitted
101+ });
102+
103+ if response.inner {
104+ log::info!("Adding new playlist: {}", state.add_playlist_name);
105+ let mut playlist = Playlist::new(state.add_playlist_name.trim().to_string());
106+ if !state.add_playlist_description.is_empty() {
107+ playlist.set_description(state.add_playlist_description.clone());
108+ }
109+ state.add_playlist(playlist);
110+ super::super::config::storage::save_playlists_default(&state.playlists).ok();
111+ log::info!("Playlist added successfully");
112+ state.clear_add_playlist_form();
113+ state.show_add_playlist_dialog = false;
114+ } else if response.should_close() {
115+ state.clear_add_playlist_form();
116+ state.show_add_playlist_dialog = false;
117+ }
112 }118 }
113 119
114-/// Draws the edit playlist dialog120+/// Draws the edit playlist dialog (modal)
115 pub fn draw_edit_playlist_dialog(state: &mut AppState, ui: &mut Ui) {121 pub fn draw_edit_playlist_dialog(state: &mut AppState, ui: &mut Ui) {
116 let editing_index = match state.editing_playlist_index {122 let editing_index = match state.editing_playlist_index {
117 Some(idx) => idx,123 Some(idx) => idx,
@@ -120,47 +126,59 @@ pub fn draw_edit_playlist_dialog(state: &mut AppState, ui: &mut Ui) {
120 126
121 if editing_index >= state.playlists.len() {127 if editing_index >= state.playlists.len() {
122 state.clear_edit_playlist_form();128 state.clear_edit_playlist_form();
129+ state.show_edit_playlist_dialog = false;
123 return;130 return;
124 }131 }
125 132
126- egui::Window::new("Éditer la playlist")133+ let ctx = ui.ctx().clone();
127- .resizable(false)134+ let modal = egui::Modal::new(egui::Id::new("edit_playlist_dialog"));
128- .show(ui.ctx(), |ui| {135+ let response = modal.show(&ctx, |ui| {
129- ui.vertical(|ui| {136+ ui.set_min_width(380.0);
130- ui.label(format!("Éditer: {}", state.edit_playlist_name));137+ ui.heading("Éditer la playlist");
131- ui.separator();138+ ui.separator();
132-139+
133- ui.label("Nom de la playlist");140+ ui.label("Nom de la playlist");
134- ui.text_edit_singleline(&mut state.edit_playlist_name);141+ ui.text_edit_singleline(&mut state.edit_playlist_name);
135-142+
136- ui.label("Description (optionnel)");143+ ui.label("Description (optionnel)");
137- ui.text_edit_multiline(&mut state.edit_playlist_description);144+ ui.text_edit_multiline(&mut state.edit_playlist_description);
138-145+
139- ui.separator();146+ ui.separator();
140-147+
141- ui.horizontal(|ui| {148+ let valid = !state.edit_playlist_name.trim().is_empty();
142- if !state.edit_playlist_name.is_empty() && ui.button("Sauvegarder").clicked() {149+ let mut submitted = false;
143- log::info!("Updating playlist at index {}", editing_index);150+ ui.horizontal(|ui| {
144- state.playlists[editing_index].name = state.edit_playlist_name.clone();151+ if ui.add_enabled(valid, egui::Button::new("Sauvegarder")).clicked() {
145- if !state.edit_playlist_description.is_empty() {152+ submitted = true;
146- state.playlists[editing_index].set_description(state.edit_playlist_description.clone());153+ }
147- } else {154+ if ui.button("Annuler").clicked() {
148- state.playlists[editing_index].clear_description();155+ state.clear_edit_playlist_form();
149- }156+ state.show_edit_playlist_dialog = false;
150- super::super::config::storage::save_playlists_default(&state.playlists).ok();157+ }
151- log::info!("Playlist updated successfully");
152- state.notification_manager.success("Playlist modifiée");
153- state.clear_edit_playlist_form();
154- state.show_edit_playlist_dialog = false;
155- } else {
156- ui.label("Veuillez entrer un nom");
157- }
158-
159- if ui.button("Annuler").clicked() {
160- state.clear_edit_playlist_form();
161- state.show_edit_playlist_dialog = false;
162- }
163- });
164- });
165 });158 });
159+ if !valid {
160+ ui.label(egui::RichText::new("Veuillez entrer un nom.").weak().small());
161+ }
162+
163+ submitted
164+ });
165+
166+ if response.inner {
167+ log::info!("Updating playlist at index {}", editing_index);
168+ let playlist = &mut state.playlists[editing_index];
169+ playlist.name = state.edit_playlist_name.trim().to_string();
170+ if !state.edit_playlist_description.is_empty() {
171+ playlist.set_description(state.edit_playlist_description.clone());
172+ } else {
173+ playlist.clear_description();
174+ }
175+ super::super::config::storage::save_playlists_default(&state.playlists).ok();
176+ log::info!("Playlist updated successfully");
177+ state.notification_manager.success("Playlist modifiée");
178+ state.clear_edit_playlist_form();
179+ state.show_edit_playlist_dialog = false;
180+ } else if response.should_close() {
181+ state.clear_edit_playlist_form();
182+ state.show_edit_playlist_dialog = false;
183+ }
166 }184 }
modified src/ui/search_bar.rs +78 -127
@@ -1,137 +1,88 @@
1-#![allow(dead_code)] // Fonctionnalité prévue dans la spec (Phase 9+), pas encore branchée dans l'UI
2-use crate::config::search::{SearchQuery, SearchType};
3-use eframe::egui;
4-
5-/// Barre de recherche pour l'application
6-#[derive(Default)]
7-pub struct SearchBar {
8- /// Requête de recherche active
9- pub query: SearchQuery,
10- /// Focus sur la barre de recherche
11- focus: bool,
12-}
13-
14-
15-impl SearchBar {
16- /// Créer une nouvelle barre de recherche
17- pub fn new() -> Self {
18- Self::default()
19- }
20-
21- /// Créer une barre de recherche avec une requête initiale
22- pub fn with_query(query: SearchQuery) -> Self {
23- Self {
24- query,
25- focus: false,
26- }
27- }
28-
29- /// Effacer la requête de recherche
30- pub fn clear(&mut self) {
31- self.query.query.clear();
32- }
33-
34- /// Définir le focus sur la barre de recherche
35- pub fn set_focus(&mut self, focus: bool) {
36- self.focus = focus;
37- }
38-
39- /// Vérifier si la recherche est active
40- pub fn is_searching(&self) -> bool {
41- !self.query.is_empty()
42- }
43-
44- /// Obtenir la requête de recherche actuelle
45- pub fn get_query(&self) -> &SearchQuery {
46- &self.query
47- }
48-
49- /// Définir la requête de recherche
50- pub fn set_query(&mut self, query: SearchQuery) {
51- self.query = query;
1+//! Search bar UI component
2+//!
3+//! Barre de recherche de la barre d'outils : filtre la liste des flux
4+//! par nom, URL ou tags selon le type de recherche sélectionné.
5+
6+use crate::config::search::SearchType;
7+use crate::config::AppState;
8+use egui::Ui;
9+
10+/// Affiche la barre de recherche et le sélecteur de type de recherche
11+pub fn draw_search_bar(state: &mut AppState, ui: &mut Ui) {
12+ ui.label("🔍");
13+
14+ let response = ui.add(
15+ egui::TextEdit::singleline(&mut state.search_query.query)
16+ .hint_text("Rechercher un flux (Ctrl+F)…")
17+ .desired_width(200.0),
18+ );
19+
20+ // Focus demandé via le raccourci Ctrl+F
21+ if state.focus_search {
22+ response.request_focus();
23+ state.focus_search = false;
5224 }
5325
54- /// Afficher la barre de recherche
55- pub fn show(&mut self, ui: &mut egui::Ui) -> bool {
56- let mut changed = false;
57-
58- // Simple horizontal layout sans Frame
59- ui.horizontal(|ui| {
60- // Icône de recherche
61- ui.label("🔍");
62-
63- // Champ de texte de recherche
64- let text_edit = egui::TextEdit::singleline(&mut self.query.query)
65- .hint_text("Rechercher dans les flux et épisodes...")
66- .desired_width(f32::INFINITY);
67-
68- let response = ui.add(text_edit);
69-
70- // Si focus demandé, le demander
71- if self.focus {
72- response.request_focus();
73- }
74-
75- // Vérifier si le texte a changé
76- if response.changed() {
77- changed = true;
78- }
79-
80- // Bouton pour effacer la recherche
81- if self.is_searching() && ui.button("✕").clicked() {
82- self.clear();
83- changed = true;
84- }
85-
86- // Menu déroulant pour le type de recherche
87- egui::ComboBox::from_label("")
88- .width(120.0)
89- .selected_text(format!("{:?}", self.query.search_type))
90- .show_ui(ui, |ui| {
91- ui.selectable_value(&mut self.query.search_type, SearchType::All, "Tous");
92- ui.selectable_value(&mut self.query.search_type, SearchType::FeedName, "Nom du flux");
93- ui.selectable_value(&mut self.query.search_type, SearchType::FeedUrl, "URL du flux");
94- ui.selectable_value(&mut self.query.search_type, SearchType::FeedTags, "Tags");
95- ui.selectable_value(&mut self.query.search_type, SearchType::EpisodeTitle, "Titre épisode");
96- ui.selectable_value(&mut self.query.search_type, SearchType::EpisodeDescription, "Description");
97- });
98- });
99-
100- changed
26+ if !state.search_query.is_empty()
27+ && ui
28+ .button("✕")
29+ .on_hover_text("Effacer la recherche")
30+ .clicked()
31+ {
32+ state.search_query.query.clear();
10133 }
10234
103- /// Afficher une version simplifiée (juste le champ de recherche)
104- pub fn show_simple(&mut self, ui: &mut egui::Ui) -> bool {
105- let mut changed = false;
106-
107- ui.horizontal(|ui| {
108- ui.label("🔍");
109- let response = ui.text_edit_singleline(&mut self.query.query);
110- if response.changed() {
111- changed = true;
112- }
113- if self.is_searching() && ui.button("✕").clicked() {
114- self.clear();
115- changed = true;
116- }
117- });
35+ egui::ComboBox::from_id_salt("search_type")
36+ .width(130.0)
37+ .selected_text(search_type_label(state.search_query.search_type))
38+ .show_ui(ui, |ui| {
39+ ui.selectable_value(&mut state.search_query.search_type, SearchType::All, "Tous");
40+ ui.selectable_value(
41+ &mut state.search_query.search_type,
42+ SearchType::FeedName,
43+ "Nom du flux",
44+ );
45+ ui.selectable_value(
46+ &mut state.search_query.search_type,
47+ SearchType::FeedUrl,
48+ "URL du flux",
49+ );
50+ ui.selectable_value(
51+ &mut state.search_query.search_type,
52+ SearchType::FeedTags,
53+ "Tags",
54+ );
55+ })
56+ .response
57+ .on_hover_text("Champ de recherche");
58+}
11859
119- changed
60+/// Libellé court du type de recherche sélectionné
61+fn search_type_label(search_type: SearchType) -> &'static str {
62+ match search_type {
63+ SearchType::All => "Tous",
64+ SearchType::FeedName => "Nom",
65+ SearchType::FeedUrl => "URL",
66+ SearchType::FeedTags => "Tags",
67+ // La recherche par épisode se fait dans la vue détaillée d'un flux
68+ SearchType::EpisodeTitle | SearchType::EpisodeDescription => "Flux",
12069 }
12170 }
12271
123-/// Widget pour afficher les résultats de recherche
124-pub fn show_search_results(
125- ui: &mut egui::Ui,
126- total_matches: usize,
127- search_active: bool,
128-) {
129- if search_active {
130- ui.horizontal(|ui| {
131- ui.label(format!("{} résultats trouvés", total_matches));
132- if ui.button("Effacer").clicked() {
133- // L'appelant doit effacer la recherche
134- }
135- });
72+/// Vérifie si un flux correspond à la requête de recherche courante
73+pub fn feed_matches_search(state: &AppState, feed: &crate::config::feed::Feed) -> bool {
74+ let query = &state.search_query;
75+ if query.is_empty() {
76+ return true;
77+ }
78+ match query.search_type {
79+ SearchType::FeedName => query.contains_query(&feed.name),
80+ SearchType::FeedUrl => query.contains_query(&feed.url),
81+ SearchType::FeedTags => feed.tags.iter().any(|t| query.contains_query(t)),
82+ _ => {
83+ query.contains_query(&feed.name)
84+ || query.contains_query(&feed.url)
85+ || feed.tags.iter().any(|t| query.contains_query(t))
86+ }
13687 }
13788 }
@@ -1,137 +1,88 @@
1-#![allow(dead_code)] // Fonctionnalité prévue dans la spec (Phase 9+), pas encore branchée dans l'UI1+//! Search bar UI component
2-use crate::config::search::{SearchQuery, SearchType};2+//!
3-use eframe::egui;3+//! Barre de recherche de la barre d'outils : filtre la liste des flux
4-4+//! par nom, URL ou tags selon le type de recherche sélectionné.
5-/// Barre de recherche pour l'application5+
6-#[derive(Default)]6+use crate::config::search::SearchType;
7-pub struct SearchBar {7+use crate::config::AppState;
8- /// Requête de recherche active8+use egui::Ui;
9- pub query: SearchQuery,9+
10- /// Focus sur la barre de recherche10+/// Affiche la barre de recherche et le sélecteur de type de recherche
11- focus: bool,11+pub fn draw_search_bar(state: &mut AppState, ui: &mut Ui) {
12-}12+ ui.label("🔍");
13-13+
14-14+ let response = ui.add(
15-impl SearchBar {15+ egui::TextEdit::singleline(&mut state.search_query.query)
16- /// Créer une nouvelle barre de recherche16+ .hint_text("Rechercher un flux (Ctrl+F)…")
17- pub fn new() -> Self {17+ .desired_width(200.0),
18- Self::default()18+ );
19- }19+
20-20+ // Focus demandé via le raccourci Ctrl+F
21- /// Créer une barre de recherche avec une requête initiale21+ if state.focus_search {
22- pub fn with_query(query: SearchQuery) -> Self {22+ response.request_focus();
23- Self {23+ state.focus_search = false;
24- query,
25- focus: false,
26- }
27- }
28-
29- /// Effacer la requête de recherche
30- pub fn clear(&mut self) {
31- self.query.query.clear();
32- }
33-
34- /// Définir le focus sur la barre de recherche
35- pub fn set_focus(&mut self, focus: bool) {
36- self.focus = focus;
37- }
38-
39- /// Vérifier si la recherche est active
40- pub fn is_searching(&self) -> bool {
41- !self.query.is_empty()
42- }
43-
44- /// Obtenir la requête de recherche actuelle
45- pub fn get_query(&self) -> &SearchQuery {
46- &self.query
47- }
48-
49- /// Définir la requête de recherche
50- pub fn set_query(&mut self, query: SearchQuery) {
51- self.query = query;
52 }24 }
53 25
54- /// Afficher la barre de recherche26+ if !state.search_query.is_empty()
55- pub fn show(&mut self, ui: &mut egui::Ui) -> bool {27+ && ui
56- let mut changed = false;28+ .button("✕")
57-29+ .on_hover_text("Effacer la recherche")
58- // Simple horizontal layout sans Frame30+ .clicked()
59- ui.horizontal(|ui| {31+ {
60- // Icône de recherche32+ state.search_query.query.clear();
61- ui.label("🔍");
62-
63- // Champ de texte de recherche
64- let text_edit = egui::TextEdit::singleline(&mut self.query.query)
65- .hint_text("Rechercher dans les flux et épisodes...")
66- .desired_width(f32::INFINITY);
67-
68- let response = ui.add(text_edit);
69-
70- // Si focus demandé, le demander
71- if self.focus {
72- response.request_focus();
73- }
74-
75- // Vérifier si le texte a changé
76- if response.changed() {
77- changed = true;
78- }
79-
80- // Bouton pour effacer la recherche
81- if self.is_searching() && ui.button("✕").clicked() {
82- self.clear();
83- changed = true;
84- }
85-
86- // Menu déroulant pour le type de recherche
87- egui::ComboBox::from_label("")
88- .width(120.0)
89- .selected_text(format!("{:?}", self.query.search_type))
90- .show_ui(ui, |ui| {
91- ui.selectable_value(&mut self.query.search_type, SearchType::All, "Tous");
92- ui.selectable_value(&mut self.query.search_type, SearchType::FeedName, "Nom du flux");
93- ui.selectable_value(&mut self.query.search_type, SearchType::FeedUrl, "URL du flux");
94- ui.selectable_value(&mut self.query.search_type, SearchType::FeedTags, "Tags");
95- ui.selectable_value(&mut self.query.search_type, SearchType::EpisodeTitle, "Titre épisode");
96- ui.selectable_value(&mut self.query.search_type, SearchType::EpisodeDescription, "Description");
97- });
98- });
99-
100- changed
101 }33 }
102 34
103- /// Afficher une version simplifiée (juste le champ de recherche)35+ egui::ComboBox::from_id_salt("search_type")
104- pub fn show_simple(&mut self, ui: &mut egui::Ui) -> bool {36+ .width(130.0)
105- let mut changed = false;37+ .selected_text(search_type_label(state.search_query.search_type))
106-38+ .show_ui(ui, |ui| {
107- ui.horizontal(|ui| {39+ ui.selectable_value(&mut state.search_query.search_type, SearchType::All, "Tous");
108- ui.label("🔍");40+ ui.selectable_value(
109- let response = ui.text_edit_singleline(&mut self.query.query);41+ &mut state.search_query.search_type,
110- if response.changed() {42+ SearchType::FeedName,
111- changed = true;43+ "Nom du flux",
112- }44+ );
113- if self.is_searching() && ui.button("✕").clicked() {45+ ui.selectable_value(
114- self.clear();46+ &mut state.search_query.search_type,
115- changed = true;47+ SearchType::FeedUrl,
116- }48+ "URL du flux",
117- });49+ );
50+ ui.selectable_value(
51+ &mut state.search_query.search_type,
52+ SearchType::FeedTags,
53+ "Tags",
54+ );
55+ })
56+ .response
57+ .on_hover_text("Champ de recherche");
58+}
118 59
119- changed60+/// Libellé court du type de recherche sélectionné
61+fn search_type_label(search_type: SearchType) -> &'static str {
62+ match search_type {
63+ SearchType::All => "Tous",
64+ SearchType::FeedName => "Nom",
65+ SearchType::FeedUrl => "URL",
66+ SearchType::FeedTags => "Tags",
67+ // La recherche par épisode se fait dans la vue détaillée d'un flux
68+ SearchType::EpisodeTitle | SearchType::EpisodeDescription => "Flux",
120 }69 }
121 }70 }
122 71
123-/// Widget pour afficher les résultats de recherche72+/// Vérifie si un flux correspond à la requête de recherche courante
124-pub fn show_search_results(73+pub fn feed_matches_search(state: &AppState, feed: &crate::config::feed::Feed) -> bool {
125- ui: &mut egui::Ui,74+ let query = &state.search_query;
126- total_matches: usize,75+ if query.is_empty() {
127- search_active: bool,76+ return true;
128-) {77+ }
129- if search_active {78+ match query.search_type {
130- ui.horizontal(|ui| {79+ SearchType::FeedName => query.contains_query(&feed.name),
131- ui.label(format!("{} résultats trouvés", total_matches));80+ SearchType::FeedUrl => query.contains_query(&feed.url),
132- if ui.button("Effacer").clicked() {81+ SearchType::FeedTags => feed.tags.iter().any(|t| query.contains_query(t)),
133- // L'appelant doit effacer la recherche82+ _ => {
134- }83+ query.contains_query(&feed.name)
135- });84+ || query.contains_query(&feed.url)
85+ || feed.tags.iter().any(|t| query.contains_query(t))
86+ }
136 }87 }
137 }88 }
modified src/ui/stats_panel.rs +93 -157
@@ -1,173 +1,109 @@
1-#![allow(dead_code)] // Fonctionnalité prévue dans la spec (Phase 9+), pas encore branchée dans l'UI
2-use crate::config::statistics::{AppStatistics, format_bytes};
3-use eframe::egui;
4-
5-/// Panneau d'affichage des statistiques
6-#[derive(Default)]
7-pub struct StatsPanel {
8- /// Visibilité du panneau
9- visible: bool,
10-}
11-
12-
13-impl StatsPanel {
14- /// Créer un nouveau panneau de statistiques
15- pub fn new() -> Self {
16- Self::default()
1+//! Stats Panel UI component
2+//!
3+//! Fenêtre flottante affichant les statistiques de l'application,
4+//! calculées à partir des flux configurés.
5+
6+use crate::config::feed::Feed;
7+use crate::config::AppState;
8+use egui::Ui;
9+
10+/// Draws the statistics window (toggled from the menu)
11+pub fn draw_stats_window(state: &mut AppState, ui: &mut Ui) {
12+ if !state.show_stats_window {
13+ return;
1714 }
1815
19- /// Définir la visibilité
20- pub fn set_visible(&mut self, visible: bool) {
21- self.visible = visible;
22- }
23-
24- /// Basculer la visibilité
25- pub fn toggle(&mut self) {
26- self.visible = !self.visible;
27- }
28-
29- /// Vérifier si le panneau est visible
30- pub fn is_visible(&self) -> bool {
31- self.visible
32- }
16+ let mut open = state.show_stats_window;
17+ egui::Window::new("📊 Statistiques")
18+ .open(&mut open)
19+ .default_size([520.0, 380.0])
20+ .show(ui.ctx(), |ui| {
21+ draw_stats_content(&state.feeds, ui);
22+ });
23+ state.show_stats_window = open;
24+}
3325
34- /// Afficher le panneau de statistiques
35- pub fn show(&mut self, ui: &mut egui::Ui, stats: &AppStatistics) {
36- if !self.visible {
37- return;
38- }
26+/// Draws the statistics content
27+fn draw_stats_content(feeds: &[Feed], ui: &mut Ui) {
28+ // Statistiques globales
29+ let total_feeds = feeds.len();
30+ let enabled_feeds = feeds.iter().filter(|f| f.enabled).count();
31+ let downloaded_episodes: usize = feeds.iter().map(|f| f.downloaded_episodes_count).sum();
32+ let last_checked = feeds.iter().filter_map(|f| f.last_checked).max();
33+ let last_download = feeds
34+ .iter()
35+ .filter_map(|f| f.last_downloaded_episode_date)
36+ .max();
37+
38+ ui.label("🌍 Statistiques globales");
39+ ui.separator();
40+
41+ egui::Grid::new("global_stats_grid")
42+ .striped(true)
43+ .show(ui, |ui| {
44+ ui.label("Nombre total de flux:");
45+ ui.strong(total_feeds.to_string());
46+ ui.end_row();
47+
48+ ui.label("Flux activés:");
49+ ui.strong(enabled_feeds.to_string());
50+ ui.end_row();
51+
52+ ui.label("Épisodes téléchargés (total):");
53+ ui.strong(downloaded_episodes.to_string());
54+ ui.end_row();
55+
56+ ui.label("Dernière synchronisation:");
57+ ui.strong(match last_checked {
58+ Some(date) => date.format("%d/%m/%Y %H:%M").to_string(),
59+ None => "Jamais".to_string(),
60+ });
61+ ui.end_row();
3962
40- egui::CollapsingHeader::new("📊 Statistiques")
41- .default_open(true)
42- .show(ui, |ui| {
43- self.show_content(ui, stats);
63+ ui.label("Dernier téléchargement:");
64+ ui.strong(match last_download {
65+ Some(date) => date.format("%d/%m/%Y %H:%M").to_string(),
66+ None => "Jamais".to_string(),
4467 });
45- }
68+ ui.end_row();
69+ });
4670
47- /// Afficher le contenu du panneau
48- fn show_content(&self, ui: &mut egui::Ui, stats: &AppStatistics) {
49- // Statistiques globales
50- self.show_global_stats(ui, stats);
71+ ui.separator();
5172
52- ui.separator();
73+ // Statistiques par flux
74+ ui.label("📻 Statistiques par flux");
75+ ui.separator();
5376
54- // Statistiques par flux
55- self.show_feed_stats(ui, stats);
77+ if feeds.is_empty() {
78+ ui.label("Aucun flux configuré.");
79+ return;
5680 }
5781
58- /// Afficher les statistiques globales
59- fn show_global_stats(&self, ui: &mut egui::Ui, stats: &AppStatistics) {
60- ui.label("🌍 Statistiques Globales");
61- ui.separator();
62-
63- egui::Grid::new("global_stats_grid")
82+ egui::ScrollArea::vertical().show(ui, |ui| {
83+ egui::Grid::new("feed_stats_grid")
6484 .striped(true)
6585 .show(ui, |ui| {
66- ui.label("Nombre total de flux:");
67- ui.label(format!("{}", stats.total_feeds));
68- ui.end_row();
69-
70- ui.label("Flux activés:");
71- ui.label(format!("{}", stats.enabled_feeds));
72- ui.end_row();
73-
74- ui.label("Nombre total d'épisodes:");
75- ui.label(format!("{}", stats.total_episodes));
76- ui.end_row();
77-
78- ui.label("Épisodes téléchargés:");
79- ui.label(format!("{}", stats.downloaded_episodes));
86+ ui.strong("Nom du flux");
87+ ui.strong("Téléchargés");
88+ ui.strong("Dernier épisode téléchargé");
89+ ui.strong("Dernière vérification");
8090 ui.end_row();
8191
82- ui.label("Épisodes non téléchargés:");
83- ui.label(format!("{}", stats.not_downloaded_episodes));
84- ui.end_row();
85-
86- ui.label("Taille totale des téléchargements:");
87- ui.label(stats.get_formatted_total_size().to_string());
88- ui.end_row();
89-
90- ui.label("Pourcentage téléchargé:");
91- ui.label(format!("{:.1}%", stats.get_download_percentage()));
92- ui.end_row();
93-
94- ui.label("Taille moyenne par fichier:");
95- ui.label(format_bytes(stats.get_average_file_size()).to_string());
96- ui.end_row();
97-
98- ui.label("Nombre de synchronisations:");
99- ui.label(format!("{}", stats.total_sync_count));
100- ui.end_row();
101-
102- ui.label("Nombre de téléchargements:");
103- ui.label(format!("{}", stats.total_download_count));
104- ui.end_row();
105- });
106-
107- // Afficher les dates
108- ui.separator();
109- ui.label("📅 Dates:");
110-
111- if let Some(date) = stats.last_sync_date {
112- ui.label(format!("Dernière synchronisation: {}", date.format("%Y-%m-%d %H:%M:%S")));
113- } else {
114- ui.label("Dernière synchronisation: Jamais");
115- }
116-
117- if let Some(date) = stats.last_download_date {
118- ui.label(format!("Dernier téléchargement: {}", date.format("%Y-%m-%d %H:%M:%S")));
119- } else {
120- ui.label("Dernier téléchargement: Jamais");
121- }
122- }
123-
124- /// Afficher les statistiques par flux
125- fn show_feed_stats(&self, ui: &mut egui::Ui, stats: &AppStatistics) {
126- ui.label("📻 Statistiques par Flux");
127- ui.separator();
128-
129- if stats.feed_statistics.is_empty() {
130- ui.label("Aucun flux disponible");
131- return;
132- }
133-
134- // Tableau des statistiques par flux
135- egui::ScrollArea::vertical().show(ui, |ui| {
136- egui::Grid::new("feed_stats_grid")
137- .striped(true)
138- .show(ui, |ui| {
139- // En-têtes
140- ui.strong("Nom du flux");
141- ui.strong("Épisodes");
142- ui.strong("Téléchargés");
143- ui.strong("Taille");
144- ui.strong("% Téléchargé");
92+ for feed in feeds {
93+ ui.label(&feed.name);
94+ ui.label(feed.downloaded_episodes_count.to_string());
95+ ui.label(
96+ feed.last_downloaded_episode_name
97+ .as_deref()
98+ .map(|n| crate::ui::truncate(n, 25))
99+ .unwrap_or_else(|| "—".to_string()),
100+ );
101+ ui.label(match feed.last_checked {
102+ Some(date) => date.format("%d/%m/%Y %H:%M").to_string(),
103+ None => "Jamais".to_string(),
104+ });
145105 ui.end_row();
146-
147- // Lignes de données
148- let mut feed_stats: Vec<_> = stats.feed_statistics.values().collect();
149- feed_stats.sort_by_key(|s| std::cmp::Reverse(s.total_episodes));
150-
151- for feed_stat in feed_stats {
152- ui.label(&feed_stat.feed_name);
153- ui.label(format!("{}", feed_stat.total_episodes));
154- ui.label(format!("{}", feed_stat.downloaded_episodes));
155- ui.label(feed_stat.get_formatted_size().to_string());
156- ui.label(format!("{:.1}%", feed_stat.get_download_percentage()));
157- ui.end_row();
158- }
159- });
160- });
161- }
162-
163- /// Afficher un widget de statistiques rapide (pour la barre de statut)
164- pub fn show_quick_stats(ui: &mut egui::Ui, stats: &AppStatistics) {
165- ui.horizontal(|ui| {
166- ui.label(format!("📊 {} flux | {} épisodes | {}",
167- stats.total_feeds,
168- stats.total_episodes,
169- stats.get_formatted_total_size()
170- ));
171- });
172- }
106+ }
107+ });
108+ });
173109 }
@@ -1,173 +1,109 @@
1-#![allow(dead_code)] // Fonctionnalité prévue dans la spec (Phase 9+), pas encore branchée dans l'UI1+//! Stats Panel UI component
2-use crate::config::statistics::{AppStatistics, format_bytes};2+//!
3-use eframe::egui;3+//! Fenêtre flottante affichant les statistiques de l'application,
4-4+//! calculées à partir des flux configurés.
5-/// Panneau d'affichage des statistiques5+
6-#[derive(Default)]6+use crate::config::feed::Feed;
7-pub struct StatsPanel {7+use crate::config::AppState;
8- /// Visibilité du panneau8+use egui::Ui;
9- visible: bool,9+
10-}10+/// Draws the statistics window (toggled from the menu)
11-11+pub fn draw_stats_window(state: &mut AppState, ui: &mut Ui) {
12-12+ if !state.show_stats_window {
13-impl StatsPanel {13+ return;
14- /// Créer un nouveau panneau de statistiques
15- pub fn new() -> Self {
16- Self::default()
17 }14 }
18 15
19- /// Définir la visibilité16+ let mut open = state.show_stats_window;
20- pub fn set_visible(&mut self, visible: bool) {17+ egui::Window::new("📊 Statistiques")
21- self.visible = visible;18+ .open(&mut open)
22- }19+ .default_size([520.0, 380.0])
23-20+ .show(ui.ctx(), |ui| {
24- /// Basculer la visibilité21+ draw_stats_content(&state.feeds, ui);
25- pub fn toggle(&mut self) {22+ });
26- self.visible = !self.visible;23+ state.show_stats_window = open;
27- }24+}
28-
29- /// Vérifier si le panneau est visible
30- pub fn is_visible(&self) -> bool {
31- self.visible
32- }
33 25
34- /// Afficher le panneau de statistiques26+/// Draws the statistics content
35- pub fn show(&mut self, ui: &mut egui::Ui, stats: &AppStatistics) {27+fn draw_stats_content(feeds: &[Feed], ui: &mut Ui) {
36- if !self.visible {28+ // Statistiques globales
37- return;29+ let total_feeds = feeds.len();
38- }30+ let enabled_feeds = feeds.iter().filter(|f| f.enabled).count();
31+ let downloaded_episodes: usize = feeds.iter().map(|f| f.downloaded_episodes_count).sum();
32+ let last_checked = feeds.iter().filter_map(|f| f.last_checked).max();
33+ let last_download = feeds
34+ .iter()
35+ .filter_map(|f| f.last_downloaded_episode_date)
36+ .max();
37+
38+ ui.label("🌍 Statistiques globales");
39+ ui.separator();
40+
41+ egui::Grid::new("global_stats_grid")
42+ .striped(true)
43+ .show(ui, |ui| {
44+ ui.label("Nombre total de flux:");
45+ ui.strong(total_feeds.to_string());
46+ ui.end_row();
47+
48+ ui.label("Flux activés:");
49+ ui.strong(enabled_feeds.to_string());
50+ ui.end_row();
51+
52+ ui.label("Épisodes téléchargés (total):");
53+ ui.strong(downloaded_episodes.to_string());
54+ ui.end_row();
55+
56+ ui.label("Dernière synchronisation:");
57+ ui.strong(match last_checked {
58+ Some(date) => date.format("%d/%m/%Y %H:%M").to_string(),
59+ None => "Jamais".to_string(),
60+ });
61+ ui.end_row();
39 62
40- egui::CollapsingHeader::new("📊 Statistiques")63+ ui.label("Dernier téléchargement:");
41- .default_open(true)64+ ui.strong(match last_download {
42- .show(ui, |ui| {65+ Some(date) => date.format("%d/%m/%Y %H:%M").to_string(),
43- self.show_content(ui, stats);66+ None => "Jamais".to_string(),
44 });67 });
45- }68+ ui.end_row();
69+ });
46 70
47- /// Afficher le contenu du panneau71+ ui.separator();
48- fn show_content(&self, ui: &mut egui::Ui, stats: &AppStatistics) {
49- // Statistiques globales
50- self.show_global_stats(ui, stats);
51 72
52- ui.separator();73+ // Statistiques par flux
74+ ui.label("📻 Statistiques par flux");
75+ ui.separator();
53 76
54- // Statistiques par flux77+ if feeds.is_empty() {
55- self.show_feed_stats(ui, stats);78+ ui.label("Aucun flux configuré.");
79+ return;
56 }80 }
57 81
58- /// Afficher les statistiques globales82+ egui::ScrollArea::vertical().show(ui, |ui| {
59- fn show_global_stats(&self, ui: &mut egui::Ui, stats: &AppStatistics) {83+ egui::Grid::new("feed_stats_grid")
60- ui.label("🌍 Statistiques Globales");
61- ui.separator();
62-
63- egui::Grid::new("global_stats_grid")
64 .striped(true)84 .striped(true)
65 .show(ui, |ui| {85 .show(ui, |ui| {
66- ui.label("Nombre total de flux:");86+ ui.strong("Nom du flux");
67- ui.label(format!("{}", stats.total_feeds));87+ ui.strong("Téléchargés");
68- ui.end_row();88+ ui.strong("Dernier épisode téléchargé");
69-89+ ui.strong("Dernière vérification");
70- ui.label("Flux activés:");
71- ui.label(format!("{}", stats.enabled_feeds));
72- ui.end_row();
73-
74- ui.label("Nombre total d'épisodes:");
75- ui.label(format!("{}", stats.total_episodes));
76- ui.end_row();
77-
78- ui.label("Épisodes téléchargés:");
79- ui.label(format!("{}", stats.downloaded_episodes));
80 ui.end_row();90 ui.end_row();
81 91
82- ui.label("Épisodes non téléchargés:");92+ for feed in feeds {
83- ui.label(format!("{}", stats.not_downloaded_episodes));93+ ui.label(&feed.name);
84- ui.end_row();94+ ui.label(feed.downloaded_episodes_count.to_string());
85-95+ ui.label(
86- ui.label("Taille totale des téléchargements:");96+ feed.last_downloaded_episode_name
87- ui.label(stats.get_formatted_total_size().to_string());97+ .as_deref()
88- ui.end_row();98+ .map(|n| crate::ui::truncate(n, 25))
89-99+ .unwrap_or_else(|| "—".to_string()),
90- ui.label("Pourcentage téléchargé:");100+ );
91- ui.label(format!("{:.1}%", stats.get_download_percentage()));101+ ui.label(match feed.last_checked {
92- ui.end_row();102+ Some(date) => date.format("%d/%m/%Y %H:%M").to_string(),
93-103+ None => "Jamais".to_string(),
94- ui.label("Taille moyenne par fichier:");104+ });
95- ui.label(format_bytes(stats.get_average_file_size()).to_string());
96- ui.end_row();
97-
98- ui.label("Nombre de synchronisations:");
99- ui.label(format!("{}", stats.total_sync_count));
100- ui.end_row();
101-
102- ui.label("Nombre de téléchargements:");
103- ui.label(format!("{}", stats.total_download_count));
104- ui.end_row();
105- });
106-
107- // Afficher les dates
108- ui.separator();
109- ui.label("📅 Dates:");
110-
111- if let Some(date) = stats.last_sync_date {
112- ui.label(format!("Dernière synchronisation: {}", date.format("%Y-%m-%d %H:%M:%S")));
113- } else {
114- ui.label("Dernière synchronisation: Jamais");
115- }
116-
117- if let Some(date) = stats.last_download_date {
118- ui.label(format!("Dernier téléchargement: {}", date.format("%Y-%m-%d %H:%M:%S")));
119- } else {
120- ui.label("Dernier téléchargement: Jamais");
121- }
122- }
123-
124- /// Afficher les statistiques par flux
125- fn show_feed_stats(&self, ui: &mut egui::Ui, stats: &AppStatistics) {
126- ui.label("📻 Statistiques par Flux");
127- ui.separator();
128-
129- if stats.feed_statistics.is_empty() {
130- ui.label("Aucun flux disponible");
131- return;
132- }
133-
134- // Tableau des statistiques par flux
135- egui::ScrollArea::vertical().show(ui, |ui| {
136- egui::Grid::new("feed_stats_grid")
137- .striped(true)
138- .show(ui, |ui| {
139- // En-têtes
140- ui.strong("Nom du flux");
141- ui.strong("Épisodes");
142- ui.strong("Téléchargés");
143- ui.strong("Taille");
144- ui.strong("% Téléchargé");
145 ui.end_row();105 ui.end_row();
146-106+ }
147- // Lignes de données107+ });
148- let mut feed_stats: Vec<_> = stats.feed_statistics.values().collect();108+ });
149- feed_stats.sort_by_key(|s| std::cmp::Reverse(s.total_episodes));
150-
151- for feed_stat in feed_stats {
152- ui.label(&feed_stat.feed_name);
153- ui.label(format!("{}", feed_stat.total_episodes));
154- ui.label(format!("{}", feed_stat.downloaded_episodes));
155- ui.label(feed_stat.get_formatted_size().to_string());
156- ui.label(format!("{:.1}%", feed_stat.get_download_percentage()));
157- ui.end_row();
158- }
159- });
160- });
161- }
162-
163- /// Afficher un widget de statistiques rapide (pour la barre de statut)
164- pub fn show_quick_stats(ui: &mut egui::Ui, stats: &AppStatistics) {
165- ui.horizontal(|ui| {
166- ui.label(format!("📊 {} flux | {} épisodes | {}",
167- stats.total_feeds,
168- stats.total_episodes,
169- stats.get_formatted_total_size()
170- ));
171- });
172- }
173 }109 }
modified src/ui/status_bar.rs +27 -35
@@ -3,27 +3,18 @@
33 //! This module contains the status bar display logic.
44
55 use crate::config::AppState;
6-use crate::config::Theme;
6+use crate::ui::theme::ThemeColors;
77 use egui::{Align, Layout, Ui};
88
99 /// Draws the status bar at the bottom of the application
1010 pub fn draw_status_bar(state: &mut AppState, ui: &mut Ui) {
11- // Determine colors based on theme
12- let (bg_color, stroke_color) = match state.get_theme() {
13- Theme::Dark => (
14- egui::Color32::from_rgb(36, 39, 58),
15- egui::Color32::from_rgb(70, 78, 95)
16- ),
17- Theme::Light => (
18- egui::Color32::from_rgb(230, 235, 245),
19- egui::Color32::from_rgb(180, 195, 210)
20- ),
21- };
22-
11+ // Palette commune (voir ui/theme.rs)
12+ let colors = ThemeColors::for_theme(state.get_theme());
13+
2314 // Frame pour la barre de statut
2415 egui::Frame {
25- fill: bg_color,
26- stroke: egui::Stroke::new(1.0, stroke_color),
16+ fill: colors.bg_primary,
17+ stroke: egui::Stroke::new(1.0, colors.border),
2718 corner_radius: 4.0.into(),
2819 inner_margin: egui::Margin::symmetric(12, 8),
2920 ..Default::default()
@@ -31,35 +22,32 @@ pub fn draw_status_bar(state: &mut AppState, ui: &mut Ui) {
3122 .show(ui, |ui| {
3223 ui.spacing_mut().item_spacing.x = 16.0;
3324
34- // Sync status avec icône
35- // Note: Sync messages are processed in home.rs before drawing
25+ // Sync messages are processed in home.rs before drawing
3626 let feed_count = state.feed_count();
3727 let enabled_count = state.enabled_feed_count();
38-
39- // Process remaining messages (for status display only)
40- state.sync_task_manager.process_messages();
41-
28+
4229 ui.horizontal(|ui| {
4330 // Use the sync indicator from sync_status module
4431 crate::ui::sync_status::draw_sync_indicator(state, ui);
45-
32+
4633 // Show ready status only if no sync activity at all
4734 if !state.sync_task_manager.status.has_sync_activity() {
48- ui.label(egui::RichText::new("✅ ").color(egui::Color32::from_rgb(100, 220, 100)));
49- ui.colored_label(egui::Color32::from_rgb(150, 200, 150), "Prêt");
35+ ui.label(egui::RichText::new("✅ ").color(colors.success));
36+ ui.colored_label(colors.success, "Prêt");
5037 }
5138
5239 // Theme toggle button
5340 let theme_icon = match state.get_theme() {
54- Theme::Dark => "🌙",
55- Theme::Light => "☀️",
41+ crate::config::Theme::Dark => "🌙",
42+ crate::config::Theme::Light => "☀️",
5643 };
5744 let theme_tooltip = match state.get_theme() {
58- Theme::Dark => "Basculer vers le thème clair",
59- Theme::Light => "Basculer vers le thème sombre",
45+ crate::config::Theme::Dark => "Basculer vers le thème clair",
46+ crate::config::Theme::Light => "Basculer vers le thème sombre",
6047 };
61-
62- if ui.link(egui::RichText::new(theme_icon).size(16.0))
48+
49+ if ui
50+ .link(egui::RichText::new(theme_icon).size(16.0))
6351 .on_hover_text(theme_tooltip)
6452 .clicked()
6553 {
@@ -68,13 +56,17 @@ pub fn draw_status_bar(state: &mut AppState, ui: &mut Ui) {
6856
6957 // Feed count on the right avec icône
7058 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
71- ui.label(egui::RichText::new(format!("📊 {} flux ", feed_count))
72- .color(egui::Color32::from_rgb(160, 180, 200)));
73-
59+ ui.label(
60+ egui::RichText::new(format!("📊 {} flux ", feed_count))
61+ .color(colors.text_secondary),
62+ );
63+
7464 // Nombre de flux activés
7565 if enabled_count < feed_count {
76- ui.label(egui::RichText::new(format!("| {} activés", enabled_count))
77- .color(egui::Color32::from_rgb(140, 160, 180)));
66+ ui.label(
67+ egui::RichText::new(format!("| {} activés", enabled_count))
68+ .color(colors.text_dim),
69+ );
7870 }
7971 });
8072 });
@@ -3,27 +3,18 @@
3 //! This module contains the status bar display logic.3 //! This module contains the status bar display logic.
4 4
5 use crate::config::AppState;5 use crate::config::AppState;
6-use crate::config::Theme;6+use crate::ui::theme::ThemeColors;
7 use egui::{Align, Layout, Ui};7 use egui::{Align, Layout, Ui};
8 8
9 /// Draws the status bar at the bottom of the application9 /// Draws the status bar at the bottom of the application
10 pub fn draw_status_bar(state: &mut AppState, ui: &mut Ui) {10 pub fn draw_status_bar(state: &mut AppState, ui: &mut Ui) {
11- // Determine colors based on theme11+ // Palette commune (voir ui/theme.rs)
12- let (bg_color, stroke_color) = match state.get_theme() {12+ let colors = ThemeColors::for_theme(state.get_theme());
13- Theme::Dark => (13+
14- egui::Color32::from_rgb(36, 39, 58),
15- egui::Color32::from_rgb(70, 78, 95)
16- ),
17- Theme::Light => (
18- egui::Color32::from_rgb(230, 235, 245),
19- egui::Color32::from_rgb(180, 195, 210)
20- ),
21- };
22-
23 // Frame pour la barre de statut14 // Frame pour la barre de statut
24 egui::Frame {15 egui::Frame {
25- fill: bg_color,16+ fill: colors.bg_primary,
26- stroke: egui::Stroke::new(1.0, stroke_color),17+ stroke: egui::Stroke::new(1.0, colors.border),
27 corner_radius: 4.0.into(),18 corner_radius: 4.0.into(),
28 inner_margin: egui::Margin::symmetric(12, 8),19 inner_margin: egui::Margin::symmetric(12, 8),
29 ..Default::default()20 ..Default::default()
@@ -31,35 +22,32 @@ pub fn draw_status_bar(state: &mut AppState, ui: &mut Ui) {
31 .show(ui, |ui| {22 .show(ui, |ui| {
32 ui.spacing_mut().item_spacing.x = 16.0;23 ui.spacing_mut().item_spacing.x = 16.0;
33 24
34- // Sync status avec icône25+ // Sync messages are processed in home.rs before drawing
35- // Note: Sync messages are processed in home.rs before drawing
36 let feed_count = state.feed_count();26 let feed_count = state.feed_count();
37 let enabled_count = state.enabled_feed_count();27 let enabled_count = state.enabled_feed_count();
38- 28+
39- // Process remaining messages (for status display only)
40- state.sync_task_manager.process_messages();
41-
42 ui.horizontal(|ui| {29 ui.horizontal(|ui| {
43 // Use the sync indicator from sync_status module30 // Use the sync indicator from sync_status module
44 crate::ui::sync_status::draw_sync_indicator(state, ui);31 crate::ui::sync_status::draw_sync_indicator(state, ui);
45- 32+
46 // Show ready status only if no sync activity at all33 // Show ready status only if no sync activity at all
47 if !state.sync_task_manager.status.has_sync_activity() {34 if !state.sync_task_manager.status.has_sync_activity() {
48- ui.label(egui::RichText::new("✅ ").color(egui::Color32::from_rgb(100, 220, 100)));35+ ui.label(egui::RichText::new("✅ ").color(colors.success));
49- ui.colored_label(egui::Color32::from_rgb(150, 200, 150), "Prêt");36+ ui.colored_label(colors.success, "Prêt");
50 }37 }
51 38
52 // Theme toggle button39 // Theme toggle button
53 let theme_icon = match state.get_theme() {40 let theme_icon = match state.get_theme() {
54- Theme::Dark => "🌙",41+ crate::config::Theme::Dark => "🌙",
55- Theme::Light => "☀️",42+ crate::config::Theme::Light => "☀️",
56 };43 };
57 let theme_tooltip = match state.get_theme() {44 let theme_tooltip = match state.get_theme() {
58- Theme::Dark => "Basculer vers le thème clair",45+ crate::config::Theme::Dark => "Basculer vers le thème clair",
59- Theme::Light => "Basculer vers le thème sombre",46+ crate::config::Theme::Light => "Basculer vers le thème sombre",
60 };47 };
61- 48+
62- if ui.link(egui::RichText::new(theme_icon).size(16.0))49+ if ui
50+ .link(egui::RichText::new(theme_icon).size(16.0))
63 .on_hover_text(theme_tooltip)51 .on_hover_text(theme_tooltip)
64 .clicked()52 .clicked()
65 {53 {
@@ -68,13 +56,17 @@ pub fn draw_status_bar(state: &mut AppState, ui: &mut Ui) {
68 56
69 // Feed count on the right avec icône57 // Feed count on the right avec icône
70 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {58 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
71- ui.label(egui::RichText::new(format!("📊 {} flux ", feed_count))59+ ui.label(
72- .color(egui::Color32::from_rgb(160, 180, 200)));60+ egui::RichText::new(format!("📊 {} flux ", feed_count))
73- 61+ .color(colors.text_secondary),
62+ );
63+
74 // Nombre de flux activés64 // Nombre de flux activés
75 if enabled_count < feed_count {65 if enabled_count < feed_count {
76- ui.label(egui::RichText::new(format!("| {} activés", enabled_count))66+ ui.label(
77- .color(egui::Color32::from_rgb(140, 160, 180)));67+ egui::RichText::new(format!("| {} activés", enabled_count))
68+ .color(colors.text_dim),
69+ );
78 }70 }
79 });71 });
80 });72 });
modified src/ui/sync_status.rs +52 -53
@@ -3,65 +3,57 @@
33 //! This module displays the synchronization status and progress in the status bar.
44
55 use crate::config::AppState;
6-use egui::{Color32, RichText, Ui};
6+use crate::ui::theme::ThemeColors;
7+use egui::{RichText, Ui};
78
8-/// Draws a progress bar
9-fn draw_progress_bar(ui: &mut Ui, progress: f32, width: f32) {
9+/// Draws a progress bar with theme colors
10+fn draw_progress_bar(ui: &mut Ui, progress: f32, width: f32, colors: &ThemeColors) {
1011 let progress_clamped = progress.clamp(0.0, 1.0);
11-
12+
1213 // Background
13- let bg_rect = egui::Rect::from_min_size(
14- ui.cursor().min,
15- egui::Vec2::new(width, 8.0)
16- );
17-
14+ let bg_rect = egui::Rect::from_min_size(ui.cursor().min, egui::Vec2::new(width, 8.0));
15+
1816 // Progress fill
1917 let fill_width = width * progress_clamped;
20- let fill_rect = egui::Rect::from_min_size(
21- bg_rect.min,
22- egui::Vec2::new(fill_width, 8.0)
23- );
24-
18+ let fill_rect = egui::Rect::from_min_size(bg_rect.min, egui::Vec2::new(fill_width, 8.0));
19+
2520 // Paint background
26- ui.painter().rect_filled(
27- bg_rect,
28- 4.0,
29- Color32::from_rgb(60, 64, 80)
30- );
31-
21+ ui.painter().rect_filled(bg_rect, 4.0, colors.bg_tertiary);
22+
3223 // Paint progress fill
3324 let fill_color = if progress_clamped >= 1.0 {
34- Color32::from_rgb(80, 220, 100) // Green for complete
25+ colors.success
3526 } else {
36- Color32::from_rgb(130, 170, 250) // Blue for in progress
27+ colors.accent
3728 };
38-
39- ui.painter().rect_filled(
40- fill_rect,
41- 4.0,
42- fill_color
43- );
44-
29+
30+ ui.painter().rect_filled(fill_rect, 4.0, fill_color);
31+
4532 // Advance cursor
4633 ui.advance_cursor_after_rect(bg_rect);
4734 }
4835
4936 /// Draws a compact sync indicator for the status bar
5037 pub fn draw_sync_indicator(state: &mut AppState, ui: &mut Ui) {
51- // Messages are processed in status_bar.rs, so we just display the status here
38+ // Messages are processed in home.rs, so we just display the status here
5239 let status = &state.sync_task_manager.status;
53-
40+ let colors = ThemeColors::for_theme(state.get_theme());
41+
5442 // Always show sync info if there's any activity
5543 if !status.has_sync_activity() {
5644 return;
5745 }
58-
46+
5947 let global_sync_in_progress = status.global_sync_in_progress;
6048 let syncing_count = status.syncing_feed_count();
61- let completed_count = status.feed_sync_status.values()
49+ let completed_count = status
50+ .feed_sync_status
51+ .values()
6252 .filter(|s| matches!(s, crate::config::sync_task::FeedSyncStatus::Completed))
6353 .count();
64- let failed_errors: Vec<String> = status.feed_sync_status.values()
54+ let failed_errors: Vec<String> = status
55+ .feed_sync_status
56+ .values()
6557 .filter_map(|s| match s {
6658 crate::config::sync_task::FeedSyncStatus::Failed { error } => Some(error.clone()),
6759 _ => None,
@@ -71,41 +63,48 @@ pub fn draw_sync_indicator(state: &mut AppState, ui: &mut Ui) {
7163 let total_feeds = status.total_feeds;
7264 let completed_feeds = status.completed_feeds;
7365 let overall_progress = status.get_overall_progress();
74-
66+
7567 ui.horizontal(|ui| {
7668 ui.spacing_mut().item_spacing = egui::Vec2::new(4.0, 0.0);
77-
69+
7870 if global_sync_in_progress {
7971 // Global sync indicator
8072 ui.label(RichText::new("🔄").size(16.0));
81- ui.label(RichText::new(format!("Synchro: {}/{} flux",
82- completed_feeds,
83- total_feeds))
84- .size(13.0)
85- .color(Color32::from_rgb(180, 200, 255)));
86-
73+ ui.label(
74+ RichText::new(format!("Synchro: {}/{} flux", completed_feeds, total_feeds))
75+ .size(13.0)
76+ .color(colors.accent),
77+ );
78+
8779 // Overall progress bar
88- draw_progress_bar(ui, overall_progress, 60.0);
80+ draw_progress_bar(ui, overall_progress, 60.0, &colors);
8981 } else if syncing_count > 0 {
9082 // Individual feed sync indicator
9183 ui.label(RichText::new("🔄").size(16.0));
92- ui.label(RichText::new(format!("{} flux en synchro", syncing_count))
93- .size(13.0)
94- .color(Color32::from_rgb(180, 200, 255)));
84+ ui.label(
85+ RichText::new(format!("{} flux en synchro", syncing_count))
86+ .size(13.0)
87+ .color(colors.accent),
88+ );
9589 } else if completed_count > 0 || failed_count > 0 {
9690 // Show completion status briefly
97- ui.label(RichText::new("✅").size(16.0).color(Color32::from_rgb(80, 220, 100)));
98- let total_completed = completed_count + failed_count;
99- let completion_label = ui.label(RichText::new(format!("Synchro terminée: {} flux ({} OK, {} erreur)",
100- total_completed, completed_count, failed_count))
91+ ui.label(RichText::new("✅").size(16.0).color(colors.success));
92+ let completion_label = ui.label(
93+ RichText::new(format!(
94+ "Synchro terminée: {} flux ({} OK, {} erreur)",
95+ completed_count + failed_count,
96+ completed_count,
97+ failed_count
98+ ))
10199 .size(13.0)
102- .color(Color32::from_rgb(150, 220, 150)));
103-
100+ .color(colors.success),
101+ );
102+
104103 // Show the failure details on hover
105104 if !failed_errors.is_empty() {
106105 completion_label.on_hover_text(failed_errors.join("\n"));
107106 }
108-
107+
109108 // Add a button to clear completed sync status
110109 if ui.button(RichText::new("✕").size(12.0)).clicked() {
111110 state.sync_task_manager.clear_completed_sync_status();
@@ -3,65 +3,57 @@
3 //! This module displays the synchronization status and progress in the status bar.3 //! This module displays the synchronization status and progress in the status bar.
4 4
5 use crate::config::AppState;5 use crate::config::AppState;
6-use egui::{Color32, RichText, Ui};6+use crate::ui::theme::ThemeColors;
7+use egui::{RichText, Ui};
7 8
8-/// Draws a progress bar9+/// Draws a progress bar with theme colors
9-fn draw_progress_bar(ui: &mut Ui, progress: f32, width: f32) {10+fn draw_progress_bar(ui: &mut Ui, progress: f32, width: f32, colors: &ThemeColors) {
10 let progress_clamped = progress.clamp(0.0, 1.0);11 let progress_clamped = progress.clamp(0.0, 1.0);
11- 12+
12 // Background13 // Background
13- let bg_rect = egui::Rect::from_min_size(14+ let bg_rect = egui::Rect::from_min_size(ui.cursor().min, egui::Vec2::new(width, 8.0));
14- ui.cursor().min,15+
15- egui::Vec2::new(width, 8.0)
16- );
17-
18 // Progress fill16 // Progress fill
19 let fill_width = width * progress_clamped;17 let fill_width = width * progress_clamped;
20- let fill_rect = egui::Rect::from_min_size(18+ let fill_rect = egui::Rect::from_min_size(bg_rect.min, egui::Vec2::new(fill_width, 8.0));
21- bg_rect.min,19+
22- egui::Vec2::new(fill_width, 8.0)
23- );
24-
25 // Paint background20 // Paint background
26- ui.painter().rect_filled(21+ ui.painter().rect_filled(bg_rect, 4.0, colors.bg_tertiary);
27- bg_rect,22+
28- 4.0,
29- Color32::from_rgb(60, 64, 80)
30- );
31-
32 // Paint progress fill23 // Paint progress fill
33 let fill_color = if progress_clamped >= 1.0 {24 let fill_color = if progress_clamped >= 1.0 {
34- Color32::from_rgb(80, 220, 100) // Green for complete25+ colors.success
35 } else {26 } else {
36- Color32::from_rgb(130, 170, 250) // Blue for in progress27+ colors.accent
37 };28 };
38- 29+
39- ui.painter().rect_filled(30+ ui.painter().rect_filled(fill_rect, 4.0, fill_color);
40- fill_rect,31+
41- 4.0,
42- fill_color
43- );
44-
45 // Advance cursor32 // Advance cursor
46 ui.advance_cursor_after_rect(bg_rect);33 ui.advance_cursor_after_rect(bg_rect);
47 }34 }
48 35
49 /// Draws a compact sync indicator for the status bar36 /// Draws a compact sync indicator for the status bar
50 pub fn draw_sync_indicator(state: &mut AppState, ui: &mut Ui) {37 pub fn draw_sync_indicator(state: &mut AppState, ui: &mut Ui) {
51- // Messages are processed in status_bar.rs, so we just display the status here38+ // Messages are processed in home.rs, so we just display the status here
52 let status = &state.sync_task_manager.status;39 let status = &state.sync_task_manager.status;
53- 40+ let colors = ThemeColors::for_theme(state.get_theme());
41+
54 // Always show sync info if there's any activity42 // Always show sync info if there's any activity
55 if !status.has_sync_activity() {43 if !status.has_sync_activity() {
56 return;44 return;
57 }45 }
58- 46+
59 let global_sync_in_progress = status.global_sync_in_progress;47 let global_sync_in_progress = status.global_sync_in_progress;
60 let syncing_count = status.syncing_feed_count();48 let syncing_count = status.syncing_feed_count();
61- let completed_count = status.feed_sync_status.values()49+ let completed_count = status
50+ .feed_sync_status
51+ .values()
62 .filter(|s| matches!(s, crate::config::sync_task::FeedSyncStatus::Completed))52 .filter(|s| matches!(s, crate::config::sync_task::FeedSyncStatus::Completed))
63 .count();53 .count();
64- let failed_errors: Vec<String> = status.feed_sync_status.values()54+ let failed_errors: Vec<String> = status
55+ .feed_sync_status
56+ .values()
65 .filter_map(|s| match s {57 .filter_map(|s| match s {
66 crate::config::sync_task::FeedSyncStatus::Failed { error } => Some(error.clone()),58 crate::config::sync_task::FeedSyncStatus::Failed { error } => Some(error.clone()),
67 _ => None,59 _ => None,
@@ -71,41 +63,48 @@ pub fn draw_sync_indicator(state: &mut AppState, ui: &mut Ui) {
71 let total_feeds = status.total_feeds;63 let total_feeds = status.total_feeds;
72 let completed_feeds = status.completed_feeds;64 let completed_feeds = status.completed_feeds;
73 let overall_progress = status.get_overall_progress();65 let overall_progress = status.get_overall_progress();
74- 66+
75 ui.horizontal(|ui| {67 ui.horizontal(|ui| {
76 ui.spacing_mut().item_spacing = egui::Vec2::new(4.0, 0.0);68 ui.spacing_mut().item_spacing = egui::Vec2::new(4.0, 0.0);
77- 69+
78 if global_sync_in_progress {70 if global_sync_in_progress {
79 // Global sync indicator71 // Global sync indicator
80 ui.label(RichText::new("🔄").size(16.0));72 ui.label(RichText::new("🔄").size(16.0));
81- ui.label(RichText::new(format!("Synchro: {}/{} flux", 73+ ui.label(
82- completed_feeds, 74+ RichText::new(format!("Synchro: {}/{} flux", completed_feeds, total_feeds))
83- total_feeds))75+ .size(13.0)
84- .size(13.0)76+ .color(colors.accent),
85- .color(Color32::from_rgb(180, 200, 255)));77+ );
86- 78+
87 // Overall progress bar79 // Overall progress bar
88- draw_progress_bar(ui, overall_progress, 60.0);80+ draw_progress_bar(ui, overall_progress, 60.0, &colors);
89 } else if syncing_count > 0 {81 } else if syncing_count > 0 {
90 // Individual feed sync indicator82 // Individual feed sync indicator
91 ui.label(RichText::new("🔄").size(16.0));83 ui.label(RichText::new("🔄").size(16.0));
92- ui.label(RichText::new(format!("{} flux en synchro", syncing_count))84+ ui.label(
93- .size(13.0)85+ RichText::new(format!("{} flux en synchro", syncing_count))
94- .color(Color32::from_rgb(180, 200, 255)));86+ .size(13.0)
87+ .color(colors.accent),
88+ );
95 } else if completed_count > 0 || failed_count > 0 {89 } else if completed_count > 0 || failed_count > 0 {
96 // Show completion status briefly90 // Show completion status briefly
97- ui.label(RichText::new("✅").size(16.0).color(Color32::from_rgb(80, 220, 100)));91+ ui.label(RichText::new("✅").size(16.0).color(colors.success));
98- let total_completed = completed_count + failed_count;92+ let completion_label = ui.label(
99- let completion_label = ui.label(RichText::new(format!("Synchro terminée: {} flux ({} OK, {} erreur)", 93+ RichText::new(format!(
100- total_completed, completed_count, failed_count))94+ "Synchro terminée: {} flux ({} OK, {} erreur)",
95+ completed_count + failed_count,
96+ completed_count,
97+ failed_count
98+ ))
101 .size(13.0)99 .size(13.0)
102- .color(Color32::from_rgb(150, 220, 150)));100+ .color(colors.success),
103- 101+ );
102+
104 // Show the failure details on hover103 // Show the failure details on hover
105 if !failed_errors.is_empty() {104 if !failed_errors.is_empty() {
106 completion_label.on_hover_text(failed_errors.join("\n"));105 completion_label.on_hover_text(failed_errors.join("\n"));
107 }106 }
108- 107+
109 // Add a button to clear completed sync status108 // Add a button to clear completed sync status
110 if ui.button(RichText::new("✕").size(12.0)).clicked() {109 if ui.button(RichText::new("✕").size(12.0)).clicked() {
111 state.sync_task_manager.clear_completed_sync_status();110 state.sync_task_manager.clear_completed_sync_status();
added src/ui/theme.rs +113 -0
new file mode 100644
@@ -0,0 +1,113 @@
1+//! Thème centralisé de l'application
2+//!
3+//! Palette de couleurs unique pour les deux thèmes (sombre/clair),
4+//! partagée par tous les composants UI pour éviter toute divergence.
5+
6+use crate::config::Theme;
7+use eframe::egui;
8+
9+/// Palette de couleurs pour un thème donné
10+#[derive(Debug, Clone, Copy)]
11+pub struct ThemeColors {
12+ /// Arrière-plan de la fenêtre
13+ pub window_bg: egui::Color32,
14+ /// Arrière-plan principal (widgets non interactifs)
15+ pub bg_primary: egui::Color32,
16+ /// Arrière-plan secondaire (cartes, boutons inactifs)
17+ pub bg_secondary: egui::Color32,
18+ /// Arrière-plan tertiaire (survol)
19+ pub bg_tertiary: egui::Color32,
20+ /// Bordures
21+ pub border: egui::Color32,
22+ /// Couleur d'accentuation (liens, sélection, progression)
23+ pub accent: egui::Color32,
24+ /// Texte principal
25+ pub text_primary: egui::Color32,
26+ /// Texte secondaire
27+ pub text_secondary: egui::Color32,
28+ /// Texte atténué
29+ pub text_dim: egui::Color32,
30+ /// Succès / activé
31+ pub success: egui::Color32,
32+ /// Avertissement / désactivé
33+ pub warning: egui::Color32,
34+ /// Erreur
35+ pub error: egui::Color32,
36+}
37+
38+impl ThemeColors {
39+ /// Retourne la palette correspondant au thème
40+ pub fn for_theme(theme: Theme) -> Self {
41+ match theme {
42+ Theme::Dark => Self {
43+ window_bg: egui::Color32::from_rgb(30, 30, 30),
44+ bg_primary: egui::Color32::from_rgb(36, 39, 58),
45+ bg_secondary: egui::Color32::from_rgb(46, 52, 72),
46+ bg_tertiary: egui::Color32::from_rgb(56, 62, 80),
47+ border: egui::Color32::from_rgb(80, 88, 106),
48+ accent: egui::Color32::from_rgb(130, 170, 250),
49+ text_primary: egui::Color32::from_rgb(220, 230, 245),
50+ text_secondary: egui::Color32::from_rgb(180, 195, 210),
51+ text_dim: egui::Color32::from_rgb(120, 140, 160),
52+ success: egui::Color32::from_rgb(80, 220, 100),
53+ warning: egui::Color32::from_rgb(245, 166, 35),
54+ error: egui::Color32::from_rgb(230, 90, 100),
55+ },
56+ Theme::Light => Self {
57+ window_bg: egui::Color32::from_rgb(248, 250, 255),
58+ bg_primary: egui::Color32::from_rgb(240, 245, 252),
59+ bg_secondary: egui::Color32::from_rgb(220, 230, 245),
60+ bg_tertiary: egui::Color32::from_rgb(200, 220, 240),
61+ border: egui::Color32::from_rgb(180, 195, 210),
62+ accent: egui::Color32::from_rgb(100, 140, 220),
63+ text_primary: egui::Color32::from_rgb(40, 42, 54),
64+ text_secondary: egui::Color32::from_rgb(80, 90, 110),
65+ text_dim: egui::Color32::from_rgb(120, 130, 150),
66+ success: egui::Color32::from_rgb(20, 160, 80),
67+ warning: egui::Color32::from_rgb(210, 120, 0),
68+ error: egui::Color32::from_rgb(200, 60, 70),
69+ },
70+ }
71+ }
72+}
73+
74+/// Applique le thème personnalisé au contexte egui et retourne la palette active
75+pub fn apply_custom_theme(ctx: &egui::Context, theme: Theme) -> ThemeColors {
76+ let colors = ThemeColors::for_theme(theme);
77+
78+ let egui_theme = match theme {
79+ Theme::Dark => egui::Theme::Dark,
80+ Theme::Light => egui::Theme::Light,
81+ };
82+ let mut style = (*ctx.style_of(egui_theme)).clone();
83+
84+ style.visuals.dark_mode = matches!(theme, Theme::Dark);
85+
86+ // Widgets non interactifs
87+ style.visuals.widgets.noninteractive.bg_fill = colors.bg_primary;
88+ style.visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, colors.border);
89+
90+ // Boutons inactifs
91+ style.visuals.widgets.inactive.bg_fill = colors.bg_secondary;
92+ style.visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, colors.border);
93+
94+ // Survol
95+ style.visuals.widgets.hovered.bg_fill = colors.bg_tertiary;
96+
97+ // Boutons actifs
98+ style.visuals.widgets.active.bg_fill = colors.accent;
99+ style.visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, colors.accent);
100+
101+ // Liens
102+ style.visuals.hyperlink_color = colors.accent;
103+
104+ // Espacement
105+ style.spacing.item_spacing = egui::Vec2::new(12.0, 12.0);
106+ style.spacing.button_padding = egui::Vec2::new(12.0, 6.0);
107+ style.spacing.icon_width = 20.0;
108+ style.spacing.icon_spacing = 8.0;
109+
110+ ctx.set_style_of(egui_theme, style);
111+
112+ colors
113+}
new file mode 100644
@@ -0,0 +1,113 @@
1+//! Thème centralisé de l'application
2+//!
3+//! Palette de couleurs unique pour les deux thèmes (sombre/clair),
4+//! partagée par tous les composants UI pour éviter toute divergence.
5+
6+use crate::config::Theme;
7+use eframe::egui;
8+
9+/// Palette de couleurs pour un thème donné
10+#[derive(Debug, Clone, Copy)]
11+pub struct ThemeColors {
12+ /// Arrière-plan de la fenêtre
13+ pub window_bg: egui::Color32,
14+ /// Arrière-plan principal (widgets non interactifs)
15+ pub bg_primary: egui::Color32,
16+ /// Arrière-plan secondaire (cartes, boutons inactifs)
17+ pub bg_secondary: egui::Color32,
18+ /// Arrière-plan tertiaire (survol)
19+ pub bg_tertiary: egui::Color32,
20+ /// Bordures
21+ pub border: egui::Color32,
22+ /// Couleur d'accentuation (liens, sélection, progression)
23+ pub accent: egui::Color32,
24+ /// Texte principal
25+ pub text_primary: egui::Color32,
26+ /// Texte secondaire
27+ pub text_secondary: egui::Color32,
28+ /// Texte atténué
29+ pub text_dim: egui::Color32,
30+ /// Succès / activé
31+ pub success: egui::Color32,
32+ /// Avertissement / désactivé
33+ pub warning: egui::Color32,
34+ /// Erreur
35+ pub error: egui::Color32,
36+}
37+
38+impl ThemeColors {
39+ /// Retourne la palette correspondant au thème
40+ pub fn for_theme(theme: Theme) -> Self {
41+ match theme {
42+ Theme::Dark => Self {
43+ window_bg: egui::Color32::from_rgb(30, 30, 30),
44+ bg_primary: egui::Color32::from_rgb(36, 39, 58),
45+ bg_secondary: egui::Color32::from_rgb(46, 52, 72),
46+ bg_tertiary: egui::Color32::from_rgb(56, 62, 80),
47+ border: egui::Color32::from_rgb(80, 88, 106),
48+ accent: egui::Color32::from_rgb(130, 170, 250),
49+ text_primary: egui::Color32::from_rgb(220, 230, 245),
50+ text_secondary: egui::Color32::from_rgb(180, 195, 210),
51+ text_dim: egui::Color32::from_rgb(120, 140, 160),
52+ success: egui::Color32::from_rgb(80, 220, 100),
53+ warning: egui::Color32::from_rgb(245, 166, 35),
54+ error: egui::Color32::from_rgb(230, 90, 100),
55+ },
56+ Theme::Light => Self {
57+ window_bg: egui::Color32::from_rgb(248, 250, 255),
58+ bg_primary: egui::Color32::from_rgb(240, 245, 252),
59+ bg_secondary: egui::Color32::from_rgb(220, 230, 245),
60+ bg_tertiary: egui::Color32::from_rgb(200, 220, 240),
61+ border: egui::Color32::from_rgb(180, 195, 210),
62+ accent: egui::Color32::from_rgb(100, 140, 220),
63+ text_primary: egui::Color32::from_rgb(40, 42, 54),
64+ text_secondary: egui::Color32::from_rgb(80, 90, 110),
65+ text_dim: egui::Color32::from_rgb(120, 130, 150),
66+ success: egui::Color32::from_rgb(20, 160, 80),
67+ warning: egui::Color32::from_rgb(210, 120, 0),
68+ error: egui::Color32::from_rgb(200, 60, 70),
69+ },
70+ }
71+ }
72+}
73+
74+/// Applique le thème personnalisé au contexte egui et retourne la palette active
75+pub fn apply_custom_theme(ctx: &egui::Context, theme: Theme) -> ThemeColors {
76+ let colors = ThemeColors::for_theme(theme);
77+
78+ let egui_theme = match theme {
79+ Theme::Dark => egui::Theme::Dark,
80+ Theme::Light => egui::Theme::Light,
81+ };
82+ let mut style = (*ctx.style_of(egui_theme)).clone();
83+
84+ style.visuals.dark_mode = matches!(theme, Theme::Dark);
85+
86+ // Widgets non interactifs
87+ style.visuals.widgets.noninteractive.bg_fill = colors.bg_primary;
88+ style.visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, colors.border);
89+
90+ // Boutons inactifs
91+ style.visuals.widgets.inactive.bg_fill = colors.bg_secondary;
92+ style.visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, colors.border);
93+
94+ // Survol
95+ style.visuals.widgets.hovered.bg_fill = colors.bg_tertiary;
96+
97+ // Boutons actifs
98+ style.visuals.widgets.active.bg_fill = colors.accent;
99+ style.visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, colors.accent);
100+
101+ // Liens
102+ style.visuals.hyperlink_color = colors.accent;
103+
104+ // Espacement
105+ style.spacing.item_spacing = egui::Vec2::new(12.0, 12.0);
106+ style.spacing.button_padding = egui::Vec2::new(12.0, 6.0);
107+ style.spacing.icon_width = 20.0;
108+ style.spacing.icon_spacing = 8.0;
109+
110+ ctx.set_style_of(egui_theme, style);
111+
112+ colors
113+}