From 60619e0290503e643179e9e842c81e31825ccbb9 Mon Sep 17 00:00:00 2001 From: yyc12345 Date: Sun, 4 Oct 2026 16:18:08 +0800 Subject: [PATCH] feat(ui): apply flow direction to modals, default confirmations to Cancel and guard code-behind resources --- .../Conventions/CodeBehindConventionTests.cs | 53 +++++++++++++++++++ YKanBan/App.axaml.cs | 21 +++++--- YKanBan/ViewLocator.cs | 4 ++ YKanBan/Views/Board/BoardView.axaml.cs | 12 ++--- .../Views/Dialogs/CardEditorDialog.axaml.cs | 4 +- .../Dialogs/DeleteCardConfirmDialog.axaml | 4 +- .../Dialogs/DeleteColumnConfirmDialog.axaml | 4 +- .../Dialogs/DeleteTagConfirmDialog.axaml | 4 +- .../Dialogs/DiscardEditConfirmDialog.axaml | 4 +- YKanBan/Views/MainWindow.axaml.cs | 4 +- YKanBan/Views/ModalWindow.cs | 33 ++++++++++++ YKanBan/Views/Tags/TagsView.axaml.cs | 4 +- YKanBan/Views/WorkspaceView.axaml.cs | 2 +- 13 files changed, 125 insertions(+), 28 deletions(-) create mode 100644 YKanBan.Tests/Conventions/CodeBehindConventionTests.cs create mode 100644 YKanBan/Views/ModalWindow.cs diff --git a/YKanBan.Tests/Conventions/CodeBehindConventionTests.cs b/YKanBan.Tests/Conventions/CodeBehindConventionTests.cs new file mode 100644 index 0000000..6ea23ce --- /dev/null +++ b/YKanBan.Tests/Conventions/CodeBehindConventionTests.cs @@ -0,0 +1,53 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; + +namespace YKanBan.Tests.Conventions; + +/// +/// Source-level guards for code-behind conventions (plan ยง8.1): inside control, +/// window and application code-behind a bare Resources.Key resolves to +/// the StyledElement.Resources instance property, so the generated class +/// must be written as YKanBan.Resources (or through a using alias). +/// +[TestClass] +public partial class CodeBehindConventionTests { + /// A Resources. member access not preceded by a qualifier or identifier character. + [GeneratedRegex(@"(? + /// Locates the application project's source folder from this file's compile-time path. + /// + /// Filled in by the compiler. + /// The YKanBan project folder. + private static string ApplicationSourceFolder([CallerFilePath] string thisFile = "") { + DirectoryInfo? directory = new FileInfo(thisFile).Directory; + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "YKanBan", "YKanBan.csproj"))) { + directory = directory.Parent; + } + + Assert.IsNotNull(directory, "Repository root not found from the test source path."); + return Path.Combine(directory.FullName, "YKanBan"); + } + + [TestMethod] + public void CodeBehindNeverUsesBareResourcesClassName() { + string root = ApplicationSourceFolder(); + string[] files = Directory.GetFiles(root, "*.axaml.cs", SearchOption.AllDirectories) + .Where(path => !path.Split(Path.DirectorySeparatorChar).Any(part => part is "obj" or "bin")) + .ToArray(); + Assert.IsTrue(files.Length > 0, "No code-behind files found."); + + var offenders = new List(); + foreach (string file in files) { + string[] lines = File.ReadAllLines(file); + for (int index = 0; index < lines.Length; index++) { + if (BareResourcesAccess().IsMatch(lines[index])) { + offenders.Add($"{Path.GetRelativePath(root, file)}:{index + 1}: {lines[index].Trim()}"); + } + } + } + + Assert.AreEqual(0, offenders.Count, "Use YKanBan.Resources in code-behind:" + Environment.NewLine + string.Join(Environment.NewLine, offenders)); + } +} diff --git a/YKanBan/App.axaml.cs b/YKanBan/App.axaml.cs index 8bffc75..0ef6d2f 100644 --- a/YKanBan/App.axaml.cs +++ b/YKanBan/App.axaml.cs @@ -28,6 +28,12 @@ public partial class App : Application { /// public static string StartupLanguage { get; private set; } = AppConfig.DefaultLanguage; + /// + /// Gets the flow direction of the startup culture. FlowDirection does not + /// inherit across windows, so the main window and every modal window apply it. + /// + public static FlowDirection UiFlowDirection { get; private set; } = FlowDirection.LeftToRight; + public override void Initialize() { AvaloniaXamlLoader.Load(this); } @@ -42,18 +48,19 @@ public partial class App : Application { CultureInfo culture = ResolveCulture(Services.Config.Language); StartupLanguage = Services.Config.Language; Resx.Culture = culture; + + // RTL detection drives the flow direction of every window; the two + // supported languages are LTR, but the infrastructure stays in + // place for future languages. + UiFlowDirection = culture.TextInfo.IsRightToLeft + ? FlowDirection.RightToLeft + : FlowDirection.LeftToRight; ApplyTheme(Services.Config.Theme); var viewModel = new MainWindowViewModel(Services, desktop.Args ?? []); desktop.MainWindow = new MainWindow { DataContext = viewModel, - - // RTL detection drives the whole window flow direction; the two - // supported languages are LTR, but the infrastructure stays in - // place for future languages. - FlowDirection = culture.TextInfo.IsRightToLeft - ? FlowDirection.RightToLeft - : FlowDirection.LeftToRight, + FlowDirection = UiFlowDirection, }; } diff --git a/YKanBan/ViewLocator.cs b/YKanBan/ViewLocator.cs index d1b237e..448a586 100644 --- a/YKanBan/ViewLocator.cs +++ b/YKanBan/ViewLocator.cs @@ -20,6 +20,10 @@ public class ViewLocator : IDataTemplate { return (Control)Activator.CreateInstance(type)!; } + // YYC MARK: the missing-view placeholder is a programmer error (a view + // model without its matching view), not a user-facing message. It must be + // caught by the code-behind convention test before release, so it is kept + // as a hard-coded English diagnostic and is deliberately not localized. return new TextBlock { Text = "Not Found: " + name }; } diff --git a/YKanBan/Views/Board/BoardView.axaml.cs b/YKanBan/Views/Board/BoardView.axaml.cs index e508448..397ec71 100644 --- a/YKanBan/Views/Board/BoardView.axaml.cs +++ b/YKanBan/Views/Board/BoardView.axaml.cs @@ -44,7 +44,7 @@ public partial class BoardView : UserControl { var dialog = new ColumnEditorDialog { DataContext = new ColumnEditorViewModel(board.Repository, column?.Model), }; - if (await dialog.ShowDialog(owner)) { + if (await ModalWindow.ShowAsync(owner, dialog)) { board.Reload(); } } @@ -62,7 +62,7 @@ public partial class BoardView : UserControl { var dialog = new DeleteColumnConfirmDialog { DataContext = new DeleteColumnConfirmViewModel(column.Title, column.CardCount), }; - confirmed = await dialog.ShowDialog(owner); + confirmed = await ModalWindow.ShowAsync(owner, dialog); } if (!confirmed) { @@ -85,7 +85,7 @@ public partial class BoardView : UserControl { var dialog = new CardEditorDialog { DataContext = new CardEditorViewModel(board.Repository, columnId, card?.Model), }; - if (await dialog.ShowDialog(owner)) { + if (await ModalWindow.ShowAsync(owner, dialog)) { board.Reload(); } } @@ -103,7 +103,7 @@ public partial class BoardView : UserControl { var dialog = new DeleteCardConfirmDialog { DataContext = new DeleteCardConfirmViewModel(card.Id), }; - confirmed = await dialog.ShowDialog(owner); + confirmed = await ModalWindow.ShowAsync(owner, dialog); } if (!confirmed) { @@ -125,7 +125,7 @@ public partial class BoardView : UserControl { var dialog = new MoveCardDialog { DataContext = new MoveCardViewModel(board.Repository.GetColumns(), card.ColumnId), }; - if (await dialog.ShowDialog(owner) is long targetColumnId) { + if (await ModalWindow.ShowAsync(owner, dialog) is long targetColumnId) { board.Repository.MoveCard(card.Id, targetColumnId); board.Reload(); } @@ -142,6 +142,6 @@ public partial class BoardView : UserControl { var dialog = new SearchErrorDialog { DataContext = new SearchErrorViewModel(detail), }; - await dialog.ShowDialog(owner); + await ModalWindow.ShowAsync(owner, dialog); } } diff --git a/YKanBan/Views/Dialogs/CardEditorDialog.axaml.cs b/YKanBan/Views/Dialogs/CardEditorDialog.axaml.cs index dd84eb6..81b095b 100644 --- a/YKanBan/Views/Dialogs/CardEditorDialog.axaml.cs +++ b/YKanBan/Views/Dialogs/CardEditorDialog.axaml.cs @@ -35,7 +35,7 @@ public partial class CardEditorDialog : Window { viewModel.Repository, viewModel.AssignedTags.Select(row => row.Tag.Id).ToList()), }; - return await dialog.ShowDialog(this); + return await ModalWindow.ShowAsync(this, dialog); } /// @@ -76,7 +76,7 @@ public partial class CardEditorDialog : Window { private async Task RequestCloseAsync() { if (HasUnsavedChanges && DiscardConfirmEnabled) { var dialog = new DiscardEditConfirmDialog { DataContext = new DiscardEditConfirmViewModel() }; - if (!await dialog.ShowDialog(this)) { + if (!await ModalWindow.ShowAsync(this, dialog)) { return; } } diff --git a/YKanBan/Views/Dialogs/DeleteCardConfirmDialog.axaml b/YKanBan/Views/Dialogs/DeleteCardConfirmDialog.axaml index 81fb09b..94051a0 100644 --- a/YKanBan/Views/Dialogs/DeleteCardConfirmDialog.axaml +++ b/YKanBan/Views/Dialogs/DeleteCardConfirmDialog.axaml @@ -16,9 +16,9 @@