diff --git a/.forgejo/workflows/buildAndTest.yml b/.forgejo/workflows/buildAndTest.yml index e1e936204..408212098 100644 --- a/.forgejo/workflows/buildAndTest.yml +++ b/.forgejo/workflows/buildAndTest.yml @@ -27,7 +27,7 @@ jobs: build: runs-on: docker container: - image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v2 + image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4 steps: - name: Clone yavsc run: | diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 7d0670a17..de61bd43d 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -52,7 +52,7 @@ jobs: release: runs-on: docker container: - image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v2 + image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4 steps: - name: Clone du repo au tag demandé env: diff --git a/CHANGELOG.md b/CHANGELOG.md index e36d75dd3..8f77b68af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,88 @@ # Changelog +## [1.0.8-rc22] - unstable -## [1.0.8-rc19] - Blogs UX +### Fixed + +User experience login into PostIt + +## [1.0.8-rc21] - unstable + +### Fixed + +User experience editing the blog article. + +## [1.0.8-rc20] - unstable + +Chat SignalR fonctionnel de bout en bout (connexion, salons multiples, +envoi/réception de messages), enregistreur audio et localisation dans +PostIt, intégration Live.Avalonia démarrée, et reverse-proxy nginx +apprêté pour WebSocket. + +### Added + +* [PostIt] Interface de chat : page `ChatPage` (connexion au hub, + saisie du nom de salon, liste des messages et des membres présents), + entrée depuis la page d'accueil. +* [PostIt] Envoi de messages dans un salon joint : DTO `ChatMessage` + (Name/Room/Message) pour l'événement `ReceiveMessage`, saisie avec + envoi sur `Enter`, commande inactive tant qu'aucune salle n'est en + focus (le hub refuse l'envoi hors salon, `LabNoJoinNoSend`). +* [PostIt] Modèle multi-salles côté client : `JoinedRooms`, + `SelectedRoom` (salle en focus), messages et utilisateurs indexés par + salle (`FocusedMessages`/`FocusedUsers`), ComboBox de choix de la + salle active ; un `Join` sur une salle déjà jointe ne fait que + changer le focus. +* [PostIt] Enregistreur audio (`AudioRecorder`, interface + `IPCMProcessor`) et ressources de localisation + (`Resources.{resx,en-EN,pt-PT}`). +* [PostIt][WIP] Début d'intégration Live.Avalonia (preview XAML à + chaud ; encore KO). +* [contrib] Bloc `location /chathub` dans `template.vhost` : upgrade + WebSocket (`Upgrade`/`Connection`), `proxy_read_timeout 3600s` et + `proxy_buffering off` — sans quoi la connexion SignalR tombait au + premier silence ou l'upgrade échouait derrière nginx. + +### Changed + +* [PostIt] `StatusNotice` déplacé de `Models` vers `ViewModels` et + enrichi d'une commande d'action optionnelle (`CheckCommand` + + `CheckCommandLabel`, interface `IWithLabel`) : le bandeau de statut + propose désormais l'action contextuelle (Connecter / Déconnecter / + Reconnecter / Vérifier la connexion). +* [PostIt] `ChatViewModel` suit le cycle de vie complet de la + connexion : handlers `Reconnecting` / `Reconnected` / `Closed` de + `WithAutomaticReconnect`, pour refléter l'état réel au lieu d'un + `IsConnected` optimiste. +* [Yavsc.Api] `AddSignalR().AddJsonProtocol(IncludeFields = true)` : + `ChatRoomInfo` n'expose que des champs publics, que System.Text.Json + ne sérialise pas par défaut — le client recevait des objets vides + (`Name` null, `Users` vide) et la liste des salons joints n'était + jamais alimentée. +* [Yavsc.Server] `IConnexionManager.OnConnected` prend le `userName` + en paramètre (résolu depuis les claims dans `ChatHub`) au lieu de le + relire depuis un dictionnaire jamais alimenté. +* [PostIt.Android] `network_security_config.xml` déplacé sous + `Resources/xml/` (chemin canonique Android). + +### Fixed + +* [Yavsc.Server] `KeyNotFoundException` dans + `HubConnectionManager.OnConnected` à chaque connexion SignalR : + `ChatUserNames` (et `ChatCxIds`) n'étaient jamais alimentés — relique + d'un `SetUserName` disparu lors d'une refactorisation. L'exception + dans `OnConnectedAsync` faisait fermer la connexion par le hub, et + PostIt affichait « Déconnecté (The server closed the connection…) » + immédiatement après la connexion. +* [PostIt] URL du hub SignalR : retrait du préfixe `/api/v1` de + `ApiUrl` avant d'apposer `Constants.ChatHubPath` (le hub est mappé à + la racine du service, pas sous le préfixe versionné). +* [PostIt] La `StatusBar` de la page chat référençait `Grid.Row="4"` + sur une grille à 3 rangées (rangée hors limites, bandeau invisible). + +## [1.0.8-rc19] - unstable + +[PostIt] Blogs UX ### Fixed diff --git a/README.md b/README.md index fefbdd48d..1cee99bbb 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,12 @@ L'impact de l'usage d'un nouveau nom d’environnement d'execution, à l'heure d * ~/Views/Home/Index.cshtml * ~/Views/Home/About.cshtml +# 👥 Crédits & Co-autoraClaudt + +Ce projet a été développé de manière hybride : +- **Conception, architecture et revue de code :** Paul Schneider +- **Assistance au code et optimisation :** IA générative (Gemini, Google, Claude (Anthropic), https://qoder.com/ (ex-Windsurf)) + # Remerciements Photo de Dewang Guptasur Unsplash diff --git a/contrib/Makefile b/contrib/Makefile index deb55bcc6..5ccf773a1 100644 --- a/contrib/Makefile +++ b/contrib/Makefile @@ -14,7 +14,7 @@ reinstall: copy-binaries @echo "💯 backend started" reinstall-prod: - make reinstall APP_INSTALL_ENV= APP_INSTALL_PREFIX= APP_INSTALL_ENV=production + make reinstall APP_INSTALL_PREFIX= APP_INSTALL_ENV=production generated/$(APP_INSTALL_PREFIX)yavscOrg.service: generated/$(APP_INSTALL_PREFIX)yavscBlogs.service: diff --git a/contrib/template.vhost b/contrib/template.vhost index ea2c2cb6d..b4c9c139e 100644 --- a/contrib/template.vhost +++ b/contrib/template.vhost @@ -57,5 +57,25 @@ server { proxy_read_timeout 100s; proxy_buffering off; } + + # SignalR (ChatHub) : WebSocket exige Upgrade/Connection et un timeout + # long, sinon la connexion tombe au premier silence ou l'upgrade échoue. + # Connection est fixé à "upgrade" (pas de map $connection_upgrade, qui + # exigerait une directive map {} globale hors de ce vhost) : SignalR + # négocie toujours un upgrade sur ce chemin, et la négociation initiale + # (POST /negotiate, sans en-tête Upgrade) n'est pas affectée par cet + # en-tête côté Kestrel. + location /chathub { + proxy_pass $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 3600s; + proxy_buffering off; + } include server-status.conf; -} \ No newline at end of file +} diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props index 42d09cd55..a353e4b3a 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -4,7 +4,7 @@ true - 12.1.2 + 12.1.3 @@ -13,16 +13,21 @@ - + + + - - + + + + + @@ -31,4 +36,4 @@ - \ No newline at end of file + diff --git a/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml b/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml index 8793aae8a..256555df1 100644 --- a/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml +++ b/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml @@ -1,6 +1,10 @@  - + diff --git a/src/PostIt/PostIt.Android/Resources/xml/network_security_config.xml b/src/PostIt/PostIt.Android/Resources/xml/network_security_config.xml new file mode 100644 index 000000000..6d2c97559 --- /dev/null +++ b/src/PostIt/PostIt.Android/Resources/xml/network_security_config.xml @@ -0,0 +1,6 @@ + + + + 10.0.2.2 + + diff --git a/src/PostIt/PostIt.Desktop/Program.cs b/src/PostIt/PostIt.Desktop/Program.cs index a2b8cd66f..1c5aa0c27 100644 --- a/src/PostIt/PostIt.Desktop/Program.cs +++ b/src/PostIt/PostIt.Desktop/Program.cs @@ -1,6 +1,7 @@ using System; using Avalonia; using PostIt.Services; +using PostIt.CLIOptions; namespace PostIt.Desktop; diff --git a/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs index 17efd391b..31c82387d 100644 --- a/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs @@ -1,4 +1,5 @@ using System.Net.Http; +using PostIt.Models; using PostIt.ViewModels; using Yavsc; using Yavsc.Api.Client; diff --git a/src/PostIt/PostIt.Tests/MD/le-chant-1.md b/src/PostIt/PostIt.Tests/MD/le-chant-1.md new file mode 100644 index 000000000..f4a7506fe --- /dev/null +++ b/src/PostIt/PostIt.Tests/MD/le-chant-1.md @@ -0,0 +1,34 @@ + + Loin vers l'infini s'étendent + Les grands prés marécageux . + Pas un seul oiseau ne chante + Sur les arbres secs et creux ! + +*(Refrain)* + + Ô terre de détresse, + Où nous devons sans cesse, + Piocher, piocher . + + Dans ce camp morne et sauvage + Entouré de murs de fer, + Il nous semble vivre en cage + Au milieu d'un grand désert . + + Bruit des pas et bruit des armes, + Sentinelles jours et nuits, + Et du sang, et des cris, des larmes, + La mort pour celui qui fuit . + + Mais un jour dans notre vie, + Le printemps refleurira . + Liberté, liberté chérie + Je dirai : « Tu es à moi ! » + +*(Dernier refrain)* + + Ô terre d'allégresse, + Où nous pourrons sans cesse, + Aimer, aimer ! + +[audio : Mario Hacquard - Youtube](https://www.youtube.com/watch?v=ogw_mgk1pFs) diff --git a/src/PostIt/PostIt.Tests/SessionStatusBannerTests.cs b/src/PostIt/PostIt.Tests/SessionStatusBannerTests.cs index cc08304d5..3516e93d6 100644 --- a/src/PostIt/PostIt.Tests/SessionStatusBannerTests.cs +++ b/src/PostIt/PostIt.Tests/SessionStatusBannerTests.cs @@ -101,7 +101,7 @@ public class SessionStatusBannerTests { MainWindow window = new MainWindow(); window.Show(); - var banner = window.MainView.SessionBanner; + var banner = new SessionStatusBanner(); var status = new SessionStatusViewModel(); banner.DataContext = status; return banner; diff --git a/src/PostIt/PostIt.Tests/StatusBarTests.cs b/src/PostIt/PostIt.Tests/StatusBarTests.cs index 4053dbaf3..15889e0b0 100644 --- a/src/PostIt/PostIt.Tests/StatusBarTests.cs +++ b/src/PostIt/PostIt.Tests/StatusBarTests.cs @@ -2,6 +2,7 @@ using Avalonia.Controls; using Avalonia.Headless.XUnit; using Avalonia.VisualTree; using PostIt.Controls; +using PostIt.Models; using PostIt.ViewModels; namespace PostIt.Tests; @@ -21,7 +22,9 @@ public class StatusBarTests [AvaloniaFact] public void Status_message_is_rendered_by_a_SelectableTextBlock() { - var bar = new StatusBar { DataContext = StatusNotice.Error("Échec de la suppression : boom") }; + var bar = new StatusBar { + DataContext = + StatusNotice.Error("Échec de la suppression : boom") }; var window = new Window { Content = bar }; window.Show(); @@ -32,4 +35,4 @@ public class StatusBarTests Assert.NotNull(message); Assert.Equal("Échec de la suppression : boom", message!.Text); } -} \ No newline at end of file +} diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index 9a04ffd03..fca8e0313 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -6,18 +6,26 @@ using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using Avalonia.Styling; +using Avalonia.Threading; using Microsoft.Extensions.DependencyInjection; using PostIt.Services; using PostIt.ViewModels; using PostIt.Views; using PostIt.Helpers; +using System.Globalization; +using Live.Avalonia; +using System.Diagnostics; +using System.Linq; +using ReactiveUI; +using System.Reactive; namespace PostIt; -public partial class App : Application +public partial class App : Application, ILiveView, IVMPusherApp { + public static bool LiveMode { get; private set; } private int _bootStarted; - public string? CliConfigFileSpecification {  get; private set; } + public static string? CliConfigFileSpecification { get; private set; } /// /// DI container the platform entry points hand to ViewModels so @@ -36,36 +44,132 @@ public partial class App : Application public override void Initialize() { AvaloniaXamlLoader.Load(this); + +#if DEBUG + this.AttachDeveloperTools(); +#endif } public override void OnFrameworkInitializationCompleted() { if (TryHandOffCustomSchemeUrl()) return; - this.ServiceProvider = new ServiceCollection().BuildServices(); + this.ServiceProvider = new ServiceCollection().BuildPostItServices(); var settings = ServiceProvider.GetRequiredService(); - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + if (!LiveMode) { - InitializeClassicDesktopLifetime(settings, desktop); + // Debugging requires pdb loading etc, so we disable live reloading + // during a test run with an attached debugger. + + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + InitializeClassicDesktopLifetime(settings, desktop); + } + else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime) + { + InitializeActivityLifetime(settings, singleViewFactoryApplicationLifetime); + } + else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform) + { + InitializeSingleViewLifetime(settings, singleViewPlatform); + } } - else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime) + else { - InitializeActivityLifetime(settings, singleViewFactoryApplicationLifetime); - } - else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform) - { - InitializeSingleViewLifetime(settings, singleViewPlatform); + // Here, we create a new LiveViewHost, located in the 'Live.Avalonia' + // namespace, and pass an ILiveView implementation to it. The ILiveView + // implementation should have a parameterless constructor! Next, we + // start listening for any changes in the source files. And then, we + // show the LiveViewHost window. Simple enough, huh? + var window = new LiveViewHost(this, Console.WriteLine); + var view = ServiceProvider!.GetRequiredService(); + + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + InitializeClassicDesktopLifetime(window, view, settings, desktop); + } + else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime) + { + InitializeActivityLifetime(settings, singleViewFactoryApplicationLifetime); + } + else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform) + { + InitializeSingleViewLifetime(settings, singleViewPlatform); + } + + window.StartWatchingSourceFilesForHotReloading(); + Console.WriteLine("LiveViewHost started for hot reloading."); + window.Show(); } + +#if DEBUG + // Here we subscribe to ReactiveUI default exception handler to avoid app + // termination in case if we do something wrong in our view models. See: + // https://www.reactiveui.net/docs/handbook/default-exception-handler/ + // + // In case if you are using another MV* framework, please refer to its + // documentation explaining global exception handling. + Console.WriteLine("Debug mode: ReactiveUI default exception handler is set."); + RxApp.DefaultExceptionHandler = Observer.Create(Console.WriteLine); +#endif + base.OnFrameworkInitializationCompleted(); } + private bool IsProduction() + { +#if DEBUG + return false; +#else + return true; +#endif + } + + public async Task PushPageAsync(ViewModelBase viewModel) + { + var template = DataTemplates.FirstOrDefault(t => t.Match(viewModel)); + if (template is null) + { + throw new InvalidOperationException($"No IDataTemplate found for {viewModel.GetType().Name}."); + } + + Control? view = template.Build(viewModel) ; + if (view is null) + { + throw new InvalidOperationException( + $"Template for {viewModel.GetType().Name} returned ."); + } + + var page = view as Page; + if (page is null) + { + // NavigationPage expects Page instances. Wrap any fallback control + // (e.g. ViewLocator error TextBlock) into a ContentPage so it can render. + page = new ContentPage { Content = view }; + } + + page.DataContext = viewModel; + + MainView? mainView = (Current as App).View; + + // Avoid stacking the same singleton page twice (e.g. SettingsPage). + var stack = mainView.NavRoot.NavigationStack; + if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page)) + { + return page; + } + + await mainView.NavRoot.PushAsync(page); + return page; + } + public void InitializeSingleViewLifetime(Settings settings, ISingleViewApplicationLifetime singleViewPlatform) { singleViewPlatform.MainView = View = ServiceProvider!.GetRequiredService(); ConfigureRootView(View); - ApplyDarkMode(settings); + ApplyStettings(settings); } public void InitializeActivityLifetime(Settings settings, IActivityApplicationLifetime singleViewFactoryApplicationLifetime) @@ -74,7 +178,7 @@ public partial class App : Application () => { this.ConfigureRootView(ServiceProvider!.GetRequiredService()); - ApplyDarkMode(settings); + ApplyStettings(settings); return View!; }; } @@ -82,37 +186,35 @@ public partial class App : Application public void InitializeClassicDesktopLifetime(Settings settings, IClassicDesktopStyleApplicationLifetime desktop) { var window = ServiceProvider!.GetRequiredService(); - desktop.MainWindow = window; - this.ConfigureRootView(window.MainView); + var mainView = (MainView)CreateView(window); + InitializeClassicDesktopLifetime(window, mainView, settings, desktop); + } - ApplyDarkMode(settings); + public void InitializeClassicDesktopLifetime(Window window, MainView mainView, Settings settings, IClassicDesktopStyleApplicationLifetime desktop) + { + this.ConfigureRootView(mainView); + ApplyStettings(settings); + desktop.MainWindow = window; + window.Content = mainView; + window.Show(); } public void ConfigureRootView(MainView rootView) -{ - // Déclencher le Boot une seule fois lors du chargement du contrôle à l'écran. - rootView.AttachedToVisualTree += async (_, _) => await BootOnceAsync(); - - var sessionStatus = ServiceProvider!.GetRequiredService(); - sessionStatus.LogoutCompleted += () => { - // Remplacer Window.NavRoot par rootView.NavRoot - rootView.NavRoot.PopToRootAsync(); - }; + rootView.AttachedToVisualTree += (_, _) => + Dispatcher.UIThread.Post(() => _ = BootOnceAsync()); - rootView.SessionBanner.DataContext = sessionStatus; - this.View = rootView; -} - - private async Task BootOnceAsync() - { - if (Interlocked.Exchange(ref _bootStarted, 1) == 1) + var sessionStatus = ServiceProvider!.GetRequiredService(); + sessionStatus.LogoutCompleted += () => { - return; - } + // Remplacer Window.NavRoot par rootView.NavRoot + rootView.NavRoot.PopToRootAsync(); + }; + if (rootView.DataContext==null) + rootView.DataContext = ServiceProvider!.GetRequiredService(); - var api = ServiceProvider!.GetRequiredService(); - await BootAsync(this.ServiceProvider!, api); + rootView.SessionBanner.DataContext = sessionStatus; + this.View = rootView; } /// @@ -126,10 +228,24 @@ public partial class App : Application View = mainView ?? throw new ArgumentNullException(nameof(mainView)); } - private static void ApplyDarkMode(Settings settings) + private static void ApplyStettings(Settings settings) { - Application.Current!.RequestedThemeVariant = - settings.DarkMode ? ThemeVariant.Dark : ThemeVariant.Light; + if (!string.IsNullOrWhiteSpace(settings.SelectedLanguage)) + Yavsc.Lang.Resources.Culture = new CultureInfo(settings.SelectedLanguage); + Current!.RequestedThemeVariant = + settings.DarkMode ? ThemeVariant.Dark : ThemeVariant.Light; + } + + + private async Task BootOnceAsync() + { + if (Interlocked.Exchange(ref _bootStarted, 1) == 1) + { + return; + } + + var api = ServiceProvider!.GetRequiredService(); + await BootAsync(this.ServiceProvider!, api); } /// @@ -147,9 +263,9 @@ public partial class App : Application var refreshed = await api.TrySilentLoginAsync().ConfigureAwait(true); var sessionStatus = provider.GetRequiredService(); sessionStatus.Refresh(); - var homePage = provider.GetRequiredService(); + var homePageVm = provider.GetRequiredService(); var app = (App)Current!; - await app.PushPageAsync(homePage); + await app.PushPageAsync(homePageVm); } /// @@ -205,8 +321,31 @@ public partial class App : Application await View!.NavRoot.PopAsync(); } - internal void UseConfigFileWhenLoading(string configFile) + internal static void UseConfigFileWhenLoading(string configFile) { - this.CliConfigFileSpecification = configFile; + CliConfigFileSpecification = configFile; + } + + internal static void EnableLiveMode() + { + LiveMode = true; + } + + public object CreateView(Window window) + { + if (this.ServiceProvider == null) + { + var serviceCollection = new ServiceCollection(); + this.ServiceProvider = serviceCollection.BuildPostItServices(); + } + if (window.DataContext == null) + window.DataContext = ServiceProvider!.GetRequiredService(); + + // The AppView class will inherit the DataContext + // of the window. The AppView class can be a + // UserControl, a Grid, a TextBlock, whatever. + this.View = ServiceProvider!.GetRequiredService(); + this.ConfigureRootView(this.View); + return this.View; } } diff --git a/src/PostIt/PostIt/CLIOptions/CLIOptions.cs b/src/PostIt/PostIt/CLIOptions/CLIOptions.cs index c0bea85ff..643102b38 100644 --- a/src/PostIt/PostIt/CLIOptions/CLIOptions.cs +++ b/src/PostIt/PostIt/CLIOptions/CLIOptions.cs @@ -4,15 +4,21 @@ using Mono.Options; using PostIt; using PostIt.ViewModels; +namespace PostIt.CLIOptions; + public static class CLIOptionHandler { public static void HandleArgs(string[] args) { bool showHelp = false; string configFile = null; + bool liveMode = false; var options = new OptionSet { { "c|configuration=", "Configuration file to load", (string v) => configFile = v }, + { "l|live", "Enable live mode", v => liveMode = true }, + { "v|version", "Show the application version", v => Console.WriteLine("PostIt version " + + typeof(App).Assembly.GetName().Version?.ToString()) }, { "h|help", "Show this message and exit", v => showHelp = true } }; @@ -26,8 +32,11 @@ public static class CLIOptionHandler } if (configFile != null) { - var app = App.Current as App; - app.UseConfigFileWhenLoading(configFile); + App.UseConfigFileWhenLoading(configFile); + } + if (liveMode) + { + App.EnableLiveMode(); } } } diff --git a/src/PostIt/PostIt/Controls/MarkdownEditorControl.axaml b/src/PostIt/PostIt/Controls/MarkdownEditorControl.axaml new file mode 100644 index 000000000..9e8df6dd1 --- /dev/null +++ b/src/PostIt/PostIt/Controls/MarkdownEditorControl.axaml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + diff --git a/src/PostIt/PostIt/Controls/MarkdownEditorControl.axaml.cs b/src/PostIt/PostIt/Controls/MarkdownEditorControl.axaml.cs new file mode 100644 index 000000000..0431b726e --- /dev/null +++ b/src/PostIt/PostIt/Controls/MarkdownEditorControl.axaml.cs @@ -0,0 +1,88 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using AvaloniaEdit.Document; + +namespace PostIt.Controls; + +public partial class MarkdownEditorControl : UserControl +{ + public static readonly StyledProperty MarkdownTextProperty = + AvaloniaProperty.Register(nameof(MarkdownText)); + + public static readonly StyledProperty DocumentProperty = + AvaloniaProperty.Register(nameof(Document)); + + public static readonly StyledProperty IsPreviewVisibleProperty = + AvaloniaProperty.Register(nameof(IsPreviewVisible), false); + + public string? MarkdownText + { + get => GetValue(MarkdownTextProperty); + set => SetValue(MarkdownTextProperty, value); + } + + public TextDocument? Document + { + get => GetValue(DocumentProperty); + set => SetValue(DocumentProperty, value); + } + + public bool IsPreviewVisible + { + get => GetValue(IsPreviewVisibleProperty); + set => SetValue(IsPreviewVisibleProperty, value); + } + + public MarkdownEditorControl() + { + InitializeComponent(); + PropertyChanged += OnPropertyChanged; + SizeChanged += OnSizeChanged; + UpdateEditorLimits(); + } + + private void OnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property == MarkdownTextProperty) + { + var text = e.NewValue as string ?? string.Empty; + if (Document is null || !string.Equals(Document.Text, text, StringComparison.Ordinal)) + { + Document = new TextDocument(text); + } + } + else if (e.Property == DocumentProperty) + { + var newDocument = e.NewValue as TextDocument; + var text = newDocument?.Text ?? string.Empty; + if (!string.Equals(MarkdownText, text, StringComparison.Ordinal)) + { + MarkdownText = text; + } + } + } + + private void OnSizeChanged(object? sender, SizeChangedEventArgs e) + { + UpdateEditorLimits(); + } + + private void UpdateEditorLimits() + { + var visibleHeight = Bounds.Height > 0 ? Bounds.Height - 24 : 260; + visibleHeight = Math.Max(visibleHeight, 80); + + if (SourceEditor is not null) + { + SourceEditor.MaxHeight = visibleHeight; + SourceEditor.Height = double.NaN; + } + + if (PreviewViewer is not null) + { + PreviewViewer.MaxHeight = visibleHeight; + PreviewViewer.Height = double.NaN; + } + } +} diff --git a/src/PostIt/PostIt/Controls/StatusBar.axaml b/src/PostIt/PostIt/Controls/StatusBar.axaml index 2f46fee33..755ff7d3e 100644 --- a/src/PostIt/PostIt/Controls/StatusBar.axaml +++ b/src/PostIt/PostIt/Controls/StatusBar.axaml @@ -10,16 +10,19 @@ Padding="8"> - + FontWeight="Bold" + Foreground="{Binding Foreground}" + VerticalAlignment="Top"/> + Foreground="{Binding Foreground}" + TextWrapping="Wrap" + VerticalAlignment="Center"/> +