msansm1/rss-podcast-dlpublic Fork
⑂ 5951266
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 issues

msansm1 committed 2026-10-01T22:32:20+02:00 Browse files
5951266 parent: f837ddc
added AGENTS.md +60 -0
new file mode 100644
@@ -0,0 +1,60 @@
1+# AGENTS.md — rss-podcast-dl-egui
2+
3+Application desktop Rust/egui (egui + eframe 0.35) pour télécharger des podcasts depuis des flux RSS. MSRV 1.92.
4+
5+## Commandes
6+
7+```bash
8+cargo build # build debug
9+cargo run # lance l'app
10+cargo test # 69 tests (modèles, storage, parsing, downloader, extraction, UI)
11+cargo clippy --all-targets # doit passer sans aucun avertissement
12+```
13+
14+Attention : ne pas lancer `cargo fmt` sur l'ensemble du dépôt — il n'est pas formaté à rustfmt et un fmt global crée un diff de plusieurs centaines de lignes. Suivre le style du fichier voisin ; formater uniquement les fichiers nouveaux ou entièrement réécrits (`rustfmt --edition 2021 <fichier>`).
15+
16+Clippy sans warning est exigé : tout nouveau warning doit être corrigé, pas masqué par `#[allow]` (les seuls `#![allow(dead_code)]` existants marquent des fonctionnalités prévues par la spec mais pas encore branchées dans l'UI).
17+
18+## Architecture
19+
20+```
21+src/
22+├── main.rs / app.rs # point d'entrée ; RssPodcastApp (impl eframe::App), chargement initial
23+├── config/ # modèles + persistance (séparés de l'UI)
24+│ ├── app_state.rs # AppState : tout l'état mutable de l'app (champs pub)
25+│ ├── feed.rs / episode.rs / playlist.rs # modèles serde (IDs = UUID v4)
26+│ ├── storage.rs # save/load JSON dans %APPDATA%/rss-podcast-dl (unix : ~/.config/rss-podcast-dl)
27+│ ├── settings.rs # settings.json (thème), history.json
28+│ ├── history.rs # historique des opérations, auto-persisté
29+│ └── sync_task.rs # SyncTaskManager : threads + std::sync::mpsc
30+├── rss/ # I/O réseau et fichiers
31+│ ├── parser.rs / episode_extractor.rs # récupération + parsing du flux (crate rss)
32+│ ├── downloader.rs # téléchargement atomique : .part puis rename, via reqwest blocking
33+│ └── audio_metadata.rs # tags ID3 (crate id3)
34+└── ui/ # rendu pur : fonctions draw_*(state: &mut AppState, ui)
35+```
36+
37+## Patterns à respecter
38+
39+- **State centralisé** : tout état mutable va dans `AppState` (struct plat, champs `pub`). Les composants UI sont des fonctions libres `draw_...(&mut AppState, &mut egui::Ui)` — pas de widgets à état interne.
40+- **Jamais de blocage du thread UI** : les opérations réseau/downloads tournent dans des threads détachés et communiquent via `mpsc` (`SyncMessage` dans `sync_task.rs`). Les messages sont drainés par `try_recv` au fil des frames ; on ne fait pas de `recv()` bloquant dans la boucle egui.
41+- **Téléchargements atomiques** : toujours écrire via un fichier `.part` puis renommer. Ne jamais écrire directement le fichier final.
42+- **Persistance** : sauvegarder le JSON après chaque mutation de feeds/playlists (`storage::save_*_default`). Historique auto-persisté via `HistoryManager::set_save_path`.
43+- **Couleurs** : uniquement via la palette `ui::theme::ThemeColors` (`apply_custom_theme`). Pas de couleur en dur dans les composants — les deux thèmes (sombre/clair) doivent rester cohérents.
44+- **Feedback utilisateur** : erreurs/succès via `NotificationManager` (notifications flottantes), jamais de `panic!` ni `eprintln!` pour une erreur runtime ; tout logging passe par la crate `log`.
45+- **Erreurs** : types dédiés via `thiserror` (ex. `DownloadError`, `StorageError`), pas de `String` nues pour les erreurs internes.
46+- **Modèles serde** : tout champ sérialisé doit être `Serialize + Deserialize` avec `#[serde(default)]` quand l'ancien format ne l'avait pas — rester tolérant aux configs existantes.
47+- **Suppressions** : toujours passer par un dialogue de confirmation (`confirm_dialog.rs`), jamais en direct.
48+
49+## Conventions de code
50+
51+- Édition 2021, Rust 2021 idiomatique ; commentaires de doc `///` et en-têtes de module `//!` obligatoires.
52+- Docs/UI en français (README, textes), identifiants et doc-comments en anglais — suivre le fichier voisin.
53+- Tests unitaires dans chaque module via `#[cfg(test)] mod tests` ; utiliser `tempfile` pour tout fichier temporaire, ne jamais toucher au vrai répertoire de config de l'utilisateur dans un test.
54+- Pas de nouvelle dépendance sans justification (Cargo.toml est volontairement minimal, `reqwest` en mode blocking uniquement).
55+
56+## Notes
57+
58+- `spec/` contient le plan initial et les tâches — la roadmap du README fait foi pour ce qui est fait/reste à faire.
59+- Les émojis dans les libellés de boutons existent dans l'UI ; ne pas en ajouter dans le code, les commits ou les commentaires.
60+- Fins de ligne : l'arbre de travail est en LF mais `core.autocrlf=true` (gitconfig système Git for Windows). Ne pas faire `git checkout -- <fichiers>` pour annuler des changements : les fichiers repasseraient en CRLF et casseraient les outils d'édition. Annuler via `git -c core.autocrlf=false checkout -- <fichiers>` puis normaliser en LF si besoin.
new file mode 100644
@@ -0,0 +1,60 @@
1+# AGENTS.md — rss-podcast-dl-egui
2+
3+Application desktop Rust/egui (egui + eframe 0.35) pour télécharger des podcasts depuis des flux RSS. MSRV 1.92.
4+
5+## Commandes
6+
7+```bash
8+cargo build # build debug
9+cargo run # lance l'app
10+cargo test # 69 tests (modèles, storage, parsing, downloader, extraction, UI)
11+cargo clippy --all-targets # doit passer sans aucun avertissement
12+```
13+
14+Attention : ne pas lancer `cargo fmt` sur l'ensemble du dépôt — il n'est pas formaté à rustfmt et un fmt global crée un diff de plusieurs centaines de lignes. Suivre le style du fichier voisin ; formater uniquement les fichiers nouveaux ou entièrement réécrits (`rustfmt --edition 2021 <fichier>`).
15+
16+Clippy sans warning est exigé : tout nouveau warning doit être corrigé, pas masqué par `#[allow]` (les seuls `#![allow(dead_code)]` existants marquent des fonctionnalités prévues par la spec mais pas encore branchées dans l'UI).
17+
18+## Architecture
19+
20+```
21+src/
22+├── main.rs / app.rs # point d'entrée ; RssPodcastApp (impl eframe::App), chargement initial
23+├── config/ # modèles + persistance (séparés de l'UI)
24+│ ├── app_state.rs # AppState : tout l'état mutable de l'app (champs pub)
25+│ ├── feed.rs / episode.rs / playlist.rs # modèles serde (IDs = UUID v4)
26+│ ├── storage.rs # save/load JSON dans %APPDATA%/rss-podcast-dl (unix : ~/.config/rss-podcast-dl)
27+│ ├── settings.rs # settings.json (thème), history.json
28+│ ├── history.rs # historique des opérations, auto-persisté
29+│ └── sync_task.rs # SyncTaskManager : threads + std::sync::mpsc
30+├── rss/ # I/O réseau et fichiers
31+│ ├── parser.rs / episode_extractor.rs # récupération + parsing du flux (crate rss)
32+│ ├── downloader.rs # téléchargement atomique : .part puis rename, via reqwest blocking
33+│ └── audio_metadata.rs # tags ID3 (crate id3)
34+└── ui/ # rendu pur : fonctions draw_*(state: &mut AppState, ui)
35+```
36+
37+## Patterns à respecter
38+
39+- **State centralisé** : tout état mutable va dans `AppState` (struct plat, champs `pub`). Les composants UI sont des fonctions libres `draw_...(&mut AppState, &mut egui::Ui)` — pas de widgets à état interne.
40+- **Jamais de blocage du thread UI** : les opérations réseau/downloads tournent dans des threads détachés et communiquent via `mpsc` (`SyncMessage` dans `sync_task.rs`). Les messages sont drainés par `try_recv` au fil des frames ; on ne fait pas de `recv()` bloquant dans la boucle egui.
41+- **Téléchargements atomiques** : toujours écrire via un fichier `.part` puis renommer. Ne jamais écrire directement le fichier final.
42+- **Persistance** : sauvegarder le JSON après chaque mutation de feeds/playlists (`storage::save_*_default`). Historique auto-persisté via `HistoryManager::set_save_path`.
43+- **Couleurs** : uniquement via la palette `ui::theme::ThemeColors` (`apply_custom_theme`). Pas de couleur en dur dans les composants — les deux thèmes (sombre/clair) doivent rester cohérents.
44+- **Feedback utilisateur** : erreurs/succès via `NotificationManager` (notifications flottantes), jamais de `panic!` ni `eprintln!` pour une erreur runtime ; tout logging passe par la crate `log`.
45+- **Erreurs** : types dédiés via `thiserror` (ex. `DownloadError`, `StorageError`), pas de `String` nues pour les erreurs internes.
46+- **Modèles serde** : tout champ sérialisé doit être `Serialize + Deserialize` avec `#[serde(default)]` quand l'ancien format ne l'avait pas — rester tolérant aux configs existantes.
47+- **Suppressions** : toujours passer par un dialogue de confirmation (`confirm_dialog.rs`), jamais en direct.
48+
49+## Conventions de code
50+
51+- Édition 2021, Rust 2021 idiomatique ; commentaires de doc `///` et en-têtes de module `//!` obligatoires.
52+- Docs/UI en français (README, textes), identifiants et doc-comments en anglais — suivre le fichier voisin.
53+- Tests unitaires dans chaque module via `#[cfg(test)] mod tests` ; utiliser `tempfile` pour tout fichier temporaire, ne jamais toucher au vrai répertoire de config de l'utilisateur dans un test.
54+- Pas de nouvelle dépendance sans justification (Cargo.toml est volontairement minimal, `reqwest` en mode blocking uniquement).
55+
56+## Notes
57+
58+- `spec/` contient le plan initial et les tâches — la roadmap du README fait foi pour ce qui est fait/reste à faire.
59+- Les émojis dans les libellés de boutons existent dans l'UI ; ne pas en ajouter dans le code, les commits ou les commentaires.
60+- Fins de ligne : l'arbre de travail est en LF mais `core.autocrlf=true` (gitconfig système Git for Windows). Ne pas faire `git checkout -- <fichiers>` pour annuler des changements : les fichiers repasseraient en CRLF et casseraient les outils d'édition. Annuler via `git -c core.autocrlf=false checkout -- <fichiers>` puis normaliser en LF si besoin.
modified README.md +5 -2
@@ -57,7 +57,9 @@ Ou exécuter directement le binaire compilé :
5757 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.
5858
5959 - **Écran principal** :
60- - Recherche instantanée (nom, URL, tags) et filtres par date
60+ - Barre d'outils compacte regroupant recherche (nom, URL, tags), filtre par période et actions
61+ - Recherche instantanée avec compteur de résultats dès qu'un filtre est actif
62+ - Filtre par période : « 7 derniers jours », « 30 derniers jours » ou plage personnalisée
6163 - Boutons « Ajouter un flux », « Synchroniser tous », « Exporter », « Importer »
6264 - 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
6365 - Actions par flux : vue détaillée (📖), synchroniser (🔄), éditer (✏️), supprimer (🗑️ avec confirmation)
@@ -161,7 +163,8 @@ Les fichiers de configuration sont stockés dans un répertoire dédié :
161163 │ ├── stats_panel.rs # Fenêtre Statistiques
162164 │ ├── status_bar.rs # Barre de statut
163165 │ ├── sync_status.rs # Indicateur de synchronisation
164-│ └── theme.rs # Palette de couleurs centralisée
166+│ ├── theme.rs # Palette de couleurs centralisée
167+│ └── toolbar.rs # Barre d'outils (recherche, période, actions)
165168 ├── spec/
166169 │ ├── init-plan.md # Plan initial du projet
167170 │ └── init-tasks.md # Tâches détaillées
@@ -57,7 +57,9 @@ Ou exécuter directement le binaire compilé :
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.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.
58 58
59 - **Écran principal** :59 - **Écran principal** :
60- - Recherche instantanée (nom, URL, tags) et filtres par date60+ - Barre d'outils compacte regroupant recherche (nom, URL, tags), filtre par période et actions
61+ - Recherche instantanée avec compteur de résultats dès qu'un filtre est actif
62+ - Filtre par période : « 7 derniers jours », « 30 derniers jours » ou plage personnalisée
61 - Boutons « Ajouter un flux », « Synchroniser tous », « Exporter », « Importer »63 - 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 cours64 - 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)65 - Actions par flux : vue détaillée (📖), synchroniser (🔄), éditer (✏️), supprimer (🗑️ avec confirmation)
@@ -161,7 +163,8 @@ Les fichiers de configuration sont stockés dans un répertoire dédié :
161 │ ├── stats_panel.rs # Fenêtre Statistiques163 │ ├── stats_panel.rs # Fenêtre Statistiques
162 │ ├── status_bar.rs # Barre de statut164 │ ├── status_bar.rs # Barre de statut
163 │ ├── sync_status.rs # Indicateur de synchronisation165 │ ├── sync_status.rs # Indicateur de synchronisation
164-│ └── theme.rs # Palette de couleurs centralisée166+│ ├── theme.rs # Palette de couleurs centralisée
167+│ └── toolbar.rs # Barre d'outils (recherche, période, actions)
165 ├── spec/168 ├── spec/
166 │ ├── init-plan.md # Plan initial du projet169 │ ├── init-plan.md # Plan initial du projet
167 │ └── init-tasks.md # Tâches détaillées170 │ └── init-tasks.md # Tâches détaillées
modified src/config/app_state.rs +80 -0
@@ -35,6 +35,20 @@ impl Theme {
3535 }
3636 }
3737
38+/// Date filter presets for the feed list toolbar
39+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40+pub enum DatePreset {
41+ /// No date filter
42+ #[default]
43+ All,
44+ /// Episodes published in the last 7 days
45+ Last7Days,
46+ /// Episodes published in the last 30 days
47+ Last30Days,
48+ /// Custom date range (set via the date pickers)
49+ Custom,
50+}
51+
3852 /// Main application state
3953 ///
4054 /// This struct holds all the state needed for the RSS Podcast Downloader application.
@@ -91,6 +105,8 @@ pub struct AppState {
91105 pub filter_date_from: Option<DateTime<Utc>>,
92106 /// Filter: date to (inclusive)
93107 pub filter_date_to: Option<DateTime<Utc>>,
108+ /// Active date filter preset shown in the toolbar
109+ pub date_preset: DatePreset,
94110 /// Whether to show the date picker for 'from' date
95111 pub show_date_picker_from: bool,
96112 /// Whether to show the date picker for 'to' date
@@ -165,6 +181,7 @@ impl AppState {
165181 playlists: Vec::new(),
166182 filter_date_from: None,
167183 filter_date_to: None,
184+ date_preset: DatePreset::default(),
168185 show_date_picker_from: false,
169186 show_date_picker_to: false,
170187 show_export_dialog: false,
@@ -357,9 +374,42 @@ impl AppState {
357374 pub fn clear_filters(&mut self) {
358375 self.filter_date_from = None;
359376 self.filter_date_to = None;
377+ self.date_preset = DatePreset::All;
360378 self.notification_manager.info("Filtres effacés");
361379 }
362380
381+ /// Applies a date filter preset, updating the active date range
382+ pub fn apply_date_preset(&mut self, preset: DatePreset) {
383+ self.date_preset = preset;
384+ match preset {
385+ DatePreset::All => {
386+ self.filter_date_from = None;
387+ self.filter_date_to = None;
388+ }
389+ DatePreset::Last7Days => {
390+ self.filter_date_from = Some(Utc::now() - chrono::Duration::days(7));
391+ self.filter_date_to = None;
392+ }
393+ DatePreset::Last30Days => {
394+ self.filter_date_from = Some(Utc::now() - chrono::Duration::days(30));
395+ self.filter_date_to = None;
396+ }
397+ DatePreset::Custom => {
398+ self.show_date_picker_from = true;
399+ }
400+ }
401+ }
402+
403+ /// Returns true if a date filter is active
404+ pub fn has_date_filter(&self) -> bool {
405+ self.date_preset != DatePreset::All
406+ }
407+
408+ /// Returns true if a search or date filter is active
409+ pub fn has_active_filters(&self) -> bool {
410+ self.has_date_filter() || !self.search_query.is_empty()
411+ }
412+
363413 /// Sets an export/import error message
364414 pub fn set_export_import_error(&mut self, message: impl Into<String>) {
365415 let msg: String = message.into();
@@ -475,4 +525,34 @@ mod tests {
475525
476526 assert_eq!(app_state.enabled_feed_count(), 2);
477527 }
528+
529+ #[test]
530+ fn test_date_presets() {
531+ let mut app_state = AppState::new();
532+ assert_eq!(app_state.date_preset, DatePreset::All);
533+ assert!(!app_state.has_date_filter());
534+ assert!(!app_state.has_active_filters());
535+
536+ app_state.apply_date_preset(DatePreset::Last7Days);
537+ assert!(app_state.has_date_filter());
538+ assert!(app_state.has_active_filters());
539+ let from = app_state.filter_date_from.expect("from date set by preset");
540+ let seven_days_ago = Utc::now() - chrono::Duration::days(7);
541+ assert!((from - seven_days_ago).num_seconds().abs() < 60);
542+ assert!(app_state.filter_date_to.is_none());
543+
544+ app_state.apply_date_preset(DatePreset::Last30Days);
545+ let from = app_state.filter_date_from.expect("from date set by preset");
546+ let thirty_days_ago = Utc::now() - chrono::Duration::days(30);
547+ assert!((from - thirty_days_ago).num_seconds().abs() < 60);
548+
549+ app_state.apply_date_preset(DatePreset::Custom);
550+ assert!(app_state.show_date_picker_from);
551+
552+ app_state.clear_filters();
553+ assert_eq!(app_state.date_preset, DatePreset::All);
554+ assert!(app_state.filter_date_from.is_none());
555+ assert!(app_state.filter_date_to.is_none());
556+ assert!(!app_state.has_active_filters());
557+ }
478558 }
@@ -35,6 +35,20 @@ impl Theme {
35 }35 }
36 }36 }
37 37
38+/// Date filter presets for the feed list toolbar
39+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40+pub enum DatePreset {
41+ /// No date filter
42+ #[default]
43+ All,
44+ /// Episodes published in the last 7 days
45+ Last7Days,
46+ /// Episodes published in the last 30 days
47+ Last30Days,
48+ /// Custom date range (set via the date pickers)
49+ Custom,
50+}
51+
38 /// Main application state52 /// Main application state
39 ///53 ///
40 /// This struct holds all the state needed for the RSS Podcast Downloader application.54 /// This struct holds all the state needed for the RSS Podcast Downloader application.
@@ -91,6 +105,8 @@ pub struct AppState {
91 pub filter_date_from: Option<DateTime<Utc>>,105 pub filter_date_from: Option<DateTime<Utc>>,
92 /// Filter: date to (inclusive)106 /// Filter: date to (inclusive)
93 pub filter_date_to: Option<DateTime<Utc>>,107 pub filter_date_to: Option<DateTime<Utc>>,
108+ /// Active date filter preset shown in the toolbar
109+ pub date_preset: DatePreset,
94 /// Whether to show the date picker for 'from' date110 /// Whether to show the date picker for 'from' date
95 pub show_date_picker_from: bool,111 pub show_date_picker_from: bool,
96 /// Whether to show the date picker for 'to' date112 /// Whether to show the date picker for 'to' date
@@ -165,6 +181,7 @@ impl AppState {
165 playlists: Vec::new(),181 playlists: Vec::new(),
166 filter_date_from: None,182 filter_date_from: None,
167 filter_date_to: None,183 filter_date_to: None,
184+ date_preset: DatePreset::default(),
168 show_date_picker_from: false,185 show_date_picker_from: false,
169 show_date_picker_to: false,186 show_date_picker_to: false,
170 show_export_dialog: false,187 show_export_dialog: false,
@@ -357,9 +374,42 @@ impl AppState {
357 pub fn clear_filters(&mut self) {374 pub fn clear_filters(&mut self) {
358 self.filter_date_from = None;375 self.filter_date_from = None;
359 self.filter_date_to = None;376 self.filter_date_to = None;
377+ self.date_preset = DatePreset::All;
360 self.notification_manager.info("Filtres effacés");378 self.notification_manager.info("Filtres effacés");
361 }379 }
362 380
381+ /// Applies a date filter preset, updating the active date range
382+ pub fn apply_date_preset(&mut self, preset: DatePreset) {
383+ self.date_preset = preset;
384+ match preset {
385+ DatePreset::All => {
386+ self.filter_date_from = None;
387+ self.filter_date_to = None;
388+ }
389+ DatePreset::Last7Days => {
390+ self.filter_date_from = Some(Utc::now() - chrono::Duration::days(7));
391+ self.filter_date_to = None;
392+ }
393+ DatePreset::Last30Days => {
394+ self.filter_date_from = Some(Utc::now() - chrono::Duration::days(30));
395+ self.filter_date_to = None;
396+ }
397+ DatePreset::Custom => {
398+ self.show_date_picker_from = true;
399+ }
400+ }
401+ }
402+
403+ /// Returns true if a date filter is active
404+ pub fn has_date_filter(&self) -> bool {
405+ self.date_preset != DatePreset::All
406+ }
407+
408+ /// Returns true if a search or date filter is active
409+ pub fn has_active_filters(&self) -> bool {
410+ self.has_date_filter() || !self.search_query.is_empty()
411+ }
412+
363 /// Sets an export/import error message413 /// Sets an export/import error message
364 pub fn set_export_import_error(&mut self, message: impl Into<String>) {414 pub fn set_export_import_error(&mut self, message: impl Into<String>) {
365 let msg: String = message.into();415 let msg: String = message.into();
@@ -475,4 +525,34 @@ mod tests {
475 525
476 assert_eq!(app_state.enabled_feed_count(), 2);526 assert_eq!(app_state.enabled_feed_count(), 2);
477 }527 }
528+
529+ #[test]
530+ fn test_date_presets() {
531+ let mut app_state = AppState::new();
532+ assert_eq!(app_state.date_preset, DatePreset::All);
533+ assert!(!app_state.has_date_filter());
534+ assert!(!app_state.has_active_filters());
535+
536+ app_state.apply_date_preset(DatePreset::Last7Days);
537+ assert!(app_state.has_date_filter());
538+ assert!(app_state.has_active_filters());
539+ let from = app_state.filter_date_from.expect("from date set by preset");
540+ let seven_days_ago = Utc::now() - chrono::Duration::days(7);
541+ assert!((from - seven_days_ago).num_seconds().abs() < 60);
542+ assert!(app_state.filter_date_to.is_none());
543+
544+ app_state.apply_date_preset(DatePreset::Last30Days);
545+ let from = app_state.filter_date_from.expect("from date set by preset");
546+ let thirty_days_ago = Utc::now() - chrono::Duration::days(30);
547+ assert!((from - thirty_days_ago).num_seconds().abs() < 60);
548+
549+ app_state.apply_date_preset(DatePreset::Custom);
550+ assert!(app_state.show_date_picker_from);
551+
552+ app_state.clear_filters();
553+ assert_eq!(app_state.date_preset, DatePreset::All);
554+ assert!(app_state.filter_date_from.is_none());
555+ assert!(app_state.filter_date_to.is_none());
556+ assert!(!app_state.has_active_filters());
557+ }
478 }558 }
modified src/ui/feed_list.rs +239 -163
@@ -11,20 +11,9 @@ pub fn draw_feed_list(state: &mut AppState, ui: &mut Ui) {
1111 // Apply filters
1212 let filtered_indices = crate::ui::filter_panel::filter_feeds(&state.feeds, state);
1313
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-
14+ // Le compteur de résultats est affiché dans la barre d'outils
2615 if filtered_indices.is_empty() {
27- if searching {
16+ if !state.search_query.is_empty() {
2817 ui.label("Aucun flux correspondant à la recherche.");
2918 } else {
3019 ui.label("Aucun flux correspondant aux filtres.");
@@ -67,180 +56,211 @@ fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) {
6756 ..Default::default()
6857 }
6958 .show(ui, |ui| {
59+ // Carte sur toute la largeur disponible
60+ ui.set_min_width(ui.available_width());
7061 ui.spacing_mut().item_spacing = egui::Vec2::new(16.0, 8.0);
7162
72- ui.horizontal(|ui| {
73- // Section info principale
74- ui.vertical(|ui| {
75- // Nom du flux (plus gros et en gras)
76- ui.label(
77- RichText::new(&feed_name)
78- .size(16.0)
79- .strong()
80- .color(colors.text_primary),
81- );
82-
83- // URL (plus petite)
84- ui.label(
85- RichText::new(format!("🔗 {}", feed_url))
86- .size(12.0)
87- .color(colors.text_secondary),
88- );
89-
90- // Dossier
91- ui.label(
92- RichText::new(format!("📁 {}", feed_path))
93- .size(12.0)
94- .color(colors.text_secondary),
95- );
96-
97- // Tags
98- if !feed_tags.is_empty() {
99- ui.horizontal_wrapped(|ui| {
100- ui.label(RichText::new("🏷️ ").size(12.0).color(colors.text_dim));
101- for tag in &feed_tags {
63+ // Layout droite-à-gauche : les boutons puis le bloc statut prennent
64+ // leur largeur en premier, et le bloc info reçoit la largeur restante
65+ // où ses textes longs se replient. La ligne ne peut ainsi jamais
66+ // dépasser la largeur de la fenêtre.
67+ ui.with_layout(Layout::right_to_left(Align::TOP), |ui| {
68+ ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
69+
70+ // Section actions (à droite ; ajoutées en ordre inverse pour
71+ // conserver l'ordre visuel)
72+ if ui
73+ .button(RichText::new("🗑️").size(18.0))
74+ .on_hover_text("Supprimer ce flux")
75+ .clicked()
76+ {
77+ state.confirm_delete_feed_id = Some(feed_id.clone());
78+ }
79+
80+ if ui
81+ .button(RichText::new("✏️").size(18.0))
82+ .on_hover_text("Éditer ce flux")
83+ .clicked()
84+ {
85+ state.editing_feed_index = Some(index);
86+ state.load_edit_feed_form(index);
87+ state.show_edit_feed_dialog = true;
88+ }
89+
90+ if ui
91+ .button(RichText::new("🔄").size(18.0))
92+ .on_hover_text("Synchroniser ce flux")
93+ .clicked()
94+ {
95+ sync_feed_async(state, index);
96+ }
97+
98+ if ui
99+ .button(RichText::new("📖").size(18.0))
100+ .on_hover_text("Voir les épisodes de ce flux")
101+ .clicked()
102+ {
103+ state.open_feed_detail(feed_id.clone());
104+ }
105+
106+ ui.add_space(8.0);
107+
108+ // Section statut et statistiques (largeur bornée)
109+ let status_width = ui.available_width().min(165.0);
110+ ui.allocate_ui_with_layout(
111+ egui::Vec2::new(status_width, 0.0),
112+ Layout::top_down(Align::LEFT),
113+ |ui| {
114+ ui.spacing_mut().item_spacing = egui::Vec2::new(16.0, 8.0);
115+
116+ // Statut Activé/Désactivé
117+ let status_text = if feed_enabled {
118+ RichText::new("✅ Activé").color(colors.success)
119+ } else {
120+ RichText::new("❌ Désactivé").color(colors.warning)
121+ };
122+ ui.label(status_text.size(12.0));
123+
124+ // Statut de synchronisation de ce flux
125+ match feed_sync_status {
126+ Some(crate::config::sync_task::FeedSyncStatus::InProgress) => {
102127 ui.label(
103- RichText::new(format!("#{} ", tag))
128+ RichText::new("⏳ Synchronisation…")
104129 .size(11.0)
105130 .color(colors.accent),
106131 );
107132 }
108- });
109- }
110- });
111-
112- // Section statut et statistiques
113- ui.vertical(|ui| {
114- ui.add_space(4.0);
115-
116- // Statut Activé/Désactivé
117- let status_text = if feed_enabled {
118- RichText::new("✅ Activé").color(colors.success)
119- } else {
120- RichText::new("❌ Désactivé").color(colors.warning)
121- };
122- ui.label(status_text.size(12.0));
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));
133+ Some(crate::config::sync_task::FeedSyncStatus::Failed { error }) => {
134+ ui.label(
135+ RichText::new("⚠️ Échec de synchro")
136+ .size(11.0)
137+ .color(colors.error),
138+ )
139+ .on_hover_text(&error);
140+ }
141+ _ => {
142+ // Dernière vérification
143+ if let Some(date) = feed_last_checked {
144+ ui.label(
145+ RichText::new(format!(
146+ "🔍 Vérifié: {}",
147+ date.format("%d/%m %H:%M")
148+ ))
149+ .size(11.0)
150+ .color(colors.text_secondary),
151+ );
152+ } else {
153+ ui.label(
154+ RichText::new("🔍 Jamais vérifié")
155+ .size(11.0)
156+ .color(colors.text_dim),
157+ );
158+ }
159+ }
128160 }
129- Some(crate::config::sync_task::FeedSyncStatus::Failed { error }) => {
161+
162+ // Séparateur tracé manuellement : ui.separator()
163+ // occuperait toute la largeur restante du parent
164+ ui.add_space(3.0);
165+ let separator_y = ui.cursor().top();
166+ ui.add_space(3.0);
167+
168+ // Section Statistiques de téléchargement (mise en évidence)
169+ ui.label(
170+ RichText::new("📊 Téléchargements")
171+ .size(11.0)
172+ .strong()
173+ .color(colors.accent),
174+ )
175+ .on_hover_text("Nombre et dernier épisode téléchargé");
176+
177+ // Nombre de téléchargements
178+ if feed_downloaded_count > 0 {
130179 ui.label(
131- RichText::new("⚠️ Échec de synchro")
180+ RichText::new(format!("📥 {} épisodes", feed_downloaded_count))
132181 .size(11.0)
133- .color(colors.error),
134- )
135- .on_hover_text(&error);
182+ .color(colors.success),
183+ );
184+ } else {
185+ ui.label(
186+ RichText::new("📥 Aucun téléchargement")
187+ .size(11.0)
188+ .color(colors.text_dim),
189+ );
136190 }
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 {
191+
192+ // Dernier épisode téléchargé
193+ if let Some(last_name) = feed_last_downloaded_name {
194+ let display_name = crate::ui::truncate(&last_name, 25);
195+ ui.label(
196+ RichText::new(format!("🎵 {}", display_name))
197+ .size(11.0)
198+ .color(colors.accent),
199+ );
200+ if let Some(last_date) = feed_last_downloaded_date {
146201 ui.label(
147- RichText::new("🔍 Jamais vérifié")
148- .size(11.0)
202+ RichText::new(format!(" le {}", last_date.format("%d/%m %H:%M")))
203+ .size(10.0)
149204 .color(colors.text_dim),
150205 );
151206 }
152207 }
153- }
154-
155- // Séparateur visuel
156- ui.add_space(6.0);
157- ui.separator();
158- ui.add_space(4.0);
159-
160- // Section Statistiques de téléchargement (mise en évidence)
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-
169- // Nombre de téléchargements
170- if feed_downloaded_count > 0 {
208+
209+ ui.painter().hline(
210+ ui.min_rect().x_range(),
211+ separator_y,
212+ ui.style().visuals.widgets.noninteractive.bg_stroke,
213+ );
214+ },
215+ );
216+
217+ ui.add_space(8.0);
218+
219+ // Section info principale : reçoit toute la largeur restante
220+ let info_width = ui.available_width().max(0.0);
221+ ui.allocate_ui_with_layout(
222+ egui::Vec2::new(info_width, 0.0),
223+ Layout::top_down(Align::LEFT),
224+ |ui| {
225+ ui.spacing_mut().item_spacing = egui::Vec2::new(16.0, 8.0);
226+
227+ // Nom du flux (plus gros et en gras)
171228 ui.label(
172- RichText::new(format!("📥 {} épisodes", feed_downloaded_count))
173- .size(11.0)
174- .color(colors.success),
229+ RichText::new(&feed_name)
230+ .size(16.0)
231+ .strong()
232+ .color(colors.text_primary),
175233 );
176- } else {
234+
235+ // URL (plus petite)
177236 ui.label(
178- RichText::new("📥 Aucun téléchargement")
179- .size(11.0)
180- .color(colors.text_dim),
237+ RichText::new(format!("🔗 {}", feed_url))
238+ .size(12.0)
239+ .color(colors.text_secondary),
181240 );
182- }
183241
184- // Dernier épisode téléchargé
185- if let Some(last_name) = feed_last_downloaded_name {
186- let display_name = crate::ui::truncate(&last_name, 25);
242+ // Dossier
187243 ui.label(
188- RichText::new(format!("🎵 {}", display_name))
189- .size(11.0)
190- .color(colors.accent),
244+ RichText::new(format!("📁 {}", feed_path))
245+ .size(12.0)
246+ .color(colors.text_secondary),
191247 );
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- );
248+
249+ // Tags
250+ if !feed_tags.is_empty() {
251+ ui.horizontal_wrapped(|ui| {
252+ ui.label(RichText::new("🏷️ ").size(12.0).color(colors.text_dim));
253+ for tag in &feed_tags {
254+ ui.label(
255+ RichText::new(format!("#{} ", tag))
256+ .size(11.0)
257+ .color(colors.accent),
258+ );
259+ }
260+ });
198261 }
199- }
200- });
201-
202- // Section actions (à droite)
203- ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
204- ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
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-
215- // Bouton Synchroniser
216- if ui
217- .button(RichText::new("🔄").size(18.0))
218- .on_hover_text("Synchroniser ce flux")
219- .clicked()
220- {
221- sync_feed_async(state, index);
222- }
223-
224- // Bouton Éditer
225- if ui
226- .button(RichText::new("✏️").size(18.0))
227- .on_hover_text("Éditer ce flux")
228- .clicked()
229- {
230- state.editing_feed_index = Some(index);
231- state.load_edit_feed_form(index);
232- state.show_edit_feed_dialog = true;
233- }
234-
235- // Bouton Supprimer (avec confirmation)
236- if ui
237- .button(RichText::new("🗑️").size(18.0))
238- .on_hover_text("Supprimer ce flux")
239- .clicked()
240- {
241- state.confirm_delete_feed_id = Some(feed_id.clone());
242- }
243- });
262+ },
263+ );
244264 });
245265 });
246266
@@ -271,3 +291,59 @@ fn sync_feed_async(state: &mut AppState, index: usize) {
271291 .notification_manager
272292 .info(format!("Synchronisation du flux en cours: {}", state.feeds[index].name));
273293 }
294+
295+#[cfg(test)]
296+mod tests {
297+ use super::*;
298+ use crate::config::feed::Feed;
299+
300+ /// Reproduit la boucle de rétroaction de la `ScrollArea::vertical()` de
301+ /// l'écran principal (`auto_shrink([false, false])`) : la largeur interne
302+ /// de la zone de défilement s'élargit à la largeur du contenu d'une frame
303+ /// à l'autre. Si une ligne de flux dépasse la largeur qui lui est donnée,
304+ /// elle s'élargit sans limite à chaque repaint.
305+ #[test]
306+ fn feed_rows_never_exceed_available_width() {
307+ let mut state = crate::config::AppState::with_default_config();
308+ state.feeds.push(Feed {
309+ url: "https://example.com/feeds/chemin/tres/long/pour/forcer/le/retour/a/la/ligne.xml"
310+ .to_string(),
311+ name: "Flux de test au nom volontairement long pour occuper l'espace".to_string(),
312+ download_path: std::path::PathBuf::from(
313+ "C:\\Users\\rorop\\Music\\Podcasts\\Dossier tres long",
314+ ),
315+ tags: vec!["Tech".to_string(), "Actus".to_string()],
316+ downloaded_episodes_count: 12,
317+ ..Default::default()
318+ });
319+
320+ for viewport in [400.0, 700.0, 1000.0] {
321+ let mut inner_width = viewport;
322+ let mut widths = Vec::new();
323+ for _ in 0..10 {
324+ let ctx = egui::Context::default();
325+ let mut row_width = 0.0;
326+ let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
327+ let builder = egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
328+ egui::Pos2::ZERO,
329+ egui::Vec2::new(inner_width, 2000.0),
330+ ));
331+ let mut child = ui.new_child(builder);
332+ draw_feed_list(&mut state, &mut child);
333+ row_width = child.min_rect().width();
334+ });
335+ widths.push(row_width);
336+ inner_width = inner_width.max(row_width);
337+ }
338+
339+ assert!(
340+ widths.iter().all(|w| *w <= viewport + 1.0),
341+ "une ligne dépasse la largeur de la fenêtre ({viewport}) : {widths:?}"
342+ );
343+ assert!(
344+ widths.iter().skip(1).all(|w| (w - widths[0]).abs() < 1.0),
345+ "la largeur des lignes varie d'une frame à l'autre ({viewport}) : {widths:?}"
346+ );
347+ }
348+ }
349+}
@@ -11,20 +11,9 @@ pub fn draw_feed_list(state: &mut AppState, ui: &mut Ui) {
11 // Apply filters11 // Apply filters
12 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);
13 13
14- let searching = !state.search_query.is_empty();14+ // Le compteur de résultats est affiché dans la barre d'outils
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-
26 if filtered_indices.is_empty() {15 if filtered_indices.is_empty() {
27- if searching {16+ if !state.search_query.is_empty() {
28 ui.label("Aucun flux correspondant à la recherche.");17 ui.label("Aucun flux correspondant à la recherche.");
29 } else {18 } else {
30 ui.label("Aucun flux correspondant aux filtres.");19 ui.label("Aucun flux correspondant aux filtres.");
@@ -67,180 +56,211 @@ fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) {
67 ..Default::default()56 ..Default::default()
68 }57 }
69 .show(ui, |ui| {58 .show(ui, |ui| {
59+ // Carte sur toute la largeur disponible
60+ ui.set_min_width(ui.available_width());
70 ui.spacing_mut().item_spacing = egui::Vec2::new(16.0, 8.0);61 ui.spacing_mut().item_spacing = egui::Vec2::new(16.0, 8.0);
71 62
72- ui.horizontal(|ui| {63+ // Layout droite-à-gauche : les boutons puis le bloc statut prennent
73- // Section info principale64+ // leur largeur en premier, et le bloc info reçoit la largeur restante
74- ui.vertical(|ui| {65+ // où ses textes longs se replient. La ligne ne peut ainsi jamais
75- // Nom du flux (plus gros et en gras)66+ // dépasser la largeur de la fenêtre.
76- ui.label(67+ ui.with_layout(Layout::right_to_left(Align::TOP), |ui| {
77- RichText::new(&feed_name)68+ ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
78- .size(16.0)69+
79- .strong()70+ // Section actions (à droite ; ajoutées en ordre inverse pour
80- .color(colors.text_primary),71+ // conserver l'ordre visuel)
81- );72+ if ui
82-73+ .button(RichText::new("🗑️").size(18.0))
83- // URL (plus petite)74+ .on_hover_text("Supprimer ce flux")
84- ui.label(75+ .clicked()
85- RichText::new(format!("🔗 {}", feed_url))76+ {
86- .size(12.0)77+ state.confirm_delete_feed_id = Some(feed_id.clone());
87- .color(colors.text_secondary),78+ }
88- );79+
89-80+ if ui
90- // Dossier81+ .button(RichText::new("✏️").size(18.0))
91- ui.label(82+ .on_hover_text("Éditer ce flux")
92- RichText::new(format!("📁 {}", feed_path))83+ .clicked()
93- .size(12.0)84+ {
94- .color(colors.text_secondary),85+ state.editing_feed_index = Some(index);
95- );86+ state.load_edit_feed_form(index);
96-87+ state.show_edit_feed_dialog = true;
97- // Tags88+ }
98- if !feed_tags.is_empty() {89+
99- ui.horizontal_wrapped(|ui| {90+ if ui
100- ui.label(RichText::new("🏷️ ").size(12.0).color(colors.text_dim));91+ .button(RichText::new("🔄").size(18.0))
101- for tag in &feed_tags {92+ .on_hover_text("Synchroniser ce flux")
93+ .clicked()
94+ {
95+ sync_feed_async(state, index);
96+ }
97+
98+ if ui
99+ .button(RichText::new("📖").size(18.0))
100+ .on_hover_text("Voir les épisodes de ce flux")
101+ .clicked()
102+ {
103+ state.open_feed_detail(feed_id.clone());
104+ }
105+
106+ ui.add_space(8.0);
107+
108+ // Section statut et statistiques (largeur bornée)
109+ let status_width = ui.available_width().min(165.0);
110+ ui.allocate_ui_with_layout(
111+ egui::Vec2::new(status_width, 0.0),
112+ Layout::top_down(Align::LEFT),
113+ |ui| {
114+ ui.spacing_mut().item_spacing = egui::Vec2::new(16.0, 8.0);
115+
116+ // Statut Activé/Désactivé
117+ let status_text = if feed_enabled {
118+ RichText::new("✅ Activé").color(colors.success)
119+ } else {
120+ RichText::new("❌ Désactivé").color(colors.warning)
121+ };
122+ ui.label(status_text.size(12.0));
123+
124+ // Statut de synchronisation de ce flux
125+ match feed_sync_status {
126+ Some(crate::config::sync_task::FeedSyncStatus::InProgress) => {
102 ui.label(127 ui.label(
103- RichText::new(format!("#{} ", tag))128+ RichText::new("⏳ Synchronisation…")
104 .size(11.0)129 .size(11.0)
105 .color(colors.accent),130 .color(colors.accent),
106 );131 );
107 }132 }
108- });133+ Some(crate::config::sync_task::FeedSyncStatus::Failed { error }) => {
109- }134+ ui.label(
110- });135+ RichText::new("⚠️ Échec de synchro")
111-136+ .size(11.0)
112- // Section statut et statistiques137+ .color(colors.error),
113- ui.vertical(|ui| {138+ )
114- ui.add_space(4.0);139+ .on_hover_text(&error);
115-140+ }
116- // Statut Activé/Désactivé141+ _ => {
117- let status_text = if feed_enabled {142+ // Dernière vérification
118- RichText::new("✅ Activé").color(colors.success)143+ if let Some(date) = feed_last_checked {
119- } else {144+ ui.label(
120- RichText::new("❌ Désactivé").color(colors.warning)145+ RichText::new(format!(
121- };146+ "🔍 Vérifié: {}",
122- ui.label(status_text.size(12.0));147+ date.format("%d/%m %H:%M")
123-148+ ))
124- // Statut de synchronisation de ce flux149+ .size(11.0)
125- match feed_sync_status {150+ .color(colors.text_secondary),
126- Some(crate::config::sync_task::FeedSyncStatus::InProgress) => {151+ );
127- ui.label(RichText::new("⏳ Synchronisation…").size(11.0).color(colors.accent));152+ } else {
153+ ui.label(
154+ RichText::new("🔍 Jamais vérifié")
155+ .size(11.0)
156+ .color(colors.text_dim),
157+ );
158+ }
159+ }
128 }160 }
129- Some(crate::config::sync_task::FeedSyncStatus::Failed { error }) => {161+
162+ // Séparateur tracé manuellement : ui.separator()
163+ // occuperait toute la largeur restante du parent
164+ ui.add_space(3.0);
165+ let separator_y = ui.cursor().top();
166+ ui.add_space(3.0);
167+
168+ // Section Statistiques de téléchargement (mise en évidence)
169+ ui.label(
170+ RichText::new("📊 Téléchargements")
171+ .size(11.0)
172+ .strong()
173+ .color(colors.accent),
174+ )
175+ .on_hover_text("Nombre et dernier épisode téléchargé");
176+
177+ // Nombre de téléchargements
178+ if feed_downloaded_count > 0 {
130 ui.label(179 ui.label(
131- RichText::new("⚠️ Échec de synchro")180+ RichText::new(format!("📥 {} épisodes", feed_downloaded_count))
132 .size(11.0)181 .size(11.0)
133- .color(colors.error),182+ .color(colors.success),
134- )183+ );
135- .on_hover_text(&error);184+ } else {
185+ ui.label(
186+ RichText::new("📥 Aucun téléchargement")
187+ .size(11.0)
188+ .color(colors.text_dim),
189+ );
136 }190 }
137- _ => {191+
138- // Dernière vérification192+ // Dernier épisode téléchargé
139- if let Some(date) = feed_last_checked {193+ if let Some(last_name) = feed_last_downloaded_name {
140- ui.label(194+ let display_name = crate::ui::truncate(&last_name, 25);
141- RichText::new(format!("🔍 Vérifié: {}", date.format("%d/%m %H:%M")))195+ ui.label(
142- .size(11.0)196+ RichText::new(format!("🎵 {}", display_name))
143- .color(colors.text_secondary),197+ .size(11.0)
144- );198+ .color(colors.accent),
145- } else {199+ );
200+ if let Some(last_date) = feed_last_downloaded_date {
146 ui.label(201 ui.label(
147- RichText::new("🔍 Jamais vérifié")202+ RichText::new(format!(" le {}", last_date.format("%d/%m %H:%M")))
148- .size(11.0)203+ .size(10.0)
149 .color(colors.text_dim),204 .color(colors.text_dim),
150 );205 );
151 }206 }
152 }207 }
153- }208+
154-209+ ui.painter().hline(
155- // Séparateur visuel210+ ui.min_rect().x_range(),
156- ui.add_space(6.0);211+ separator_y,
157- ui.separator();212+ ui.style().visuals.widgets.noninteractive.bg_stroke,
158- ui.add_space(4.0);213+ );
159-214+ },
160- // Section Statistiques de téléchargement (mise en évidence)215+ );
161- ui.label(216+
162- RichText::new("📊 Téléchargements")217+ ui.add_space(8.0);
163- .size(11.0)218+
164- .strong()219+ // Section info principale : reçoit toute la largeur restante
165- .color(colors.accent),220+ let info_width = ui.available_width().max(0.0);
166- )221+ ui.allocate_ui_with_layout(
167- .on_hover_text("Nombre et dernier épisode téléchargé");222+ egui::Vec2::new(info_width, 0.0),
168-223+ Layout::top_down(Align::LEFT),
169- // Nombre de téléchargements224+ |ui| {
170- if feed_downloaded_count > 0 {225+ ui.spacing_mut().item_spacing = egui::Vec2::new(16.0, 8.0);
226+
227+ // Nom du flux (plus gros et en gras)
171 ui.label(228 ui.label(
172- RichText::new(format!("📥 {} épisodes", feed_downloaded_count))229+ RichText::new(&feed_name)
173- .size(11.0)230+ .size(16.0)
174- .color(colors.success),231+ .strong()
232+ .color(colors.text_primary),
175 );233 );
176- } else {234+
235+ // URL (plus petite)
177 ui.label(236 ui.label(
178- RichText::new("📥 Aucun téléchargement")237+ RichText::new(format!("🔗 {}", feed_url))
179- .size(11.0)238+ .size(12.0)
180- .color(colors.text_dim),239+ .color(colors.text_secondary),
181 );240 );
182- }
183 241
184- // Dernier épisode téléchargé242+ // Dossier
185- if let Some(last_name) = feed_last_downloaded_name {
186- let display_name = crate::ui::truncate(&last_name, 25);
187 ui.label(243 ui.label(
188- RichText::new(format!("🎵 {}", display_name))244+ RichText::new(format!("📁 {}", feed_path))
189- .size(11.0)245+ .size(12.0)
190- .color(colors.accent),246+ .color(colors.text_secondary),
191 );247 );
192- if let Some(last_date) = feed_last_downloaded_date {248+
193- ui.label(249+ // Tags
194- RichText::new(format!(" le {}", last_date.format("%d/%m %H:%M")))250+ if !feed_tags.is_empty() {
195- .size(10.0)251+ ui.horizontal_wrapped(|ui| {
196- .color(colors.text_dim),252+ ui.label(RichText::new("🏷️ ").size(12.0).color(colors.text_dim));
197- );253+ for tag in &feed_tags {
254+ ui.label(
255+ RichText::new(format!("#{} ", tag))
256+ .size(11.0)
257+ .color(colors.accent),
258+ );
259+ }
260+ });
198 }261 }
199- }262+ },
200- });263+ );
201-
202- // Section actions (à droite)
203- ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
204- ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
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-
215- // Bouton Synchroniser
216- if ui
217- .button(RichText::new("🔄").size(18.0))
218- .on_hover_text("Synchroniser ce flux")
219- .clicked()
220- {
221- sync_feed_async(state, index);
222- }
223-
224- // Bouton Éditer
225- if ui
226- .button(RichText::new("✏️").size(18.0))
227- .on_hover_text("Éditer ce flux")
228- .clicked()
229- {
230- state.editing_feed_index = Some(index);
231- state.load_edit_feed_form(index);
232- state.show_edit_feed_dialog = true;
233- }
234-
235- // Bouton Supprimer (avec confirmation)
236- if ui
237- .button(RichText::new("🗑️").size(18.0))
238- .on_hover_text("Supprimer ce flux")
239- .clicked()
240- {
241- state.confirm_delete_feed_id = Some(feed_id.clone());
242- }
243- });
244 });264 });
245 });265 });
246 266
@@ -271,3 +291,59 @@ fn sync_feed_async(state: &mut AppState, index: usize) {
271 .notification_manager291 .notification_manager
272 .info(format!("Synchronisation du flux en cours: {}", state.feeds[index].name));292 .info(format!("Synchronisation du flux en cours: {}", state.feeds[index].name));
273 }293 }
294+
295+#[cfg(test)]
296+mod tests {
297+ use super::*;
298+ use crate::config::feed::Feed;
299+
300+ /// Reproduit la boucle de rétroaction de la `ScrollArea::vertical()` de
301+ /// l'écran principal (`auto_shrink([false, false])`) : la largeur interne
302+ /// de la zone de défilement s'élargit à la largeur du contenu d'une frame
303+ /// à l'autre. Si une ligne de flux dépasse la largeur qui lui est donnée,
304+ /// elle s'élargit sans limite à chaque repaint.
305+ #[test]
306+ fn feed_rows_never_exceed_available_width() {
307+ let mut state = crate::config::AppState::with_default_config();
308+ state.feeds.push(Feed {
309+ url: "https://example.com/feeds/chemin/tres/long/pour/forcer/le/retour/a/la/ligne.xml"
310+ .to_string(),
311+ name: "Flux de test au nom volontairement long pour occuper l'espace".to_string(),
312+ download_path: std::path::PathBuf::from(
313+ "C:\\Users\\rorop\\Music\\Podcasts\\Dossier tres long",
314+ ),
315+ tags: vec!["Tech".to_string(), "Actus".to_string()],
316+ downloaded_episodes_count: 12,
317+ ..Default::default()
318+ });
319+
320+ for viewport in [400.0, 700.0, 1000.0] {
321+ let mut inner_width = viewport;
322+ let mut widths = Vec::new();
323+ for _ in 0..10 {
324+ let ctx = egui::Context::default();
325+ let mut row_width = 0.0;
326+ let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
327+ let builder = egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
328+ egui::Pos2::ZERO,
329+ egui::Vec2::new(inner_width, 2000.0),
330+ ));
331+ let mut child = ui.new_child(builder);
332+ draw_feed_list(&mut state, &mut child);
333+ row_width = child.min_rect().width();
334+ });
335+ widths.push(row_width);
336+ inner_width = inner_width.max(row_width);
337+ }
338+
339+ assert!(
340+ widths.iter().all(|w| *w <= viewport + 1.0),
341+ "une ligne dépasse la largeur de la fenêtre ({viewport}) : {widths:?}"
342+ );
343+ assert!(
344+ widths.iter().skip(1).all(|w| (w - widths[0]).abs() < 1.0),
345+ "la largeur des lignes varie d'une frame à l'autre ({viewport}) : {widths:?}"
346+ );
347+ }
348+ }
349+}
modified src/ui/filter_panel.rs +12 -46
@@ -1,56 +1,15 @@
11 //! Filter Panel UI component
22 //!
3-//! This module contains the UI for filtering feeds and episodes.
3+//! Sélecteurs de dates pour la plage personnalisée du filtre par période,
4+//! et fonction de filtrage des flux (recherche + dates).
45
6+use crate::config::app_state::DatePreset;
57 use crate::config::AppState;
68 use chrono::{DateTime, Utc};
79 use egui::Ui;
810
9-/// Draws the filter panel for feeds
10-pub fn draw_filter_panel(state: &mut AppState, ui: &mut Ui) {
11- ui.horizontal(|ui| {
12- ui.label("Filtres:");
13-
14- // Date filter
15- ui.label("Date de publication:");
16-
17- // Date from filter
18- if let Some(date_from) = state.filter_date_from {
19- if ui
20- .button(format!("Depuis: {}", date_from.format("%Y-%m-%d")))
21- .clicked()
22- {
23- state.show_date_picker_from = true;
24- }
25- } else if ui.button("Depuis...").clicked() {
26- state.show_date_picker_from = true;
27- }
28-
29- ui.add_space(5.0);
30-
31- // Date to filter
32- if let Some(date_to) = state.filter_date_to {
33- if ui
34- .button(format!("Jusqu'à: {}", date_to.format("%Y-%m-%d")))
35- .clicked()
36- {
37- state.show_date_picker_to = true;
38- }
39- } else if ui.button("Jusqu'à...").clicked() {
40- state.show_date_picker_to = true;
41- }
42-
43- ui.add_space(10.0);
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- {
49- state.clear_filters();
50- }
51- });
52-
53- // Draw date pickers
11+/// Draws the custom date pickers (visible on demand)
12+pub fn draw_date_pickers(state: &mut AppState, ui: &mut Ui) {
5413 draw_date_picker_from(state, ui);
5514 draw_date_picker_to(state, ui);
5615 }
@@ -81,6 +40,7 @@ fn draw_date_picker_from(state: &mut AppState, ui: &mut Ui) {
8140 date.and_hms_opt(0, 0, 0).unwrap(),
8241 Utc,
8342 ));
43+ state.date_preset = DatePreset::Custom;
8444 state.show_date_picker_from = false;
8545 }
8646 }
@@ -96,16 +56,19 @@ fn draw_date_picker_from(state: &mut AppState, ui: &mut Ui) {
9656 // Quick date buttons
9757 if ui.button("Aujourd'hui").clicked() {
9858 state.filter_date_from = Some(Utc::now());
59+ state.date_preset = DatePreset::Custom;
9960 state.show_date_picker_from = false;
10061 }
10162
10263 if ui.button("7 derniers jours").clicked() {
10364 state.filter_date_from = Some(Utc::now() - chrono::Duration::days(7));
65+ state.date_preset = DatePreset::Custom;
10466 state.show_date_picker_from = false;
10567 }
10668
10769 if ui.button("30 derniers jours").clicked() {
10870 state.filter_date_from = Some(Utc::now() - chrono::Duration::days(30));
71+ state.date_preset = DatePreset::Custom;
10972 state.show_date_picker_from = false;
11073 }
11174
@@ -143,6 +106,7 @@ fn draw_date_picker_to(state: &mut AppState, ui: &mut Ui) {
143106 date.and_hms_opt(23, 59, 59).unwrap(),
144107 Utc,
145108 ));
109+ state.date_preset = DatePreset::Custom;
146110 state.show_date_picker_to = false;
147111 }
148112 }
@@ -158,12 +122,14 @@ fn draw_date_picker_to(state: &mut AppState, ui: &mut Ui) {
158122 // Quick date buttons
159123 if ui.button("Aujourd'hui").clicked() {
160124 state.filter_date_to = Some(Utc::now());
125+ state.date_preset = DatePreset::Custom;
161126 state.show_date_picker_to = false;
162127 }
163128
164129 if ui.button("7 derniers jours").clicked() {
165130 state.filter_date_to = Some(Utc::now());
166131 state.filter_date_from = Some(Utc::now() - chrono::Duration::days(7));
132+ state.date_preset = DatePreset::Custom;
167133 state.show_date_picker_to = false;
168134 }
169135
@@ -1,56 +1,15 @@
1 //! Filter Panel UI component1 //! Filter Panel UI component
2 //!2 //!
3-//! This module contains the UI for filtering feeds and episodes.3+//! Sélecteurs de dates pour la plage personnalisée du filtre par période,
4+//! et fonction de filtrage des flux (recherche + dates).
4 5
6+use crate::config::app_state::DatePreset;
5 use crate::config::AppState;7 use crate::config::AppState;
6 use chrono::{DateTime, Utc};8 use chrono::{DateTime, Utc};
7 use egui::Ui;9 use egui::Ui;
8 10
9-/// Draws the filter panel for feeds11+/// Draws the custom date pickers (visible on demand)
10-pub fn draw_filter_panel(state: &mut AppState, ui: &mut Ui) {12+pub fn draw_date_pickers(state: &mut AppState, ui: &mut Ui) {
11- ui.horizontal(|ui| {
12- ui.label("Filtres:");
13-
14- // Date filter
15- ui.label("Date de publication:");
16-
17- // Date from filter
18- if let Some(date_from) = state.filter_date_from {
19- if ui
20- .button(format!("Depuis: {}", date_from.format("%Y-%m-%d")))
21- .clicked()
22- {
23- state.show_date_picker_from = true;
24- }
25- } else if ui.button("Depuis...").clicked() {
26- state.show_date_picker_from = true;
27- }
28-
29- ui.add_space(5.0);
30-
31- // Date to filter
32- if let Some(date_to) = state.filter_date_to {
33- if ui
34- .button(format!("Jusqu'à: {}", date_to.format("%Y-%m-%d")))
35- .clicked()
36- {
37- state.show_date_picker_to = true;
38- }
39- } else if ui.button("Jusqu'à...").clicked() {
40- state.show_date_picker_to = true;
41- }
42-
43- ui.add_space(10.0);
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- {
49- state.clear_filters();
50- }
51- });
52-
53- // Draw date pickers
54 draw_date_picker_from(state, ui);13 draw_date_picker_from(state, ui);
55 draw_date_picker_to(state, ui);14 draw_date_picker_to(state, ui);
56 }15 }
@@ -81,6 +40,7 @@ fn draw_date_picker_from(state: &mut AppState, ui: &mut Ui) {
81 date.and_hms_opt(0, 0, 0).unwrap(),40 date.and_hms_opt(0, 0, 0).unwrap(),
82 Utc,41 Utc,
83 ));42 ));
43+ state.date_preset = DatePreset::Custom;
84 state.show_date_picker_from = false;44 state.show_date_picker_from = false;
85 }45 }
86 }46 }
@@ -96,16 +56,19 @@ fn draw_date_picker_from(state: &mut AppState, ui: &mut Ui) {
96 // Quick date buttons56 // Quick date buttons
97 if ui.button("Aujourd'hui").clicked() {57 if ui.button("Aujourd'hui").clicked() {
98 state.filter_date_from = Some(Utc::now());58 state.filter_date_from = Some(Utc::now());
59+ state.date_preset = DatePreset::Custom;
99 state.show_date_picker_from = false;60 state.show_date_picker_from = false;
100 }61 }
101 62
102 if ui.button("7 derniers jours").clicked() {63 if ui.button("7 derniers jours").clicked() {
103 state.filter_date_from = Some(Utc::now() - chrono::Duration::days(7));64 state.filter_date_from = Some(Utc::now() - chrono::Duration::days(7));
65+ state.date_preset = DatePreset::Custom;
104 state.show_date_picker_from = false;66 state.show_date_picker_from = false;
105 }67 }
106 68
107 if ui.button("30 derniers jours").clicked() {69 if ui.button("30 derniers jours").clicked() {
108 state.filter_date_from = Some(Utc::now() - chrono::Duration::days(30));70 state.filter_date_from = Some(Utc::now() - chrono::Duration::days(30));
71+ state.date_preset = DatePreset::Custom;
109 state.show_date_picker_from = false;72 state.show_date_picker_from = false;
110 }73 }
111 74
@@ -143,6 +106,7 @@ fn draw_date_picker_to(state: &mut AppState, ui: &mut Ui) {
143 date.and_hms_opt(23, 59, 59).unwrap(),106 date.and_hms_opt(23, 59, 59).unwrap(),
144 Utc,107 Utc,
145 ));108 ));
109+ state.date_preset = DatePreset::Custom;
146 state.show_date_picker_to = false;110 state.show_date_picker_to = false;
147 }111 }
148 }112 }
@@ -158,12 +122,14 @@ fn draw_date_picker_to(state: &mut AppState, ui: &mut Ui) {
158 // Quick date buttons122 // Quick date buttons
159 if ui.button("Aujourd'hui").clicked() {123 if ui.button("Aujourd'hui").clicked() {
160 state.filter_date_to = Some(Utc::now());124 state.filter_date_to = Some(Utc::now());
125+ state.date_preset = DatePreset::Custom;
161 state.show_date_picker_to = false;126 state.show_date_picker_to = false;
162 }127 }
163 128
164 if ui.button("7 derniers jours").clicked() {129 if ui.button("7 derniers jours").clicked() {
165 state.filter_date_to = Some(Utc::now());130 state.filter_date_to = Some(Utc::now());
166 state.filter_date_from = Some(Utc::now() - chrono::Duration::days(7));131 state.filter_date_from = Some(Utc::now() - chrono::Duration::days(7));
132+ state.date_preset = DatePreset::Custom;
167 state.show_date_picker_to = false;133 state.show_date_picker_to = false;
168 }134 }
169 135
modified src/ui/home.rs +3 -59
@@ -28,26 +28,10 @@ pub fn draw_home(state: &mut AppState, ui: &mut Ui) {
2828
2929 ui.separator();
3030
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- });
31+ // Barre d'outils : recherche, filtre par période et actions
32+ crate::ui::toolbar::draw_toolbar(state, ui);
4833
49- ui.add_space(16.0);
50- ui.separator();
34+ ui.add_space(12.0);
5135
5236 if state.detail_feed_id.is_some() {
5337 // Vue détaillée d'un flux : remplace la liste des flux et les playlists
@@ -256,46 +240,6 @@ fn process_sync_messages(state: &mut AppState) {
256240 }
257241 }
258242
259-/// Draws the action buttons
260-fn draw_action_buttons(state: &mut AppState, ui: &mut Ui) {
261- ui.horizontal(|ui| {
262- // Boutons principaux
263- if ui
264- .button(egui::RichText::new("➕ Ajouter un flux").size(14.0))
265- .clicked()
266- {
267- state.clear_new_feed_form();
268- state.show_add_feed_dialog = true;
269- }
270-
271- if ui
272- .button(egui::RichText::new("🔄 Synchroniser tous").size(14.0))
273- .clicked()
274- {
275- state.start_sync_all_feeds();
276- }
277-
278- ui.add_space(10.0);
279-
280- // Boutons secondaires
281- if ui
282- .button(egui::RichText::new("📤 Exporter").size(14.0))
283- .clicked()
284- {
285- state.clear_export_import_error();
286- state.show_export_dialog = true;
287- }
288-
289- if ui
290- .button(egui::RichText::new("📥 Importer").size(14.0))
291- .clicked()
292- {
293- state.clear_export_import_error();
294- state.show_import_dialog = true;
295- }
296- });
297-}
298-
299243 /// Draws the empty state message
300244 fn draw_empty_state(ui: &mut Ui, colors: &ThemeColors) {
301245 ui.vertical_centered_justified(|ui| {
@@ -28,26 +28,10 @@ pub fn draw_home(state: &mut AppState, ui: &mut Ui) {
28 28
29 ui.separator();29 ui.separator();
30 30
31- // Barre d'outils : recherche + filtres31+ // Barre d'outils : recherche, filtre par période et actions
32- ui.horizontal(|ui| {32+ crate::ui::toolbar::draw_toolbar(state, 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 33
49- ui.add_space(16.0);34+ ui.add_space(12.0);
50- ui.separator();
51 35
52 if state.detail_feed_id.is_some() {36 if state.detail_feed_id.is_some() {
53 // Vue détaillée d'un flux : remplace la liste des flux et les playlists37 // Vue détaillée d'un flux : remplace la liste des flux et les playlists
@@ -256,46 +240,6 @@ fn process_sync_messages(state: &mut AppState) {
256 }240 }
257 }241 }
258 242
259-/// Draws the action buttons
260-fn draw_action_buttons(state: &mut AppState, ui: &mut Ui) {
261- ui.horizontal(|ui| {
262- // Boutons principaux
263- if ui
264- .button(egui::RichText::new("➕ Ajouter un flux").size(14.0))
265- .clicked()
266- {
267- state.clear_new_feed_form();
268- state.show_add_feed_dialog = true;
269- }
270-
271- if ui
272- .button(egui::RichText::new("🔄 Synchroniser tous").size(14.0))
273- .clicked()
274- {
275- state.start_sync_all_feeds();
276- }
277-
278- ui.add_space(10.0);
279-
280- // Boutons secondaires
281- if ui
282- .button(egui::RichText::new("📤 Exporter").size(14.0))
283- .clicked()
284- {
285- state.clear_export_import_error();
286- state.show_export_dialog = true;
287- }
288-
289- if ui
290- .button(egui::RichText::new("📥 Importer").size(14.0))
291- .clicked()
292- {
293- state.clear_export_import_error();
294- state.show_import_dialog = true;
295- }
296- });
297-}
298-
299 /// Draws the empty state message243 /// Draws the empty state message
300 fn draw_empty_state(ui: &mut Ui, colors: &ThemeColors) {244 fn draw_empty_state(ui: &mut Ui, colors: &ThemeColors) {
301 ui.vertical_centered_justified(|ui| {245 ui.vertical_centered_justified(|ui| {
modified src/ui/mod.rs +1 -0
@@ -11,6 +11,7 @@ pub mod status_bar;
1111 pub mod notifications;
1212 pub mod playlist_list;
1313 pub mod filter_panel;
14+pub mod toolbar;
1415 pub mod export_import;
1516 pub mod history_panel;
1617 pub mod search_bar;
@@ -11,6 +11,7 @@ pub mod status_bar;
11 pub mod notifications;11 pub mod notifications;
12 pub mod playlist_list;12 pub mod playlist_list;
13 pub mod filter_panel;13 pub mod filter_panel;
14+pub mod toolbar;
14 pub mod export_import;15 pub mod export_import;
15 pub mod history_panel;16 pub mod history_panel;
16 pub mod search_bar;17 pub mod search_bar;
modified src/ui/search_bar.rs +6 -3
@@ -7,14 +7,14 @@ use crate::config::search::SearchType;
77 use crate::config::AppState;
88 use egui::Ui;
99
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) {
10+/// Affiche le champ de recherche ; occupe toute la largeur restante
11+pub fn draw_search_field(state: &mut AppState, ui: &mut Ui) {
1212 ui.label("🔍");
1313
1414 let response = ui.add(
1515 egui::TextEdit::singleline(&mut state.search_query.query)
1616 .hint_text("Rechercher un flux (Ctrl+F)…")
17- .desired_width(200.0),
17+ .desired_width(f32::INFINITY),
1818 );
1919
2020 // Focus demandé via le raccourci Ctrl+F
@@ -22,7 +22,10 @@ pub fn draw_search_bar(state: &mut AppState, ui: &mut Ui) {
2222 response.request_focus();
2323 state.focus_search = false;
2424 }
25+}
2526
27+/// Affiche le bouton d'effacement et le sélecteur de type de recherche
28+pub fn draw_search_type_combo(state: &mut AppState, ui: &mut Ui) {
2629 if !state.search_query.is_empty()
2730 && ui
2831 .button("✕")
@@ -7,14 +7,14 @@ use crate::config::search::SearchType;
7 use crate::config::AppState;7 use crate::config::AppState;
8 use egui::Ui;8 use egui::Ui;
9 9
10-/// Affiche la barre de recherche et le sélecteur de type de recherche10+/// Affiche le champ de recherche ; occupe toute la largeur restante
11-pub fn draw_search_bar(state: &mut AppState, ui: &mut Ui) {11+pub fn draw_search_field(state: &mut AppState, ui: &mut Ui) {
12 ui.label("🔍");12 ui.label("🔍");
13 13
14 let response = ui.add(14 let response = ui.add(
15 egui::TextEdit::singleline(&mut state.search_query.query)15 egui::TextEdit::singleline(&mut state.search_query.query)
16 .hint_text("Rechercher un flux (Ctrl+F)…")16 .hint_text("Rechercher un flux (Ctrl+F)…")
17- .desired_width(200.0),17+ .desired_width(f32::INFINITY),
18 );18 );
19 19
20 // Focus demandé via le raccourci Ctrl+F20 // Focus demandé via le raccourci Ctrl+F
@@ -22,7 +22,10 @@ pub fn draw_search_bar(state: &mut AppState, ui: &mut Ui) {
22 response.request_focus();22 response.request_focus();
23 state.focus_search = false;23 state.focus_search = false;
24 }24 }
25+}
25 26
27+/// Affiche le bouton d'effacement et le sélecteur de type de recherche
28+pub fn draw_search_type_combo(state: &mut AppState, ui: &mut Ui) {
26 if !state.search_query.is_empty()29 if !state.search_query.is_empty()
27 && ui30 && ui
28 .button("✕")31 .button("✕")
added src/ui/toolbar.rs +292 -0
new file mode 100644
@@ -0,0 +1,292 @@
1+//! Toolbar UI component
2+//!
3+//! Barre d'outils principale de l'écran d'accueil : recherche, filtre par
4+//! période et boutons d'action, regroupés dans un cadre compact à thème.
5+//! Sur une fenêtre étroite, les actions et la recherche s'empilent sur
6+//! deux lignes pour ne jamais déborder de la largeur disponible.
7+
8+use crate::config::app_state::DatePreset;
9+use crate::config::AppState;
10+use crate::ui::theme::ThemeColors;
11+use egui::{Align, Layout, RichText, Ui};
12+
13+/// Largeur sous laquelle la barre s'empile sur deux lignes
14+const NARROW_WIDTH_THRESHOLD: f32 = 600.0;
15+
16+/// Draws the main toolbar: search + actions, then the date filter line
17+pub fn draw_toolbar(state: &mut AppState, ui: &mut Ui) {
18+ let colors = ThemeColors::for_theme(state.get_theme());
19+
20+ egui::Frame {
21+ fill: colors.bg_primary,
22+ stroke: egui::Stroke::new(1.0, colors.border),
23+ corner_radius: 6.0.into(),
24+ inner_margin: egui::Margin::symmetric(12, 8),
25+ ..Default::default()
26+ }
27+ .show(ui, |ui| {
28+ ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 8.0);
29+ draw_search_and_actions(state, ui, &colors);
30+ draw_date_filter(state, ui, &colors);
31+ });
32+}
33+
34+/// Search + actions: single line on wide windows, stacked on narrow ones
35+fn draw_search_and_actions(state: &mut AppState, ui: &mut Ui, colors: &ThemeColors) {
36+ let total_feeds = state.feed_count();
37+ let filtered_count = crate::ui::filter_panel::filter_feeds(&state.feeds, state).len();
38+ let global_syncing = state.sync_task_manager.status.global_sync_in_progress;
39+
40+ if ui.available_width() < NARROW_WIDTH_THRESHOLD {
41+ // Fenêtre étroite : actions puis recherche, sur deux lignes
42+ ui.horizontal(|ui| {
43+ draw_action_buttons(state, ui, global_syncing);
44+ });
45+ ui.horizontal(|ui| {
46+ ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
47+ draw_result_count(state, ui, colors, filtered_count, total_feeds);
48+ draw_search_controls(state, ui);
49+ });
50+ });
51+ } else {
52+ // Layout droite-à-gauche : les boutons prennent leur largeur en
53+ // premier et le champ de recherche reçoit toute la largeur restante.
54+ ui.horizontal(|ui| {
55+ ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
56+ draw_action_buttons(state, ui, global_syncing);
57+ ui.separator();
58+ draw_result_count(state, ui, colors, filtered_count, total_feeds);
59+ draw_search_controls(state, ui);
60+ });
61+ });
62+ }
63+}
64+
65+/// Action buttons, in a stable visual order (Ajouter, Synchroniser, Exporter, Importer)
66+fn draw_action_buttons(state: &mut AppState, ui: &mut Ui, global_syncing: bool) {
67+ // Sous-layout gauche-à-droite : l'ordre visuel est identique que la
68+ // ligne parente soit large ou étroite.
69+ ui.horizontal(|ui| {
70+ ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
71+
72+ if ui
73+ .button(RichText::new("➕ Ajouter un flux").size(14.0))
74+ .on_hover_text("Ajouter un flux RSS (Ctrl+N)")
75+ .clicked()
76+ {
77+ state.clear_new_feed_form();
78+ state.show_add_feed_dialog = true;
79+ }
80+
81+ // Synchronisation globale, désactivée pendant une synchro en cours
82+ let sync_label = if global_syncing {
83+ "⏳ Synchronisation…"
84+ } else {
85+ "🔄 Synchroniser tous"
86+ };
87+ if ui
88+ .add_enabled(
89+ !global_syncing,
90+ egui::Button::new(RichText::new(sync_label).size(14.0)),
91+ )
92+ .on_hover_text("Synchroniser tous les flux (F5)")
93+ .clicked()
94+ {
95+ state.start_sync_all_feeds();
96+ }
97+
98+ ui.separator();
99+
100+ if ui
101+ .button(RichText::new("📤").size(16.0))
102+ .on_hover_text("Exporter les flux au format JSON")
103+ .clicked()
104+ {
105+ state.clear_export_import_error();
106+ state.show_export_dialog = true;
107+ }
108+
109+ if ui
110+ .button(RichText::new("📥").size(16.0))
111+ .on_hover_text("Importer des flux au format JSON")
112+ .clicked()
113+ {
114+ state.clear_export_import_error();
115+ state.show_import_dialog = true;
116+ }
117+ });
118+}
119+
120+/// Result counter, visible only when a filter is active
121+fn draw_result_count(
122+ state: &mut AppState,
123+ ui: &mut Ui,
124+ colors: &ThemeColors,
125+ filtered_count: usize,
126+ total_feeds: usize,
127+) {
128+ if state.has_active_filters() {
129+ ui.label(
130+ RichText::new(format!("{} / {} flux", filtered_count, total_feeds))
131+ .size(12.0)
132+ .color(colors.text_secondary),
133+ )
134+ .on_hover_text("Flux visibles / total, après recherche et filtres");
135+ }
136+}
137+
138+/// Search controls: type selector, clear button, then the field filling the rest.
139+/// Must be called inside a right-to-left horizontal layout.
140+fn draw_search_controls(state: &mut AppState, ui: &mut Ui) {
141+ crate::ui::search_bar::draw_search_type_combo(state, ui);
142+ crate::ui::search_bar::draw_search_field(state, ui);
143+}
144+
145+/// Date filter line: preset selector + custom range bounds + reset
146+fn draw_date_filter(state: &mut AppState, ui: &mut Ui, colors: &ThemeColors) {
147+ ui.horizontal(|ui| {
148+ ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
149+ ui.label(RichText::new("📅").size(14.0));
150+
151+ let mut custom_clicked = false;
152+ let combo = egui::ComboBox::from_id_salt("date_preset")
153+ .width(170.0)
154+ .selected_text(date_preset_label(state))
155+ .show_ui(ui, |ui| {
156+ ui.selectable_value(&mut state.date_preset, DatePreset::All, "Toutes les dates");
157+ ui.selectable_value(
158+ &mut state.date_preset,
159+ DatePreset::Last7Days,
160+ "7 derniers jours",
161+ );
162+ ui.selectable_value(
163+ &mut state.date_preset,
164+ DatePreset::Last30Days,
165+ "30 derniers jours",
166+ );
167+ if ui
168+ .selectable_label(
169+ state.date_preset == DatePreset::Custom,
170+ "Plage personnalisée…",
171+ )
172+ .clicked()
173+ {
174+ state.date_preset = DatePreset::Custom;
175+ custom_clicked = true;
176+ }
177+ });
178+
179+ // Applique le preset choisi (met à jour les bornes, ouvre les
180+ // sélecteurs de dates pour une plage personnalisée)
181+ if combo.response.changed() || custom_clicked {
182+ state.apply_date_preset(state.date_preset);
183+ }
184+
185+ // Plage personnalisée : bornes affichées et modifiables
186+ if state.date_preset == DatePreset::Custom {
187+ let from_label = state
188+ .filter_date_from
189+ .map(|d| d.format("%d/%m/%Y").to_string())
190+ .unwrap_or_else(|| "…".to_string());
191+ if ui
192+ .button(format!("Début : {}", from_label))
193+ .on_hover_text("Modifier la date de début")
194+ .clicked()
195+ {
196+ state.show_date_picker_from = true;
197+ }
198+
199+ let to_label = state
200+ .filter_date_to
201+ .map(|d| d.format("%d/%m/%Y").to_string())
202+ .unwrap_or_else(|| "…".to_string());
203+ if ui
204+ .button(format!("Fin : {}", to_label))
205+ .on_hover_text("Modifier la date de fin")
206+ .clicked()
207+ {
208+ state.show_date_picker_to = true;
209+ }
210+ }
211+
212+ // Réinitialisation recherche + période, uniquement quand nécessaire
213+ if state.has_active_filters()
214+ && ui
215+ .button(
216+ RichText::new("✕ Effacer les filtres")
217+ .size(12.0)
218+ .color(colors.text_secondary),
219+ )
220+ .on_hover_text("Réinitialiser la recherche et la période")
221+ .clicked()
222+ {
223+ state.search_query.query.clear();
224+ state.clear_filters();
225+ }
226+ });
227+
228+ // Sélecteurs de dates (plage personnalisée)
229+ crate::ui::filter_panel::draw_date_pickers(state, ui);
230+}
231+
232+/// Libellé affiché dans le sélecteur de période
233+fn date_preset_label(state: &AppState) -> String {
234+ match state.date_preset {
235+ DatePreset::All => "Toutes les dates".to_string(),
236+ DatePreset::Last7Days => "7 derniers jours".to_string(),
237+ DatePreset::Last30Days => "30 derniers jours".to_string(),
238+ DatePreset::Custom => "Plage personnalisée".to_string(),
239+ }
240+}
241+
242+#[cfg(test)]
243+mod tests {
244+ use super::*;
245+
246+ /// Comme pour la liste des flux : la barre d'outils vit dans une
247+ /// `ScrollArea::vertical()` en `auto_shrink([false, false])`. Si une de
248+ /// ses lignes dépasse la largeur qui lui est donnée, la zone de défilement
249+ /// s'élargit sans limite d'une frame à l'autre.
250+ #[test]
251+ fn toolbar_never_exceeds_available_width() {
252+ let mut state = crate::config::AppState::with_default_config();
253+ state.feeds.push(crate::config::feed::Feed {
254+ url: "https://example.com/feeds/chemin/tres/long/pour/forcer/le/retour/a/la/ligne.xml"
255+ .to_string(),
256+ name: "Flux de test au nom volontairement long pour occuper l'espace".to_string(),
257+ download_path: std::path::PathBuf::from("C:/Users/rorop/Music/Podcasts"),
258+ tags: vec!["Tech".to_string()],
259+ last_episode_date: Some(chrono::Utc::now()),
260+ ..Default::default()
261+ });
262+
263+ for viewport in [400.0, 700.0, 1000.0] {
264+ let mut inner_width = viewport;
265+ let mut widths = Vec::new();
266+ for _ in 0..10 {
267+ let ctx = egui::Context::default();
268+ let mut row_width = 0.0;
269+ let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
270+ let builder = egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
271+ egui::Pos2::ZERO,
272+ egui::Vec2::new(inner_width, 2000.0),
273+ ));
274+ let mut child = ui.new_child(builder);
275+ draw_toolbar(&mut state, &mut child);
276+ row_width = child.min_rect().width();
277+ });
278+ widths.push(row_width);
279+ inner_width = inner_width.max(row_width);
280+ }
281+
282+ assert!(
283+ widths.iter().all(|w| *w <= viewport + 1.0),
284+ "la barre d'outils dépasse la largeur de la fenêtre ({viewport}) : {widths:?}"
285+ );
286+ assert!(
287+ widths.iter().skip(1).all(|w| (w - widths[0]).abs() < 1.0),
288+ "la largeur de la barre varie d'une frame à l'autre ({viewport}) : {widths:?}"
289+ );
290+ }
291+ }
292+}
new file mode 100644
@@ -0,0 +1,292 @@
1+//! Toolbar UI component
2+//!
3+//! Barre d'outils principale de l'écran d'accueil : recherche, filtre par
4+//! période et boutons d'action, regroupés dans un cadre compact à thème.
5+//! Sur une fenêtre étroite, les actions et la recherche s'empilent sur
6+//! deux lignes pour ne jamais déborder de la largeur disponible.
7+
8+use crate::config::app_state::DatePreset;
9+use crate::config::AppState;
10+use crate::ui::theme::ThemeColors;
11+use egui::{Align, Layout, RichText, Ui};
12+
13+/// Largeur sous laquelle la barre s'empile sur deux lignes
14+const NARROW_WIDTH_THRESHOLD: f32 = 600.0;
15+
16+/// Draws the main toolbar: search + actions, then the date filter line
17+pub fn draw_toolbar(state: &mut AppState, ui: &mut Ui) {
18+ let colors = ThemeColors::for_theme(state.get_theme());
19+
20+ egui::Frame {
21+ fill: colors.bg_primary,
22+ stroke: egui::Stroke::new(1.0, colors.border),
23+ corner_radius: 6.0.into(),
24+ inner_margin: egui::Margin::symmetric(12, 8),
25+ ..Default::default()
26+ }
27+ .show(ui, |ui| {
28+ ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 8.0);
29+ draw_search_and_actions(state, ui, &colors);
30+ draw_date_filter(state, ui, &colors);
31+ });
32+}
33+
34+/// Search + actions: single line on wide windows, stacked on narrow ones
35+fn draw_search_and_actions(state: &mut AppState, ui: &mut Ui, colors: &ThemeColors) {
36+ let total_feeds = state.feed_count();
37+ let filtered_count = crate::ui::filter_panel::filter_feeds(&state.feeds, state).len();
38+ let global_syncing = state.sync_task_manager.status.global_sync_in_progress;
39+
40+ if ui.available_width() < NARROW_WIDTH_THRESHOLD {
41+ // Fenêtre étroite : actions puis recherche, sur deux lignes
42+ ui.horizontal(|ui| {
43+ draw_action_buttons(state, ui, global_syncing);
44+ });
45+ ui.horizontal(|ui| {
46+ ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
47+ draw_result_count(state, ui, colors, filtered_count, total_feeds);
48+ draw_search_controls(state, ui);
49+ });
50+ });
51+ } else {
52+ // Layout droite-à-gauche : les boutons prennent leur largeur en
53+ // premier et le champ de recherche reçoit toute la largeur restante.
54+ ui.horizontal(|ui| {
55+ ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
56+ draw_action_buttons(state, ui, global_syncing);
57+ ui.separator();
58+ draw_result_count(state, ui, colors, filtered_count, total_feeds);
59+ draw_search_controls(state, ui);
60+ });
61+ });
62+ }
63+}
64+
65+/// Action buttons, in a stable visual order (Ajouter, Synchroniser, Exporter, Importer)
66+fn draw_action_buttons(state: &mut AppState, ui: &mut Ui, global_syncing: bool) {
67+ // Sous-layout gauche-à-droite : l'ordre visuel est identique que la
68+ // ligne parente soit large ou étroite.
69+ ui.horizontal(|ui| {
70+ ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
71+
72+ if ui
73+ .button(RichText::new("➕ Ajouter un flux").size(14.0))
74+ .on_hover_text("Ajouter un flux RSS (Ctrl+N)")
75+ .clicked()
76+ {
77+ state.clear_new_feed_form();
78+ state.show_add_feed_dialog = true;
79+ }
80+
81+ // Synchronisation globale, désactivée pendant une synchro en cours
82+ let sync_label = if global_syncing {
83+ "⏳ Synchronisation…"
84+ } else {
85+ "🔄 Synchroniser tous"
86+ };
87+ if ui
88+ .add_enabled(
89+ !global_syncing,
90+ egui::Button::new(RichText::new(sync_label).size(14.0)),
91+ )
92+ .on_hover_text("Synchroniser tous les flux (F5)")
93+ .clicked()
94+ {
95+ state.start_sync_all_feeds();
96+ }
97+
98+ ui.separator();
99+
100+ if ui
101+ .button(RichText::new("📤").size(16.0))
102+ .on_hover_text("Exporter les flux au format JSON")
103+ .clicked()
104+ {
105+ state.clear_export_import_error();
106+ state.show_export_dialog = true;
107+ }
108+
109+ if ui
110+ .button(RichText::new("📥").size(16.0))
111+ .on_hover_text("Importer des flux au format JSON")
112+ .clicked()
113+ {
114+ state.clear_export_import_error();
115+ state.show_import_dialog = true;
116+ }
117+ });
118+}
119+
120+/// Result counter, visible only when a filter is active
121+fn draw_result_count(
122+ state: &mut AppState,
123+ ui: &mut Ui,
124+ colors: &ThemeColors,
125+ filtered_count: usize,
126+ total_feeds: usize,
127+) {
128+ if state.has_active_filters() {
129+ ui.label(
130+ RichText::new(format!("{} / {} flux", filtered_count, total_feeds))
131+ .size(12.0)
132+ .color(colors.text_secondary),
133+ )
134+ .on_hover_text("Flux visibles / total, après recherche et filtres");
135+ }
136+}
137+
138+/// Search controls: type selector, clear button, then the field filling the rest.
139+/// Must be called inside a right-to-left horizontal layout.
140+fn draw_search_controls(state: &mut AppState, ui: &mut Ui) {
141+ crate::ui::search_bar::draw_search_type_combo(state, ui);
142+ crate::ui::search_bar::draw_search_field(state, ui);
143+}
144+
145+/// Date filter line: preset selector + custom range bounds + reset
146+fn draw_date_filter(state: &mut AppState, ui: &mut Ui, colors: &ThemeColors) {
147+ ui.horizontal(|ui| {
148+ ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
149+ ui.label(RichText::new("📅").size(14.0));
150+
151+ let mut custom_clicked = false;
152+ let combo = egui::ComboBox::from_id_salt("date_preset")
153+ .width(170.0)
154+ .selected_text(date_preset_label(state))
155+ .show_ui(ui, |ui| {
156+ ui.selectable_value(&mut state.date_preset, DatePreset::All, "Toutes les dates");
157+ ui.selectable_value(
158+ &mut state.date_preset,
159+ DatePreset::Last7Days,
160+ "7 derniers jours",
161+ );
162+ ui.selectable_value(
163+ &mut state.date_preset,
164+ DatePreset::Last30Days,
165+ "30 derniers jours",
166+ );
167+ if ui
168+ .selectable_label(
169+ state.date_preset == DatePreset::Custom,
170+ "Plage personnalisée…",
171+ )
172+ .clicked()
173+ {
174+ state.date_preset = DatePreset::Custom;
175+ custom_clicked = true;
176+ }
177+ });
178+
179+ // Applique le preset choisi (met à jour les bornes, ouvre les
180+ // sélecteurs de dates pour une plage personnalisée)
181+ if combo.response.changed() || custom_clicked {
182+ state.apply_date_preset(state.date_preset);
183+ }
184+
185+ // Plage personnalisée : bornes affichées et modifiables
186+ if state.date_preset == DatePreset::Custom {
187+ let from_label = state
188+ .filter_date_from
189+ .map(|d| d.format("%d/%m/%Y").to_string())
190+ .unwrap_or_else(|| "…".to_string());
191+ if ui
192+ .button(format!("Début : {}", from_label))
193+ .on_hover_text("Modifier la date de début")
194+ .clicked()
195+ {
196+ state.show_date_picker_from = true;
197+ }
198+
199+ let to_label = state
200+ .filter_date_to
201+ .map(|d| d.format("%d/%m/%Y").to_string())
202+ .unwrap_or_else(|| "…".to_string());
203+ if ui
204+ .button(format!("Fin : {}", to_label))
205+ .on_hover_text("Modifier la date de fin")
206+ .clicked()
207+ {
208+ state.show_date_picker_to = true;
209+ }
210+ }
211+
212+ // Réinitialisation recherche + période, uniquement quand nécessaire
213+ if state.has_active_filters()
214+ && ui
215+ .button(
216+ RichText::new("✕ Effacer les filtres")
217+ .size(12.0)
218+ .color(colors.text_secondary),
219+ )
220+ .on_hover_text("Réinitialiser la recherche et la période")
221+ .clicked()
222+ {
223+ state.search_query.query.clear();
224+ state.clear_filters();
225+ }
226+ });
227+
228+ // Sélecteurs de dates (plage personnalisée)
229+ crate::ui::filter_panel::draw_date_pickers(state, ui);
230+}
231+
232+/// Libellé affiché dans le sélecteur de période
233+fn date_preset_label(state: &AppState) -> String {
234+ match state.date_preset {
235+ DatePreset::All => "Toutes les dates".to_string(),
236+ DatePreset::Last7Days => "7 derniers jours".to_string(),
237+ DatePreset::Last30Days => "30 derniers jours".to_string(),
238+ DatePreset::Custom => "Plage personnalisée".to_string(),
239+ }
240+}
241+
242+#[cfg(test)]
243+mod tests {
244+ use super::*;
245+
246+ /// Comme pour la liste des flux : la barre d'outils vit dans une
247+ /// `ScrollArea::vertical()` en `auto_shrink([false, false])`. Si une de
248+ /// ses lignes dépasse la largeur qui lui est donnée, la zone de défilement
249+ /// s'élargit sans limite d'une frame à l'autre.
250+ #[test]
251+ fn toolbar_never_exceeds_available_width() {
252+ let mut state = crate::config::AppState::with_default_config();
253+ state.feeds.push(crate::config::feed::Feed {
254+ url: "https://example.com/feeds/chemin/tres/long/pour/forcer/le/retour/a/la/ligne.xml"
255+ .to_string(),
256+ name: "Flux de test au nom volontairement long pour occuper l'espace".to_string(),
257+ download_path: std::path::PathBuf::from("C:/Users/rorop/Music/Podcasts"),
258+ tags: vec!["Tech".to_string()],
259+ last_episode_date: Some(chrono::Utc::now()),
260+ ..Default::default()
261+ });
262+
263+ for viewport in [400.0, 700.0, 1000.0] {
264+ let mut inner_width = viewport;
265+ let mut widths = Vec::new();
266+ for _ in 0..10 {
267+ let ctx = egui::Context::default();
268+ let mut row_width = 0.0;
269+ let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
270+ let builder = egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
271+ egui::Pos2::ZERO,
272+ egui::Vec2::new(inner_width, 2000.0),
273+ ));
274+ let mut child = ui.new_child(builder);
275+ draw_toolbar(&mut state, &mut child);
276+ row_width = child.min_rect().width();
277+ });
278+ widths.push(row_width);
279+ inner_width = inner_width.max(row_width);
280+ }
281+
282+ assert!(
283+ widths.iter().all(|w| *w <= viewport + 1.0),
284+ "la barre d'outils dépasse la largeur de la fenêtre ({viewport}) : {widths:?}"
285+ );
286+ assert!(
287+ widths.iter().skip(1).all(|w| (w - widths[0]).abs() < 1.0),
288+ "la largeur de la barre varie d'une frame à l'autre ({viewport}) : {widths:?}"
289+ );
290+ }
291+ }
292+}