feat(ui): apply flow direction to modals, default confirmations to Cancel and guard code-behind resources

This commit is contained in:
yyc12345 committed 2026-10-04 16:18:08 +08:00
1 parent e3265e3c89
commit 60619e0290
13 files changed
+125 -28

No files matched your search

@@ -0,0 +1,53 @@
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
namespace YKanBan.Tests.Conventions;
/// <summary>
/// Source-level guards for code-behind conventions (plan §8.1): inside control,
/// window and application code-behind a bare <c>Resources.Key</c> resolves to
/// the <c>StyledElement.Resources</c> instance property, so the generated class
/// must be written as <c>YKanBan.Resources</c> (or through a using alias).
/// </summary>
[TestClass]
public partial class CodeBehindConventionTests {
/// <summary>A <c>Resources.</c> member access not preceded by a qualifier or identifier character.</summary>
[GeneratedRegex(@"(?<![\w.])Resources\.")]
private static partial Regex BareResourcesAccess();
/// <summary>
/// Locates the application project's source folder from this file's compile-time path.
/// </summary>
/// <param name="thisFile">Filled in by the compiler.</param>
/// <returns>The YKanBan project folder.</returns>
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<string>();
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));
}
}
+14 -7
View File
@@ -28,6 +28,12 @@ public partial class App : Application {
/// </summary> /// </summary>
public static string StartupLanguage { get; private set; } = AppConfig.DefaultLanguage; public static string StartupLanguage { get; private set; } = AppConfig.DefaultLanguage;
/// <summary>
/// Gets the flow direction of the startup culture. FlowDirection does not
/// inherit across windows, so the main window and every modal window apply it.
/// </summary>
public static FlowDirection UiFlowDirection { get; private set; } = FlowDirection.LeftToRight;
public override void Initialize() { public override void Initialize() {
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
} }
@@ -42,18 +48,19 @@ public partial class App : Application {
CultureInfo culture = ResolveCulture(Services.Config.Language); CultureInfo culture = ResolveCulture(Services.Config.Language);
StartupLanguage = Services.Config.Language; StartupLanguage = Services.Config.Language;
Resx.Culture = culture; 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); ApplyTheme(Services.Config.Theme);
var viewModel = new MainWindowViewModel(Services, desktop.Args ?? []); var viewModel = new MainWindowViewModel(Services, desktop.Args ?? []);
desktop.MainWindow = new MainWindow { desktop.MainWindow = new MainWindow {
DataContext = viewModel, DataContext = viewModel,
FlowDirection = UiFlowDirection,
// 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,
}; };
} }
+4
View File
@@ -20,6 +20,10 @@ public class ViewLocator : IDataTemplate {
return (Control)Activator.CreateInstance(type)!; 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 }; return new TextBlock { Text = "Not Found: " + name };
} }
+6 -6
View File
@@ -44,7 +44,7 @@ public partial class BoardView : UserControl {
var dialog = new ColumnEditorDialog { var dialog = new ColumnEditorDialog {
DataContext = new ColumnEditorViewModel(board.Repository, column?.Model), DataContext = new ColumnEditorViewModel(board.Repository, column?.Model),
}; };
if (await dialog.ShowDialog<bool>(owner)) { if (await ModalWindow.ShowAsync<bool>(owner, dialog)) {
board.Reload(); board.Reload();
} }
} }
@@ -62,7 +62,7 @@ public partial class BoardView : UserControl {
var dialog = new DeleteColumnConfirmDialog { var dialog = new DeleteColumnConfirmDialog {
DataContext = new DeleteColumnConfirmViewModel(column.Title, column.CardCount), DataContext = new DeleteColumnConfirmViewModel(column.Title, column.CardCount),
}; };
confirmed = await dialog.ShowDialog<bool>(owner); confirmed = await ModalWindow.ShowAsync<bool>(owner, dialog);
} }
if (!confirmed) { if (!confirmed) {
@@ -85,7 +85,7 @@ public partial class BoardView : UserControl {
var dialog = new CardEditorDialog { var dialog = new CardEditorDialog {
DataContext = new CardEditorViewModel(board.Repository, columnId, card?.Model), DataContext = new CardEditorViewModel(board.Repository, columnId, card?.Model),
}; };
if (await dialog.ShowDialog<bool>(owner)) { if (await ModalWindow.ShowAsync<bool>(owner, dialog)) {
board.Reload(); board.Reload();
} }
} }
@@ -103,7 +103,7 @@ public partial class BoardView : UserControl {
var dialog = new DeleteCardConfirmDialog { var dialog = new DeleteCardConfirmDialog {
DataContext = new DeleteCardConfirmViewModel(card.Id), DataContext = new DeleteCardConfirmViewModel(card.Id),
}; };
confirmed = await dialog.ShowDialog<bool>(owner); confirmed = await ModalWindow.ShowAsync<bool>(owner, dialog);
} }
if (!confirmed) { if (!confirmed) {
@@ -125,7 +125,7 @@ public partial class BoardView : UserControl {
var dialog = new MoveCardDialog { var dialog = new MoveCardDialog {
DataContext = new MoveCardViewModel(board.Repository.GetColumns(), card.ColumnId), DataContext = new MoveCardViewModel(board.Repository.GetColumns(), card.ColumnId),
}; };
if (await dialog.ShowDialog<long?>(owner) is long targetColumnId) { if (await ModalWindow.ShowAsync<long?>(owner, dialog) is long targetColumnId) {
board.Repository.MoveCard(card.Id, targetColumnId); board.Repository.MoveCard(card.Id, targetColumnId);
board.Reload(); board.Reload();
} }
@@ -142,6 +142,6 @@ public partial class BoardView : UserControl {
var dialog = new SearchErrorDialog { var dialog = new SearchErrorDialog {
DataContext = new SearchErrorViewModel(detail), DataContext = new SearchErrorViewModel(detail),
}; };
await dialog.ShowDialog(owner); await ModalWindow.ShowAsync(owner, dialog);
} }
} }
@@ -35,7 +35,7 @@ public partial class CardEditorDialog : Window {
viewModel.Repository, viewModel.Repository,
viewModel.AssignedTags.Select(row => row.Tag.Id).ToList()), viewModel.AssignedTags.Select(row => row.Tag.Id).ToList()),
}; };
return await dialog.ShowDialog<TagModel?>(this); return await ModalWindow.ShowAsync<TagModel?>(this, dialog);
} }
/// <summary> /// <summary>
@@ -76,7 +76,7 @@ public partial class CardEditorDialog : Window {
private async Task RequestCloseAsync() { private async Task RequestCloseAsync() {
if (HasUnsavedChanges && DiscardConfirmEnabled) { if (HasUnsavedChanges && DiscardConfirmEnabled) {
var dialog = new DiscardEditConfirmDialog { DataContext = new DiscardEditConfirmViewModel() }; var dialog = new DiscardEditConfirmDialog { DataContext = new DiscardEditConfirmViewModel() };
if (!await dialog.ShowDialog<bool>(this)) { if (!await ModalWindow.ShowAsync<bool>(this, dialog)) {
return; return;
} }
} }
@@ -16,9 +16,9 @@
<TextBlock Grid.Row="0" Text="{Binding Message}" TextWrapping="Wrap" VerticalAlignment="Top"/> <TextBlock Grid.Row="0" Text="{Binding Message}" TextWrapping="Wrap" VerticalAlignment="Top"/>
<StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8"> <StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
<Button Content="{x:Static lang:Resources.Common_Delete}" Click="OnConfirmClick" <Button Content="{x:Static lang:Resources.Common_Delete}" Click="OnConfirmClick"
MinWidth="90" IsDefault="True"/> MinWidth="90"/>
<Button Content="{x:Static lang:Resources.Common_Cancel}" Click="OnCancelClick" <Button Content="{x:Static lang:Resources.Common_Cancel}" Click="OnCancelClick"
MinWidth="90" IsCancel="True"/> MinWidth="90" IsDefault="True" IsCancel="True"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
</Window> </Window>
@@ -16,9 +16,9 @@
<TextBlock Grid.Row="0" Text="{Binding Message}" TextWrapping="Wrap" VerticalAlignment="Top"/> <TextBlock Grid.Row="0" Text="{Binding Message}" TextWrapping="Wrap" VerticalAlignment="Top"/>
<StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8"> <StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
<Button Content="{x:Static lang:Resources.Common_Delete}" Click="OnConfirmClick" <Button Content="{x:Static lang:Resources.Common_Delete}" Click="OnConfirmClick"
MinWidth="90" IsDefault="True"/> MinWidth="90"/>
<Button Content="{x:Static lang:Resources.Common_Cancel}" Click="OnCancelClick" <Button Content="{x:Static lang:Resources.Common_Cancel}" Click="OnCancelClick"
MinWidth="90" IsCancel="True"/> MinWidth="90" IsDefault="True" IsCancel="True"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
</Window> </Window>
@@ -16,9 +16,9 @@
<TextBlock Grid.Row="0" Text="{Binding Message}" TextWrapping="Wrap" VerticalAlignment="Top"/> <TextBlock Grid.Row="0" Text="{Binding Message}" TextWrapping="Wrap" VerticalAlignment="Top"/>
<StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8"> <StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
<Button Content="{x:Static lang:Resources.Common_Delete}" Click="OnConfirmClick" <Button Content="{x:Static lang:Resources.Common_Delete}" Click="OnConfirmClick"
MinWidth="90" IsDefault="True"/> MinWidth="90"/>
<Button Content="{x:Static lang:Resources.Common_Cancel}" Click="OnCancelClick" <Button Content="{x:Static lang:Resources.Common_Cancel}" Click="OnCancelClick"
MinWidth="90" IsCancel="True"/> MinWidth="90" IsDefault="True" IsCancel="True"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
</Window> </Window>
@@ -16,9 +16,9 @@
<TextBlock Grid.Row="0" Text="{Binding Message}" TextWrapping="Wrap" VerticalAlignment="Top"/> <TextBlock Grid.Row="0" Text="{Binding Message}" TextWrapping="Wrap" VerticalAlignment="Top"/>
<StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8"> <StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
<Button Content="{x:Static lang:Resources.Common_Ok}" Click="OnConfirmClick" <Button Content="{x:Static lang:Resources.Common_Ok}" Click="OnConfirmClick"
MinWidth="90" IsDefault="True"/> MinWidth="90"/>
<Button Content="{x:Static lang:Resources.Common_Cancel}" Click="OnCancelClick" <Button Content="{x:Static lang:Resources.Common_Cancel}" Click="OnCancelClick"
MinWidth="90" IsCancel="True"/> MinWidth="90" IsDefault="True" IsCancel="True"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
</Window> </Window>
+2 -2
View File
@@ -33,7 +33,7 @@ public partial class MainWindow : Window {
var dialog = new SettingsDialog { var dialog = new SettingsDialog {
DataContext = new SettingsDialogViewModel(App.Services.Config, App.StartupLanguage), DataContext = new SettingsDialogViewModel(App.Services.Config, App.StartupLanguage),
}; };
await dialog.ShowDialog(this); await ModalWindow.ShowAsync(this, dialog);
} }
/// <summary> /// <summary>
@@ -43,6 +43,6 @@ public partial class MainWindow : Window {
var dialog = new AboutDialog { var dialog = new AboutDialog {
DataContext = new AboutDialogViewModel(), DataContext = new AboutDialogViewModel(),
}; };
await dialog.ShowDialog(this); await ModalWindow.ShowAsync(this, dialog);
} }
} }
+33
View File
@@ -0,0 +1,33 @@
using Avalonia.Controls;
namespace YKanBan.Views;
/// <summary>
/// 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.
/// </summary>
internal static class ModalWindow {
/// <summary>
/// Shows a modal window that returns a result.
/// </summary>
/// <param name="owner">The owning window.</param>
/// <param name="dialog">The modal window to show.</param>
/// <typeparam name="TResult">The dialog result type.</typeparam>
/// <returns>A task completing with the dialog result.</returns>
public static Task<TResult> ShowAsync<TResult>(Window owner, Window dialog) {
dialog.FlowDirection = App.UiFlowDirection;
return dialog.ShowDialog<TResult>(owner);
}
/// <summary>
/// Shows a modal window that has no result.
/// </summary>
/// <param name="owner">The owning window.</param>
/// <param name="dialog">The modal window to show.</param>
/// <returns>A task completing when the dialog closes.</returns>
public static Task ShowAsync(Window owner, Window dialog) {
dialog.FlowDirection = App.UiFlowDirection;
return dialog.ShowDialog(owner);
}
}
+2 -2
View File
@@ -39,7 +39,7 @@ public partial class TagsView : UserControl {
var dialog = new TagEditorDialog { var dialog = new TagEditorDialog {
DataContext = new TagEditorViewModel(tags.Repository, item?.Tag), DataContext = new TagEditorViewModel(tags.Repository, item?.Tag),
}; };
if (await dialog.ShowDialog<bool>(owner)) { if (await ModalWindow.ShowAsync<bool>(owner, dialog)) {
tags.RefreshAfterChange(); tags.RefreshAfterChange();
} }
} }
@@ -57,7 +57,7 @@ public partial class TagsView : UserControl {
var dialog = new DeleteTagConfirmDialog { var dialog = new DeleteTagConfirmDialog {
DataContext = new DeleteTagConfirmViewModel(item.Name, item.UsageCount), DataContext = new DeleteTagConfirmViewModel(item.Name, item.UsageCount),
}; };
confirmed = await dialog.ShowDialog<bool>(owner); confirmed = await ModalWindow.ShowAsync<bool>(owner, dialog);
} }
if (!confirmed) { if (!confirmed) {
+1 -1
View File
@@ -66,7 +66,7 @@ public partial class WorkspaceView : UserControl {
var dialog = new ExportDoneDialog { var dialog = new ExportDoneDialog {
DataContext = new ExportDoneViewModel(file.TryGetLocalPath() ?? file.Name), DataContext = new ExportDoneViewModel(file.TryGetLocalPath() ?? file.Name),
}; };
await dialog.ShowDialog(owner); await ModalWindow.ShowAsync(owner, dialog);
} }
} }
} }