update UX and fixes issues
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é : | ||
| 57 | 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 | 59 | - **É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 | |
| 61 | 63 | - Boutons « Ajouter un flux », « Synchroniser tous », « Exporter », « Importer » |
| 62 | 64 | - 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 | 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 | 163 | │ ├── stats_panel.rs # Fenêtre Statistiques |
| 162 | 164 | │ ├── status_bar.rs # Barre de statut |
| 163 | 165 | │ ├── 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) | |
| 165 | 168 | ├── spec/ |
| 166 | 169 | │ ├── init-plan.md # Plan initial du projet |
| 167 | 170 | │ └── 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 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 | ||
| 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 cours | 64 | - 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 Statistiques | 163 | │ ├── stats_panel.rs # Fenêtre Statistiques |
| 162 | │ ├── status_bar.rs # Barre de statut | 164 | │ ├── status_bar.rs # Barre de statut |
| 163 | │ ├── sync_status.rs # Indicateur de synchronisation | 165 | │ ├── 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) | ||
| 165 | ├── spec/ | 168 | ├── spec/ |
| 166 | │ ├── init-plan.md # Plan initial du projet | 169 | │ ├── init-plan.md # Plan initial du projet |
| 167 | │ └── init-tasks.md # Tâches détaillées | 170 | │ └── init-tasks.md # Tâches détaillées |
modified
src/config/app_state.rs +80 -0 | @@ -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 | 52 | /// Main application state |
| 39 | 53 | /// |
| 40 | 54 | /// This struct holds all the state needed for the RSS Podcast Downloader application. |
| @@ -91,6 +105,8 @@ pub struct AppState { | ||
| 91 | 105 | pub filter_date_from: Option<DateTime<Utc>>, |
| 92 | 106 | /// Filter: date to (inclusive) |
| 93 | 107 | pub filter_date_to: Option<DateTime<Utc>>, |
| 108 | + /// Active date filter preset shown in the toolbar | |
| 109 | + pub date_preset: DatePreset, | |
| 94 | 110 | /// Whether to show the date picker for 'from' date |
| 95 | 111 | pub show_date_picker_from: bool, |
| 96 | 112 | /// Whether to show the date picker for 'to' date |
| @@ -165,6 +181,7 @@ impl AppState { | ||
| 165 | 181 | playlists: Vec::new(), |
| 166 | 182 | filter_date_from: None, |
| 167 | 183 | filter_date_to: None, |
| 184 | + date_preset: DatePreset::default(), | |
| 168 | 185 | show_date_picker_from: false, |
| 169 | 186 | show_date_picker_to: false, |
| 170 | 187 | show_export_dialog: false, |
| @@ -357,9 +374,42 @@ impl AppState { | ||
| 357 | 374 | pub fn clear_filters(&mut self) { |
| 358 | 375 | self.filter_date_from = None; |
| 359 | 376 | self.filter_date_to = None; |
| 377 | + self.date_preset = DatePreset::All; | |
| 360 | 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 | 413 | /// Sets an export/import error message |
| 364 | 414 | pub fn set_export_import_error(&mut self, message: impl Into<String>) { |
| 365 | 415 | let msg: String = message.into(); |
| @@ -475,4 +525,34 @@ mod tests { | ||
| 475 | 525 | |
| 476 | 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 | } |
| @@ -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 state | 52 | /// 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' date | 110 | /// 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' date | 112 | /// 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 message | 413 | /// 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) { | ||
| 11 | 11 | // Apply filters |
| 12 | 12 | let filtered_indices = crate::ui::filter_panel::filter_feeds(&state.feeds, state); |
| 13 | 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 | - | |
| 14 | + // Le compteur de résultats est affiché dans la barre d'outils | |
| 26 | 15 | if filtered_indices.is_empty() { |
| 27 | - if searching { | |
| 16 | + if !state.search_query.is_empty() { | |
| 28 | 17 | ui.label("Aucun flux correspondant à la recherche."); |
| 29 | 18 | } else { |
| 30 | 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 | 56 | ..Default::default() |
| 68 | 57 | } |
| 69 | 58 | .show(ui, |ui| { |
| 59 | + // Carte sur toute la largeur disponible | |
| 60 | + ui.set_min_width(ui.available_width()); | |
| 70 | 61 | ui.spacing_mut().item_spacing = egui::Vec2::new(16.0, 8.0); |
| 71 | 62 | |
| 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) => { | |
| 102 | 127 | ui.label( |
| 103 | - RichText::new(format!("#{} ", tag)) | |
| 128 | + RichText::new("⏳ Synchronisation…") | |
| 104 | 129 | .size(11.0) |
| 105 | 130 | .color(colors.accent), |
| 106 | 131 | ); |
| 107 | 132 | } |
| 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 | + } | |
| 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 | 179 | ui.label( |
| 131 | - RichText::new("⚠️ Échec de synchro") | |
| 180 | + RichText::new(format!("📥 {} épisodes", feed_downloaded_count)) | |
| 132 | 181 | .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 | + ); | |
| 136 | 190 | } |
| 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 { | |
| 146 | 201 | 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) | |
| 149 | 204 | .color(colors.text_dim), |
| 150 | 205 | ); |
| 151 | 206 | } |
| 152 | 207 | } |
| 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) | |
| 171 | 228 | 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), | |
| 175 | 233 | ); |
| 176 | - } else { | |
| 234 | + | |
| 235 | + // URL (plus petite) | |
| 177 | 236 | 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), | |
| 181 | 240 | ); |
| 182 | - } | |
| 183 | 241 | |
| 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 | |
| 187 | 243 | 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), | |
| 191 | 247 | ); |
| 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 | + }); | |
| 198 | 261 | } |
| 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 | + ); | |
| 244 | 264 | }); |
| 245 | 265 | }); |
| 246 | 266 | |
| @@ -271,3 +291,59 @@ fn sync_feed_async(state: &mut AppState, index: usize) { | ||
| 271 | 291 | .notification_manager |
| 272 | 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 | +} | |
| @@ -11,20 +11,9 @@ pub fn draw_feed_list(state: &mut AppState, ui: &mut Ui) { | |||
| 11 | // Apply filters | 11 | // 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 principale | 64 | + // 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 | - // Dossier | 81 | + .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 | - // Tags | 88 | + } |
| 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 statistiques | 137 | + .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 flux | 149 | + .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érification | 192 | + // 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 visuel | 210 | + 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échargements | 224 | + |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_manager | 291 | .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 @@ | ||
| 1 | 1 | //! 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 | 7 | use crate::config::AppState; |
| 6 | 8 | use chrono::{DateTime, Utc}; |
| 7 | 9 | use egui::Ui; |
| 8 | 10 | |
| 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) { | |
| 54 | 13 | draw_date_picker_from(state, ui); |
| 55 | 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 | 40 | date.and_hms_opt(0, 0, 0).unwrap(), |
| 82 | 41 | Utc, |
| 83 | 42 | )); |
| 43 | + state.date_preset = DatePreset::Custom; | |
| 84 | 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 | 56 | // Quick date buttons |
| 97 | 57 | if ui.button("Aujourd'hui").clicked() { |
| 98 | 58 | state.filter_date_from = Some(Utc::now()); |
| 59 | + state.date_preset = DatePreset::Custom; | |
| 99 | 60 | state.show_date_picker_from = false; |
| 100 | 61 | } |
| 101 | 62 | |
| 102 | 63 | if ui.button("7 derniers jours").clicked() { |
| 103 | 64 | state.filter_date_from = Some(Utc::now() - chrono::Duration::days(7)); |
| 65 | + state.date_preset = DatePreset::Custom; | |
| 104 | 66 | state.show_date_picker_from = false; |
| 105 | 67 | } |
| 106 | 68 | |
| 107 | 69 | if ui.button("30 derniers jours").clicked() { |
| 108 | 70 | state.filter_date_from = Some(Utc::now() - chrono::Duration::days(30)); |
| 71 | + state.date_preset = DatePreset::Custom; | |
| 109 | 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 | 106 | date.and_hms_opt(23, 59, 59).unwrap(), |
| 144 | 107 | Utc, |
| 145 | 108 | )); |
| 109 | + state.date_preset = DatePreset::Custom; | |
| 146 | 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 | 122 | // Quick date buttons |
| 159 | 123 | if ui.button("Aujourd'hui").clicked() { |
| 160 | 124 | state.filter_date_to = Some(Utc::now()); |
| 125 | + state.date_preset = DatePreset::Custom; | |
| 161 | 126 | state.show_date_picker_to = false; |
| 162 | 127 | } |
| 163 | 128 | |
| 164 | 129 | if ui.button("7 derniers jours").clicked() { |
| 165 | 130 | state.filter_date_to = Some(Utc::now()); |
| 166 | 131 | state.filter_date_from = Some(Utc::now() - chrono::Duration::days(7)); |
| 132 | + state.date_preset = DatePreset::Custom; | |
| 167 | 133 | state.show_date_picker_to = false; |
| 168 | 134 | } |
| 169 | 135 | |
| @@ -1,56 +1,15 @@ | |||
| 1 | //! Filter Panel UI component | 1 | //! 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 feeds | 11 | +/// 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 buttons | 56 | // 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 buttons | 122 | // 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) { | ||
| 28 | 28 | |
| 29 | 29 | ui.separator(); |
| 30 | 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 | - }); | |
| 31 | + // Barre d'outils : recherche, filtre par période et actions | |
| 32 | + crate::ui::toolbar::draw_toolbar(state, ui); | |
| 48 | 33 | |
| 49 | - ui.add_space(16.0); | |
| 50 | - ui.separator(); | |
| 34 | + ui.add_space(12.0); | |
| 51 | 35 | |
| 52 | 36 | if state.detail_feed_id.is_some() { |
| 53 | 37 | // 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 | 243 | /// Draws the empty state message |
| 300 | 244 | fn draw_empty_state(ui: &mut Ui, colors: &ThemeColors) { |
| 301 | 245 | 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 + filtres | 31 | + // 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 playlists | 37 | // 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 message | 243 | /// 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; | ||
| 11 | 11 | pub mod notifications; |
| 12 | 12 | pub mod playlist_list; |
| 13 | 13 | pub mod filter_panel; |
| 14 | +pub mod toolbar; | |
| 14 | 15 | pub mod export_import; |
| 15 | 16 | pub mod history_panel; |
| 16 | 17 | 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; | ||
| 7 | 7 | use crate::config::AppState; |
| 8 | 8 | use egui::Ui; |
| 9 | 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) { | |
| 10 | +/// Affiche le champ de recherche ; occupe toute la largeur restante | |
| 11 | +pub fn draw_search_field(state: &mut AppState, ui: &mut Ui) { | |
| 12 | 12 | ui.label("🔍"); |
| 13 | 13 | |
| 14 | 14 | let response = ui.add( |
| 15 | 15 | egui::TextEdit::singleline(&mut state.search_query.query) |
| 16 | 16 | .hint_text("Rechercher un flux (Ctrl+F)…") |
| 17 | - .desired_width(200.0), | |
| 17 | + .desired_width(f32::INFINITY), | |
| 18 | 18 | ); |
| 19 | 19 | |
| 20 | 20 | // Focus demandé via le raccourci Ctrl+F |
| @@ -22,7 +22,10 @@ pub fn draw_search_bar(state: &mut AppState, ui: &mut Ui) { | ||
| 22 | 22 | response.request_focus(); |
| 23 | 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 | 29 | if !state.search_query.is_empty() |
| 27 | 30 | && ui |
| 28 | 31 | .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 recherche | 10 | +/// 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+F | 20 | // 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 | && ui | 30 | && 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 | +} | ||