Files
LehrerApp/LehrerApp.Desktop/Views/Groups/ExamGradingDialog.axaml.cs
adminandClaude Sonnet 5 dbd8777e64 Punkteeingabe: Enter und Pfeiltasten unterstützen beide Korrektur-Workflows
Enter läuft jetzt zeilenweise (Arbeit für Arbeit), Pfeil-Hoch/-Runter spaltenweise
mit Umbruch zur Nachbaraufgabe (Aufgabe für Aufgabe) - beides ohne Moduswechsel,
da sich beide Reihenfolgen ohnehin auf dieselbe Tabelle abbilden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 22:25:53 +02:00

245 lines
9.5 KiB
C#

using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Avalonia.Data;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
using Avalonia.VisualTree;
using LehrerApp.Desktop.ViewModels.Groups;
using System.Collections;
using System.Globalization;
namespace LehrerApp.Desktop.Views.Groups;
public partial class ExamGradingDialog : Window
{
private static readonly IBrush InvalidBackground = new SolidColorBrush(Color.Parse("#FFCDD2"));
public ExamGradingDialog() => InitializeComponent();
protected override void OnOpened(EventArgs e)
{
base.OnOpened(e);
if (DataContext is ExamGradingDialogViewModel vm) BuildColumns(vm);
}
private void BuildColumns(ExamGradingDialogViewModel vm)
{
var grid = this.FindControl<DataGrid>("ResultGrid");
if (grid is null) return;
grid.Columns.Clear();
grid.Columns.Add(new DataGridTextColumn
{
Header = "Schüler",
Binding = new Binding("Name"),
IsReadOnly = true,
Width = new DataGridLength(170, DataGridLengthUnitType.Pixel),
});
for (var i = 0; i < vm.Tasks.Count; i++)
{
var task = vm.Tasks[i];
grid.Columns.Add(new DataGridTemplateColumn
{
Header = string.IsNullOrWhiteSpace(task.Title) ? $"Aufg. {task.Nr}" : $"{task.Nr}. {task.Title}",
Width = new DataGridLength(1, DataGridLengthUnitType.Star),
CellTemplate = BuildPointsCellTemplate(i, grid, vm.Tasks.Count),
});
}
grid.Columns.Add(new DataGridTextColumn
{
Header = "Summe", Binding = new Binding("TotalPointsDisplay"), IsReadOnly = true,
Width = new DataGridLength(70, DataGridLengthUnitType.Pixel),
});
grid.Columns.Add(new DataGridTextColumn
{
Header = "Note", Binding = new Binding("GradeDisplay"), IsReadOnly = true,
Width = new DataGridLength(70, DataGridLengthUnitType.Pixel),
});
grid.Columns.Add(new DataGridTemplateColumn
{
Header = "Abw.", Width = new DataGridLength(50, DataGridLengthUnitType.Pixel),
CellTemplate = BuildAbsentCellTemplate(),
});
grid.Columns.Add(new DataGridTemplateColumn
{
Header = "Kommentar", Width = new DataGridLength(180, DataGridLengthUnitType.Pixel),
CellTemplate = BuildCommentCellTemplate(grid),
});
}
private IDataTemplate BuildPointsCellTemplate(int taskIndex, DataGrid grid, int taskCount)
{
var cellName = $"Pts_{taskIndex}";
return new FuncDataTemplate<ExamResultRow>((row, _) =>
{
if (row is null) return new TextBlock();
var cell = row.Cells.ElementAtOrDefault(taskIndex);
if (cell is null) return new TextBlock();
var tb = new TextBox
{
Name = cellName,
Text = FormatPoints(cell.Value),
BorderThickness = new Thickness(0),
Background = Brushes.Transparent,
Padding = new Thickness(6, 4),
HorizontalContentAlignment = HorizontalAlignment.Center,
};
ToolTip.SetTip(tb, $"max. {FormatPoints(cell.MaxPoints)} Punkte");
UpdateInvalidVisual(tb, cell.IsInvalid);
cell.PropertyChanged += (_, pe) =>
{
if (pe.PropertyName == nameof(PointsCell.IsInvalid))
UpdateInvalidVisual(tb, cell.IsInvalid);
};
tb.LostFocus += (_, _) => CommitPointsCell(tb, cell);
tb.KeyDown += (_, e) => HandlePointsCellKeyDown(e, grid, row, taskIndex, taskCount);
return tb;
});
}
private static void CommitPointsCell(TextBox tb, PointsCell cell)
{
var text = tb.Text?.Trim().Replace(',', '.');
double? parsed = string.IsNullOrEmpty(text)
? null
: double.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var v) ? v : cell.Value;
cell.TrySetValue(parsed);
tb.Text = FormatPoints(cell.Value);
}
private static void UpdateInvalidVisual(TextBox tb, bool invalid) =>
tb.Background = invalid ? InvalidBackground : Brushes.Transparent;
private static string FormatPoints(double? value) =>
value?.ToString("0.##", CultureInfo.InvariantCulture) ?? "";
private IDataTemplate BuildAbsentCellTemplate()
{
return new FuncDataTemplate<ExamResultRow>((row, _) =>
{
if (row is null) return new TextBlock();
var cb = new CheckBox { IsChecked = row.Absent, HorizontalAlignment = HorizontalAlignment.Center };
cb.IsCheckedChanged += (_, _) => row.Absent = cb.IsChecked == true;
return cb;
});
}
private IDataTemplate BuildCommentCellTemplate(DataGrid grid)
{
const string cellName = "Comment";
return new FuncDataTemplate<ExamResultRow>((row, _) =>
{
if (row is null) return new TextBlock();
var tb = new TextBox
{
Name = cellName,
Text = row.Comment,
BorderThickness = new Thickness(0),
Background = Brushes.Transparent,
Padding = new Thickness(6, 4),
};
tb.LostFocus += (_, _) => row.Comment = tb.Text ?? "";
tb.KeyDown += (_, e) => HandleCommentCellKeyDown(e, grid, row, cellName);
return tb;
});
}
/// Zwei Korrektur-Workflows (Nutzer-Feedback): "Arbeit für Arbeit" (ein Schüler komplett,
/// dann der nächste) und "Aufgabe für Aufgabe" (eine Aufgabe für alle Schüler, dann die
/// nächste) — beide bewusst ohne Moduswechsel über zwei verschiedene Tasten, weil sich beide
/// Reihenfolgen ohnehin schon auf die Tabelle abbilden (Zeile vs. Spalte). Enter läuft
/// zeilenweise (am Zeilenende zurück zu Aufgabe 1 der nächsten Zeile), Pfeil-hoch/-runter
/// spaltenweise (am Spaltenende weiter zur Nachbaraufgabe) — beides bricht am Tabellenende
/// einfach ab, kein Rundlauf zurück zum Anfang.
private void HandlePointsCellKeyDown(KeyEventArgs e, DataGrid grid, ExamResultRow row, int taskIndex, int taskCount)
{
if (e.Key is not (Key.Down or Key.Up or Key.Enter)) return;
e.Handled = true;
if (grid.ItemsSource is not IList items) return;
var rowIdx = items.IndexOf(row);
if (rowIdx < 0) return;
int targetRowIdx;
int targetTaskIndex;
if (e.Key == Key.Enter)
{
targetTaskIndex = taskIndex + 1;
targetRowIdx = rowIdx;
if (targetTaskIndex >= taskCount) { targetTaskIndex = 0; targetRowIdx = rowIdx + 1; }
}
else
{
targetTaskIndex = taskIndex;
targetRowIdx = e.Key == Key.Up ? rowIdx - 1 : rowIdx + 1;
if (targetRowIdx < 0) { targetRowIdx = items.Count - 1; targetTaskIndex = taskIndex - 1; }
else if (targetRowIdx >= items.Count) { targetRowIdx = 0; targetTaskIndex = taskIndex + 1; }
}
if (targetTaskIndex < 0 || targetTaskIndex >= taskCount
|| targetRowIdx < 0 || targetRowIdx >= items.Count) return;
FocusPointsCell(grid, items[targetRowIdx]!, targetTaskIndex);
}
/// Enter/Pfeil-Hoch/Pfeil-Runter springen im Kommentarfeld zur gleichen Spalte in der
/// Nachbarzeile (Tab funktioniert bereits über die normale Fokus-Reihenfolge).
private void HandleCommentCellKeyDown(KeyEventArgs e, DataGrid grid, object rowItem, string cellName)
{
if (e.Key is not (Key.Down or Key.Up or Key.Enter)) return;
e.Handled = true;
if (grid.ItemsSource is not IList items) return;
var idx = items.IndexOf(rowItem);
if (idx < 0) return;
var targetIdx = e.Key == Key.Up ? idx - 1 : idx + 1;
if (targetIdx < 0 || targetIdx >= items.Count) return;
var targetItem = items[targetIdx]!;
grid.ScrollIntoView(targetItem, grid.CurrentColumn);
Dispatcher.UIThread.Post(() =>
{
var targetTb = grid.GetVisualDescendants().OfType<TextBox>()
.FirstOrDefault(t => t.DataContext == targetItem && t.Name == cellName);
if (targetTb is null) return;
targetTb.Focus();
targetTb.SelectAll();
}, DispatcherPriority.Loaded);
}
/// Spalte 0 ist "Schüler", danach folgen die Aufgaben-Spalten in Reihenfolge — daher der
/// Offset von 1 beim Auflösen des Spaltenobjekts für ScrollIntoView (muss auch horizontal
/// zur Nachbaraufgabe scrollen, nicht nur vertikal zur Nachbarzeile).
private void FocusPointsCell(DataGrid grid, object targetItem, int targetTaskIndex)
{
var targetColumn = grid.Columns.ElementAtOrDefault(1 + targetTaskIndex);
if (targetColumn is null) return;
grid.ScrollIntoView(targetItem, targetColumn);
var cellName = $"Pts_{targetTaskIndex}";
Dispatcher.UIThread.Post(() =>
{
var targetTb = grid.GetVisualDescendants().OfType<TextBox>()
.FirstOrDefault(t => t.DataContext == targetItem && t.Name == cellName);
if (targetTb is null) return;
targetTb.Focus();
targetTb.SelectAll();
}, DispatcherPriority.Loaded);
}
private void OnClose(object? sender, RoutedEventArgs e) => Close();
}