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 @@
+ MinWidth="90"/>
+ MinWidth="90" IsDefault="True" IsCancel="True"/>
diff --git a/YKanBan/Views/Dialogs/DeleteColumnConfirmDialog.axaml b/YKanBan/Views/Dialogs/DeleteColumnConfirmDialog.axaml
index f0e83ce..4dfe520 100644
--- a/YKanBan/Views/Dialogs/DeleteColumnConfirmDialog.axaml
+++ b/YKanBan/Views/Dialogs/DeleteColumnConfirmDialog.axaml
@@ -16,9 +16,9 @@
+ MinWidth="90"/>
+ MinWidth="90" IsDefault="True" IsCancel="True"/>
diff --git a/YKanBan/Views/Dialogs/DeleteTagConfirmDialog.axaml b/YKanBan/Views/Dialogs/DeleteTagConfirmDialog.axaml
index 68842f4..c3cab38 100644
--- a/YKanBan/Views/Dialogs/DeleteTagConfirmDialog.axaml
+++ b/YKanBan/Views/Dialogs/DeleteTagConfirmDialog.axaml
@@ -16,9 +16,9 @@
+ MinWidth="90"/>
+ MinWidth="90" IsDefault="True" IsCancel="True"/>
diff --git a/YKanBan/Views/Dialogs/DiscardEditConfirmDialog.axaml b/YKanBan/Views/Dialogs/DiscardEditConfirmDialog.axaml
index a9b1822..ac67feb 100644
--- a/YKanBan/Views/Dialogs/DiscardEditConfirmDialog.axaml
+++ b/YKanBan/Views/Dialogs/DiscardEditConfirmDialog.axaml
@@ -16,9 +16,9 @@
+ MinWidth="90"/>
+ MinWidth="90" IsDefault="True" IsCancel="True"/>
diff --git a/YKanBan/Views/MainWindow.axaml.cs b/YKanBan/Views/MainWindow.axaml.cs
index 29730ec..f9902b3 100644
--- a/YKanBan/Views/MainWindow.axaml.cs
+++ b/YKanBan/Views/MainWindow.axaml.cs
@@ -33,7 +33,7 @@ public partial class MainWindow : Window {
var dialog = new SettingsDialog {
DataContext = new SettingsDialogViewModel(App.Services.Config, App.StartupLanguage),
};
- await dialog.ShowDialog(this);
+ await ModalWindow.ShowAsync(this, dialog);
}
///
@@ -43,6 +43,6 @@ public partial class MainWindow : Window {
var dialog = new AboutDialog {
DataContext = new AboutDialogViewModel(),
};
- await dialog.ShowDialog(this);
+ await ModalWindow.ShowAsync(this, dialog);
}
}
diff --git a/YKanBan/Views/ModalWindow.cs b/YKanBan/Views/ModalWindow.cs
new file mode 100644
index 0000000..92bd5cd
--- /dev/null
+++ b/YKanBan/Views/ModalWindow.cs
@@ -0,0 +1,33 @@
+using Avalonia.Controls;
+
+namespace YKanBan.Views;
+
+///
+/// Shows a modal window owned by another window, applying the startup culture's
+/// flow direction first. FlowDirection does not inherit across windows, so every
+/// modal must apply it explicitly.
+///
+internal static class ModalWindow {
+ ///
+ /// Shows a modal window that returns a result.
+ ///
+ /// The owning window.
+ /// The modal window to show.
+ /// The dialog result type.
+ /// A task completing with the dialog result.
+ public static Task ShowAsync(Window owner, Window dialog) {
+ dialog.FlowDirection = App.UiFlowDirection;
+ return dialog.ShowDialog(owner);
+ }
+
+ ///
+ /// Shows a modal window that has no result.
+ ///
+ /// The owning window.
+ /// The modal window to show.
+ /// A task completing when the dialog closes.
+ public static Task ShowAsync(Window owner, Window dialog) {
+ dialog.FlowDirection = App.UiFlowDirection;
+ return dialog.ShowDialog(owner);
+ }
+}
diff --git a/YKanBan/Views/Tags/TagsView.axaml.cs b/YKanBan/Views/Tags/TagsView.axaml.cs
index d51d415..37e351a 100644
--- a/YKanBan/Views/Tags/TagsView.axaml.cs
+++ b/YKanBan/Views/Tags/TagsView.axaml.cs
@@ -39,7 +39,7 @@ public partial class TagsView : UserControl {
var dialog = new TagEditorDialog {
DataContext = new TagEditorViewModel(tags.Repository, item?.Tag),
};
- if (await dialog.ShowDialog(owner)) {
+ if (await ModalWindow.ShowAsync(owner, dialog)) {
tags.RefreshAfterChange();
}
}
@@ -57,7 +57,7 @@ public partial class TagsView : UserControl {
var dialog = new DeleteTagConfirmDialog {
DataContext = new DeleteTagConfirmViewModel(item.Name, item.UsageCount),
};
- confirmed = await dialog.ShowDialog(owner);
+ confirmed = await ModalWindow.ShowAsync(owner, dialog);
}
if (!confirmed) {
diff --git a/YKanBan/Views/WorkspaceView.axaml.cs b/YKanBan/Views/WorkspaceView.axaml.cs
index 9b99113..26024f2 100644
--- a/YKanBan/Views/WorkspaceView.axaml.cs
+++ b/YKanBan/Views/WorkspaceView.axaml.cs
@@ -66,7 +66,7 @@ public partial class WorkspaceView : UserControl {
var dialog = new ExportDoneDialog {
DataContext = new ExportDoneViewModel(file.TryGetLocalPath() ?? file.Name),
};
- await dialog.ShowDialog(owner);
+ await ModalWindow.ShowAsync(owner, dialog);
}
}
}