From 08a3541749198fef7f7a43e97bc9b406146755bf Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 18 Oct 2026 13:47:33 +0100 Subject: [PATCH] feat(PostIt): responsive two-column blogs layout, camera streamer (1.0.8-rc27) - BlogsPage switches to a two-column layout when width >= height (post on the left, search/commands/list on the right), with a compact navigation bar; layout update deferred on the UI thread to avoid "Visual was invalidated during the render pass". - Explicit DataContext on CommandBar items so overflowed buttons keep their bindings. - Preliminary CameraStreamer service (DirectShowLib / V4L2 via LibVLC). - Tests and architecture doc updated; CHANGELOG entry 1.0.8-rc27. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 23 +++ doc/architecture/postit.md | 14 ++ src/PostIt/Directory.Packages.props | 3 +- src/PostIt/PostIt.Tests/BlogReaderTests.cs | 138 ++++++++++++++++++ src/PostIt/PostIt/PostIt.csproj | 1 + src/PostIt/PostIt/Services/CameraStreamer.cs | 93 ++++++++++++ src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml | 47 +++--- .../PostIt/Views/Blogs/BlogsPage.axaml.cs | 45 ++++++ 8 files changed, 341 insertions(+), 23 deletions(-) create mode 100644 src/PostIt/PostIt/Services/CameraStreamer.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1019d3e13..951ce2d05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## [1.0.8-rc27] - unstable + +### Added + +* [PostIt] Service `CameraStreamer` (préliminaire) : détection des + périphériques de capture vidéo (DirectShow sous Windows via le paquet + `DirectShowLib`, `/dev/video*` sous Linux) et création d'un média + LibVLC de capture pour le périphérique choisi. + +### Changed + +* [PostIt] La page des blogs passe en deux colonnes lorsque la largeur de + la fenêtre atteint sa hauteur : billet à gauche, recherche, commandes et + liste à droite, avec séparateur vertical redimensionnable. La disposition + verticale revient en portrait sans perdre le brouillon ; la barre de + navigation est plus compacte en mode large et conserve le bouton Retour. + +### Fixed + +* [PostIt] Le changement de disposition de la page des blogs est différé + sur le thread UI, évitant l'exception « Visual was invalidated during + the render pass » au redimensionnement. + ## [1.0.8-rc26] - unstable ### Added diff --git a/doc/architecture/postit.md b/doc/architecture/postit.md index 439bb8f70..476137a8b 100644 --- a/doc/architecture/postit.md +++ b/doc/architecture/postit.md @@ -178,6 +178,20 @@ et les commandes restent visibles, tandis que la liste des billets et l'éditeur se partagent l'espace disponible. Un `GridSplitter` permet de modifier cette répartition. La liste et l'article défilent séparément. +Lorsque la largeur de la fenêtre atteint ou dépasse sa hauteur, le lecteur +ou l'éditeur passe à gauche (deux tiers de la largeur par défaut), et +l'en-tête, la recherche, les commandes et la liste passent à droite. +Le séparateur devient vertical. En portrait, la disposition verticale +revient sans recréer les contrôles ni perdre le brouillon. +Le seuil utilise les dimensions clientes de la fenêtre, indépendamment +de la hauteur occupée par la navigation et la bannière de session. + +La page applique `NavigationPage.BarHeightOverride` à 36 en mode large ; +le `NavRoot` de `MainView` conserve ainsi le bouton Retour, sans changer +les autres pages. En portrait, la hauteur de navigation par défaut revient. +Les commandes ont un contexte de données explicite pour conserver leurs +liaisons, y compris lorsqu'elles passent dans le menu de débordement. + Dans `MarkdownEditorControl`, AvaloniaEdit gère son propre défilement ; l'aperçu Markdown est placé dans un `ScrollViewer`. Ne pas limiter la hauteur du `MarkdownViewer` lui-même : son contenu doit pouvoir diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props index f0b523488..fbfaa1f38 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -16,6 +16,7 @@ + @@ -37,4 +38,4 @@ - + \ No newline at end of file diff --git a/src/PostIt/PostIt.Tests/BlogReaderTests.cs b/src/PostIt/PostIt.Tests/BlogReaderTests.cs index b56e88ccf..9bf6d800e 100644 --- a/src/PostIt/PostIt.Tests/BlogReaderTests.cs +++ b/src/PostIt/PostIt.Tests/BlogReaderTests.cs @@ -2,7 +2,9 @@ using System.Net; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Templates; +using Avalonia.Headless; using Avalonia.Headless.XUnit; +using Avalonia.Input; using Avalonia.Logging; using Avalonia.Threading; using Avalonia.VisualTree; @@ -95,6 +97,7 @@ public class BlogReaderTests window.Show(); Dispatcher.UIThread.RunJobs(); window.UpdateLayout(); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); Assert.True(page.FindControl("PostReader")!.IsVisible); Assert.False(page.FindControl("PostEditor")!.IsVisible); Assert.False(page.SaveButton.IsVisible); @@ -164,6 +167,141 @@ public class BlogReaderTests } } + [AvaloniaTheory] + [InlineData(360, 800, false)] + [InlineData(1000, 420, true)] + [InlineData(1000, 280, true)] + [InlineData(600, 600, true)] + [InlineData(599, 600, false)] + [InlineData(601, 600, true)] + public async Task Blog_layout_uses_columns_from_the_square_window_threshold(int width, int height, bool wide) + { + var vm = CreateViewModel(); + await vm.InitializeAsync(); + for (var i = 0; i < 40; i++) + vm.FilteredPosts.Add(Post($"Billet {i}")); + vm.SelectedPost = Post(string.Join("\n\n", Enumerable.Range(0, 100).Select(i => $"Paragraphe {i}."))); + var page = new BlogsPage { DataContext = vm }; + var main = new MainView(); + var nav = main.FindControl("NavRoot")!; + await nav.PushAsync(new ContentPage()); + await nav.PushAsync(page); + var window = new Window { Width = width, Height = height, Content = main }; + try + { + window.Show(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + var reader = page.FindControl("PostReader")!; + var list = page.FindControl("PostsListBorder")!; + var splitter = page.FindControl("PostsSplitter")!; + Assert.Equal(wide ? GridResizeDirection.Columns : GridResizeDirection.Rows, splitter.ResizeDirection); + Assert.Equal(wide ? 36d : (double?)null, NavigationPage.GetBarHeightOverride(page)); + Assert.True(nav.IsBackButtonEffectivelyVisible); + Assert.True(reader.Bounds.Width > 0 && reader.Bounds.Height > 0); + Assert.True(list.Bounds.Width > 0 && list.Bounds.Height > 0); + if (wide) + { + Assert.True(reader.Bounds.Right <= list.Bounds.Left); + Assert.Equal(2, Grid.GetColumn(page.BlogsHeaderPanel)); + Assert.Equal(2, Grid.GetColumn(page.BlogsCommandBar)); + Assert.Equal(reader.Margin.Top, reader.Bounds.Top); + Assert.Equal(page.StatusBar.Bounds.Top - reader.Margin.Bottom, reader.Bounds.Bottom); + Assert.Equal(36, page.TranslatePoint(new Point(), nav)!.Value.Y); + } + else + { + Assert.True(list.Bounds.Bottom <= reader.Bounds.Top); + Assert.Equal(0, Grid.GetColumn(page.BlogsHeaderPanel)); + Assert.Equal(0, Grid.GetColumn(page.BlogsCommandBar)); + } + + var articleScroll = page.FindControl("ArticleReader")! + .GetVisualDescendants().OfType().Single(); + Assert.InRange(articleScroll.Viewport.Height, 1, window.ClientSize.Height); + Assert.True(articleScroll.Extent.Height > articleScroll.Viewport.Height); + var listScroll = page.PostsListBox.GetVisualDescendants().OfType().Single(); + Assert.InRange(listScroll.Viewport.Height, 1, window.ClientSize.Height); + Assert.True(listScroll.Extent.Height > listScroll.Viewport.Height); + + var originalReaderSize = reader.Bounds.Size; + splitter.Focus(); + splitter.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Key = wide ? Key.Right : Key.Down + }); + window.UpdateLayout(); + if (wide) + Assert.True(reader.Bounds.Width > originalReaderSize.Width); + else + Assert.True(reader.Bounds.Height < originalReaderSize.Height); + + page.BlogsCommandBar.IsOpen = true; + Dispatcher.UIThread.RunJobs(); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + Assert.Same(vm, page.SaveButton.DataContext); + Assert.False(page.SaveButton.IsVisible); + Assert.True(page.EditPostButton.IsVisible); + vm.EditPostCommand.Execute(null); + Dispatcher.UIThread.RunJobs(); + Assert.True(page.SaveButton.IsVisible); + Assert.Same(vm.SaveCommand, page.SaveButton.Command); + Assert.False(page.EditPostButton.IsVisible); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public async Task Resizing_between_portrait_and_landscape_preserves_the_editor_and_draft() + { + var vm = CreateViewModel(); + await vm.InitializeAsync(); + vm.SelectedPost = Post(); + vm.EditPostCommand.Execute(null); + vm.DraftArticle = "Brouillon conservé pendant le redimensionnement."; + var document = vm.DraftArticleDocument; + var page = new BlogsPage { DataContext = vm }; + var editor = page.FindControl("PostEditor")!; + var nav = new NavigationPage(); + await nav.PushAsync(page); + var window = new Window { Width = 360, Height = 800, Content = nav }; + try + { + window.Show(); + foreach (var size in new[] { new Size(360, 800), new Size(1000, 420), new Size(600, 600), new Size(599, 600), new Size(360, 800) }) + { + window.Width = size.Width; + window.Height = size.Height; + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + + var wide = size.Width >= size.Height; + Assert.Equal(wide ? 0 : 4, Grid.GetRow(editor)); + Assert.Equal(wide ? 3 : 1, Grid.GetRowSpan(editor)); + Assert.Equal(wide ? 36d : (double?)null, NavigationPage.GetBarHeightOverride(page)); + Assert.Same(editor, page.FindControl("PostEditor")); + Assert.True(editor.IsVisible); + Assert.False(page.FindControl("PostReader")!.IsVisible); + Assert.True(vm.IsEditing); + Assert.True(vm.IsDraftModified); + Assert.Equal("Brouillon conservé pendant le redimensionnement.", vm.DraftArticle); + Assert.Same(document, vm.DraftArticleDocument); + Assert.Equal(42, vm.SelectedPost!.Id); + Assert.True(editor.Bounds.Width > 0 && editor.Bounds.Height > 0); + } + } + finally + { + window.Close(); + } + } + [AvaloniaFact] public async Task Image_http_failure_is_logged_with_its_url_and_is_not_swallowed() { diff --git a/src/PostIt/PostIt/PostIt.csproj b/src/PostIt/PostIt/PostIt.csproj index 31c5afbda..27bdf298c 100644 --- a/src/PostIt/PostIt/PostIt.csproj +++ b/src/PostIt/PostIt/PostIt.csproj @@ -32,6 +32,7 @@ None All + diff --git a/src/PostIt/PostIt/Services/CameraStreamer.cs b/src/PostIt/PostIt/Services/CameraStreamer.cs new file mode 100644 index 000000000..10607f204 --- /dev/null +++ b/src/PostIt/PostIt/Services/CameraStreamer.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using LibVLCSharp.Shared; + +// Note : Si vous compilez sous Linux, DirectShowLib n'est pas disponible. +// Pour un projet multiplateforme propre, vous pouvez utiliser des directives de précompilation (#if WINDOWS) +// ou charger la détection DirectShow dans une assembly séparée. +using DirectShowLib; + +public class CameraStreamer +{ + private readonly LibVLC _libVLC; + + public CameraStreamer(LibVLC libVLC) + { + _libVLC = libVLC ?? throw new ArgumentNullException(nameof(libVLC)); + } + + /// + /// Récupère la liste des périphériques de capture disponibles sur la machine. + /// Retourne un dictionnaire : Clé = Nom lisible, Valeur = Identifiant/Chemin système + /// + public Dictionary GetCaptureDevices() + { + var devices = new Dictionary(); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // Détection Windows via DirectShow + DsDevice[] capDevices = DsDevice.GetDevicesOfCat(FilterCategory.VideoInputDevice); + foreach (var device in capDevices) + { + // Pour Windows, VLC utilise le nom de l'appareil + devices.Add(device.Name, device.Name); + } + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + // Détection Linux via V4L2 (Video4Linux2) + if (Directory.Exists("/dev")) + { + var videoFiles = Directory.GetFiles("/dev", "video*"); + foreach (var path in videoFiles) + { + // Exemple : Clé = "Caméra (/dev/video0)", Valeur = "/dev/video0" + devices.Add($"Caméra ({path})", path); + } + } + } + else + { + throw new PlatformNotSupportedException("OS non supporté pour la capture automatique dans cet exemple."); + } + + return devices; + } + + /// + /// Crée un objet Media configuré pour le périphérique sélectionné. + /// + /// Le nom de la caméra (Windows) ou le chemin /dev/videoX (Linux) + public Media CreateCaptureMedia(string deviceValue) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // Configuration pour DirectShow (Windows) + var media = new Media(_libVLC, "dshow://", FromType.FromLocation); + media.AddOption($":dshow-vdev={deviceValue}"); + + // Options optionnelles (Exemple : Forcer la résolution ou le framerate) + // media.AddOption(":dshow-size=1280x720"); + // media.AddOption(":dshow-fps=30"); + + return media; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + // Configuration pour V4L2 (Linux) + var media = new Media(_libVLC, "v4l2://", FromType.FromLocation); + media.AddOption($":v4l2-vdev={deviceValue}"); + + // Options optionnelles pour Linux + // media.AddOption(":v4l2-width=1280"); + // media.AddOption(":v4l2-height=720"); + + return media; + } + + throw new PlatformNotSupportedException(); + } +} diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml index d29d1f020..1d387ad3a 100644 --- a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml +++ b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml @@ -1,5 +1,6 @@ - @@ -45,18 +46,19 @@ Grid.Row="1" HorizontalAlignment="Right" IsDynamicOverflowEnabled="True"> - + - - + - - + @@ -100,6 +105,7 @@ - + - - - - - - - @@ -178,14 +180,16 @@ - @@ -237,7 +241,7 @@ - @@ -263,7 +267,6 @@ Text="Choisissez un billet dans la liste pour le lire." HorizontalAlignment="Center" VerticalAlignment="Center" TextWrapping="Wrap" /> - - + diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs index fec5da5bf..0583ad563 100644 --- a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs +++ b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs @@ -1,6 +1,8 @@ using System.IO; +using Avalonia; using Avalonia.Controls; +using Avalonia.Layout; using Avalonia.Platform.Storage; using Avalonia.Controls.Primitives; using Avalonia.Interactivity; @@ -13,14 +15,57 @@ public partial class BlogsPage : ContentPage { private static readonly RelayCommand DisabledLinkCommand = new(static _ => { }, static _ => false); + private bool? _isWideLayout; public BlogsPage() { InitializeComponent(); + SizeChanged += (_, _) => + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + UpdateResponsiveLayout(); + }); DataContextChanged += (_, _) => UpdateHyperlinkCommand(); UpdateHyperlinkCommand(); } + private void UpdateResponsiveLayout() + { + var size = TopLevel.GetTopLevel(this)?.ClientSize ?? Bounds.Size; + if (size.Width <= 0 || size.Height <= 0) + return; + + // Use the window size so compacting the navigation bar cannot change the orientation. + var isWide = size.Width >= size.Height; + if (_isWideLayout == isWide) + return; + _isWideLayout = isWide; + + MainGrid.RowDefinitions = new RowDefinitions(isWide ? "Auto,Auto,*,Auto" : "Auto,Auto,*,4,2*,Auto"); + MainGrid.ColumnDefinitions = new ColumnDefinitions(isWide ? "2*,4,*" : "*"); + var sidebarColumn = isWide ? 2 : 0; + SetArea(BlogsHeaderPanel, 0, sidebarColumn); + SetArea(BlogsCommandBar, 1, sidebarColumn); + SetArea(PostsListBorder, 2, sidebarColumn); + SetArea(PostsSplitter, isWide ? 0 : 3, isWide ? 1 : 0, isWide ? 3 : 1); + SetArea(PostReader, isWide ? 0 : 4, 0, isWide ? 3 : 1); + SetArea(PostEditor, isWide ? 0 : 4, 0, isWide ? 3 : 1); + SetArea(StatusBar, isWide ? 3 : 5, 0, columnSpan: isWide ? 3 : 1); + PostsSplitter.ResizeDirection = isWide ? GridResizeDirection.Columns : GridResizeDirection.Rows; + PostsSplitter.HorizontalAlignment = isWide ? HorizontalAlignment.Center : HorizontalAlignment.Stretch; + PostsSplitter.VerticalAlignment = isWide ? VerticalAlignment.Stretch : VerticalAlignment.Center; + PostsSplitter.Margin = isWide ? new Thickness(0, 12) : new Thickness(12, 0); + NavigationPage.SetBarHeightOverride(this, isWide ? 36 : null); + } + + private static void SetArea(Control control, int row, int column, int rowSpan = 1, int columnSpan = 1) + { + Grid.SetRow(control, row); + Grid.SetColumn(control, column); + Grid.SetRowSpan(control, rowSpan); + Grid.SetColumnSpan(control, columnSpan); + } + private void UpdateHyperlinkCommand() { if (ArticleReader.Engine is IMarkdownEngine2 engine)