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>
This commit is contained in:
Paul Schneider 2026-10-18 13:47:33 +01:00
commit 08a3541749
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
8 changed files with 341 additions and 23 deletions

View file

@ -16,6 +16,7 @@
<PackageVersion Include="Avalonia.Headless" Version="12.1.3" />
<PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="DirectShowLib" Version="1.0.0" />
<PackageVersion Include="LibVLCSharp.Avalonia" Version="3.10.1" />
<PackageVersion Include="Live.Avalonia" Version="1.4.2" />
<PackageVersion Include="Mapsui" Version="5.1.0" />
@ -37,4 +38,4 @@
<PackageVersion Include="Xamarin.UITest" Version="4.4.2" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
</ItemGroup>
</Project>
</Project>

View file

@ -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<Grid>("PostReader")!.IsVisible);
Assert.False(page.FindControl<Grid>("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<NavigationPage>("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<Grid>("PostReader")!;
var list = page.FindControl<Border>("PostsListBorder")!;
var splitter = page.FindControl<GridSplitter>("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<MarkdownScrollViewer>("ArticleReader")!
.GetVisualDescendants().OfType<ScrollViewer>().Single();
Assert.InRange(articleScroll.Viewport.Height, 1, window.ClientSize.Height);
Assert.True(articleScroll.Extent.Height > articleScroll.Viewport.Height);
var listScroll = page.PostsListBox.GetVisualDescendants().OfType<ScrollViewer>().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<Grid>("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<Grid>("PostEditor"));
Assert.True(editor.IsVisible);
Assert.False(page.FindControl<Grid>("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()
{

View file

@ -32,6 +32,7 @@
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="DirectShowLib" />
<PackageReference Include="LibVLCSharp.Avalonia" />
<PackageReference Include="Live.Avalonia" />
<PackageReference Include="Mapsui" />

View file

@ -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));
}
/// <summary>
/// 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
/// </summary>
public Dictionary<string, string> GetCaptureDevices()
{
var devices = new Dictionary<string, string>();
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;
}
/// <summary>
/// Crée un objet Media configuré pour le périphérique sélectionné.
/// </summary>
/// <param name="deviceValue">Le nom de la caméra (Windows) ou le chemin /dev/videoX (Linux)</param>
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();
}
}

View file

@ -1,5 +1,6 @@
<ContentPage
x:Class="PostIt.Views.Blogs.BlogsPage"
x:Name="BlogsPageRoot"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
@ -23,8 +24,8 @@
<Setter Property="FontWeight" Value="Bold" />
</Style>
</ContentPage.Styles>
<Grid
RowDefinitions="Auto,Auto,*,Auto"
<Grid x:Name="MainGrid"
RowDefinitions="Auto,Auto,*,4,2*,Auto"
Margin="5,5,10,5"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
@ -45,18 +46,19 @@
Grid.Row="1"
HorizontalAlignment="Right" IsDynamicOverflowEnabled="True">
<CommandBar.PrimaryCommands>
<CommandBarButton Command="{Binding RefreshCommand}" Label="Actualiser">
<CommandBarButton DataContext="{Binding #BlogsPageRoot.DataContext}" Command="{Binding RefreshCommand}" Label="Actualiser">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource RefreshIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton x:Name="NewPostButton" Command="{Binding NewPostCommand}" Label="Nouveau billet" />
<CommandBarButton x:Name="ReadPostButton" Command="{Binding ReadPostCommand}"
<CommandBarButton DataContext="{Binding #BlogsPageRoot.DataContext}" x:Name="NewPostButton" Command="{Binding NewPostCommand}" Label="Nouveau billet" />
<CommandBarButton DataContext="{Binding #BlogsPageRoot.DataContext}" x:Name="ReadPostButton" Command="{Binding ReadPostCommand}"
IsVisible="{Binding IsEditing}" Label="Lire" />
<CommandBarButton x:Name="EditPostButton" Command="{Binding EditPostCommand}"
<CommandBarButton DataContext="{Binding #BlogsPageRoot.DataContext}" x:Name="EditPostButton" Command="{Binding EditPostCommand}"
IsEnabled="{Binding SelectedPost, Converter={x:Static ObjectConverters.IsNotNull}}"
IsVisible="{Binding !IsEditing}" Label="Modifier" />
<CommandBarButton
DataContext="{Binding #BlogsPageRoot.DataContext}"
x:Name="SaveButton"
IsVisible="{Binding IsEditing}"
Command="{Binding SaveCommand}"
@ -67,12 +69,13 @@
<PathIcon Data="{StaticResource SaveIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton Command="{Binding DeleteCommand}" IsVisible="{Binding IsEditing}" Label="Supprimer">
<CommandBarButton DataContext="{Binding #BlogsPageRoot.DataContext}" Command="{Binding DeleteCommand}" IsVisible="{Binding IsEditing}" Label="Supprimer">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource DeleteIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton
DataContext="{Binding #BlogsPageRoot.DataContext}"
x:Name="AddAttachmentButton"
IsVisible="{Binding IsEditing}"
Click="AddAttachment_Click"
@ -83,6 +86,7 @@
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton
DataContext="{Binding #BlogsPageRoot.DataContext}"
x:Name="ManageAclButton"
IsVisible="{Binding IsEditing}"
Command="{Binding ManageAclAsync}"
@ -92,6 +96,7 @@
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton
DataContext="{Binding #BlogsPageRoot.DataContext}"
x:Name="OpenCirclesButton"
Command="{Binding OpenCirclesAsync}"
Label="Mes cercles">
@ -100,6 +105,7 @@
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarToggleButton
DataContext="{Binding #BlogsPageRoot.DataContext}"
IsVisible="{Binding IsEditing}"
Command="{Binding TogglePublishCommand}"
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}"
@ -108,12 +114,13 @@
<PathIcon Data="{StaticResource PublishedIcon}" />
</CommandBarToggleButton.Icon>
</CommandBarToggleButton>
<CommandBarToggleButton IsVisible="{Binding IsEditing}" IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}" Label="Aperçu">
<CommandBarToggleButton DataContext="{Binding #BlogsPageRoot.DataContext}" IsVisible="{Binding IsEditing}" IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}" Label="Aperçu">
<CommandBarToggleButton.Icon>
<PathIcon Data="{StaticResource MarkdownIcon}" />
</CommandBarToggleButton.Icon>
</CommandBarToggleButton>
<CommandBarButton
DataContext="{Binding #BlogsPageRoot.DataContext}"
x:Name="OpenSignatureDevButton"
IsVisible="{Binding IsEditing}"
Command="{Binding OpenSignatureDevAsync}"
@ -125,14 +132,9 @@
</CommandBar.PrimaryCommands>
</CommandBar>
<Grid Grid.Row="2" Margin="12" RowSpacing="12" x:Name="MainGrid">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="4" />
<RowDefinition Height="2*" />
</Grid.RowDefinitions>
<Border
Grid.Row="0"
<Border x:Name="PostsListBorder"
Grid.Row="2"
Margin="12"
Padding="8"
BorderBrush="Gray"
BorderThickness="1">
@ -178,14 +180,16 @@
</ListBox>
</Border>
<GridSplitter
Grid.Row="1"
<GridSplitter x:Name="PostsSplitter"
Grid.Row="3"
Margin="12,0"
HorizontalAlignment="Stretch"
ResizeDirection="Rows" />
<Grid x:Name="PostEditor"
IsVisible="{Binding IsEditing}"
Grid.Row="2"
Grid.Row="4"
Margin="12"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
RowSpacing="10">
@ -237,7 +241,7 @@
</Border>
</Grid>
<Grid x:Name="PostReader" Grid.Row="2" IsVisible="{Binding !IsEditing}"
<Grid x:Name="PostReader" Grid.Row="4" Margin="12" IsVisible="{Binding !IsEditing}"
RowDefinitions="Auto,Auto,*" MaxWidth="900" HorizontalAlignment="Stretch" RowSpacing="12">
<TextBlock Text="{Binding ReaderTitle}" FontSize="28" FontWeight="SemiBold"
TextWrapping="Wrap" />
@ -263,7 +267,6 @@
Text="Choisissez un billet dans la liste pour le lire."
HorizontalAlignment="Center" VerticalAlignment="Center" TextWrapping="Wrap" />
</Grid>
</Grid>
<postitControls:StatusBar Grid.Row="3" DataContext="{Binding ActionStatus}" x:Name="StatusBar" />
<postitControls:StatusBar Grid.Row="5" DataContext="{Binding ActionStatus}" x:Name="StatusBar" />
</Grid>
</ContentPage>

View file

@ -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<string?> 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)