diff --git a/LehrerApp.Core/Services/ExamGradingSettingsService.cs b/LehrerApp.Core/Services/ExamGradingSettingsService.cs
new file mode 100644
index 0000000..b2e1361
--- /dev/null
+++ b/LehrerApp.Core/Services/ExamGradingSettingsService.cs
@@ -0,0 +1,44 @@
+using System.Text.Json;
+
+namespace LehrerApp.Core.Services;
+
+/// Reihenfolge, in der Enter bei der Punkteeingabe zum nächsten Feld springt.
+public enum GradingEntryOrder
+{
+ /// Eine Arbeit komplett, dann die nächste (Enter läuft die Zeile entlang).
+ ByStudent,
+ /// Eine Aufgabe für alle, dann die nächste (Enter läuft die Spalte hinunter).
+ ByTask,
+}
+
+public sealed class ExamGradingSettings
+{
+ public GradingEntryOrder EntryOrder { get; set; } = GradingEntryOrder.ByStudent;
+}
+
+/// Merkt sich den zuletzt gewählten Korrekturmodus der Punkteeingabe lokal.
+public sealed class ExamGradingSettingsService
+{
+ private readonly string _configPath;
+
+ public ExamGradingSettingsService(string appDataPath) =>
+ _configPath = Path.Combine(appDataPath, "examgradingsettings.json");
+
+ public ExamGradingSettings Load()
+ {
+ try
+ {
+ if (File.Exists(_configPath))
+ return JsonSerializer.Deserialize(File.ReadAllText(_configPath))
+ ?? new ExamGradingSettings();
+ }
+ catch { /* beschädigte Konfiguration -> Standard */ }
+ return new ExamGradingSettings();
+ }
+
+ public void Save(ExamGradingSettings settings)
+ {
+ try { File.WriteAllText(_configPath, JsonSerializer.Serialize(settings)); }
+ catch { /* Einstellung ist Komfort — Fehler beim Schreiben nicht an die UI durchreichen */ }
+ }
+}
diff --git a/LehrerApp.Core/Services/ExamScoringService.cs b/LehrerApp.Core/Services/ExamScoringService.cs
index 7951db7..725a237 100644
--- a/LehrerApp.Core/Services/ExamScoringService.cs
+++ b/LehrerApp.Core/Services/ExamScoringService.cs
@@ -10,6 +10,36 @@ public static class ExamScoringService
public static double Maximum(IEnumerable tasks, ExamVariant variant) =>
tasks.Where(t => AppliesTo(t, variant) && !t.IsBonus).Sum(t => t.MaxPoints);
+ ///
+ /// Anzeigenummern der Aufgaben in der gespeicherten Reihenfolge. Bonusaufgaben werden auf
+ /// dem Aufgabenblatt üblicherweise nicht nummeriert — würden sie mitgezählt, passten alle
+ /// Nummern hinter einer Bonusaufgabe nicht mehr zur gedruckten Arbeit. Sie heißen daher
+ /// "Bonus" (bei mehreren "Bonus 1", "Bonus 2", …). bleibt davon
+ /// unberührt die reine Positions-/Sortiernummer.
+ ///
+ public static List TaskNumbers(IEnumerable isBonus)
+ {
+ var flags = isBonus.ToList();
+ var multipleBonus = flags.Count(b => b) > 1;
+ int nr = 0, bonus = 0;
+ return flags.Select(b => b
+ ? multipleBonus ? $"Bonus {++bonus}" : "Bonus"
+ : (++nr).ToString())
+ .ToList();
+ }
+
+ /// Beschriftung je Aufgabe ("Aufgabe 3", "3. Titel", "Bonus", "Bonus: Titel").
+ public static List TaskLabels(IReadOnlyList orderedTasks) =>
+ TaskNumbers(orderedTasks.Select(t => t.IsBonus))
+ .Select((number, i) =>
+ {
+ var title = orderedTasks[i].Title;
+ if (orderedTasks[i].IsBonus)
+ return string.IsNullOrWhiteSpace(title) ? number : $"{number}: {title}";
+ return string.IsNullOrWhiteSpace(title) ? $"Aufgabe {number}" : $"{number}. {title}";
+ })
+ .ToList();
+
public static double Achieved(IReadOnlyList tasks, IReadOnlyList points,
ExamVariant variant) => tasks.Select((task, index) =>
AppliesTo(task, variant) && index < points.Count ? points[index] : 0).Sum();
diff --git a/LehrerApp.Desktop.Tests/ExamGradingDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/ExamGradingDialogViewModelTests.cs
index a36039c..0d1cac2 100644
--- a/LehrerApp.Desktop.Tests/ExamGradingDialogViewModelTests.cs
+++ b/LehrerApp.Desktop.Tests/ExamGradingDialogViewModelTests.cs
@@ -62,4 +62,75 @@ public sealed class ExamGradingDialogViewModelTests
Assert.True(vm.Rows[0].Absent);
}
+
+ private static ExamGradingDialogViewModel CreateGrid(int studentCount, params ExamTask[] tasks)
+ {
+ var group = new LearningGroup { Name = "Q1 Chemie" };
+ var students = Enumerable.Range(0, studentCount)
+ .Select(i => new Student { FirstName = "S", LastName = $"N{i}" }).ToList();
+ var memberships = new FakeMemberships(
+ students.Select(s => new GroupMembership { GroupId = group.Id, StudentId = s.Id }).ToList());
+ var exam = new Exam
+ {
+ GroupId = group.Id, Title = "Klausur 1", Tasks = [.. tasks],
+ GradingKey = GradingService.DefaultKey1To6(),
+ };
+ return new ExamGradingDialogViewModel(new FakeResults(), new FakeStudents(students), memberships,
+ new GradingService(), exam, group.Id);
+ }
+
+ private static ExamTask[] ThreeTasks() =>
+ [
+ new() { Nr = 1, MaxPoints = 5 },
+ new() { Nr = 2, MaxPoints = 5 },
+ new() { Nr = 3, MaxPoints = 5 },
+ ];
+
+ [Fact]
+ public void Enter_SchülerFürSchüler_LäuftDieZeileEntlangUndDannInDieNächste()
+ {
+ var vm = CreateGrid(2, ThreeTasks());
+
+ Assert.Equal((0, 1), vm.NextCell(0, 0, GradingMove.Next));
+ Assert.Equal((1, 0), vm.NextCell(0, 2, GradingMove.Next));
+ Assert.Null(vm.NextCell(1, 2, GradingMove.Next));
+ Assert.Equal((0, 2), vm.NextCell(1, 0, GradingMove.Previous));
+ }
+
+ [Fact]
+ public void Enter_AufgabeFürAufgabe_LäuftDieSpalteHinunterUndDannZurNächstenAufgabe()
+ {
+ var vm = CreateGrid(2, ThreeTasks());
+ vm.EntryOrderName = vm.EntryOrderOptions[1];
+
+ Assert.Equal((1, 0), vm.NextCell(0, 0, GradingMove.Next));
+ Assert.Equal((0, 1), vm.NextCell(1, 0, GradingMove.Next));
+ Assert.Equal((1, 0), vm.NextCell(0, 1, GradingMove.Previous));
+ }
+
+ [Fact]
+ public void Navigation_ÜberspringtAbwesendeUndAndereFassung()
+ {
+ var vm = CreateGrid(3,
+ new ExamTask { Nr = 1, MaxPoints = 5 },
+ new ExamTask { Nr = 2, MaxPoints = 5, Variant = ExamVariant.B },
+ new ExamTask { Nr = 3, MaxPoints = 5 });
+ vm.Rows[1].Absent = true;
+
+ Assert.Equal((0, 2), vm.NextCell(0, 0, GradingMove.Next));
+ Assert.Equal((0, 2), vm.NextCell(0, 0, GradingMove.Right));
+ Assert.Equal((2, 0), vm.NextCell(0, 0, GradingMove.Down));
+ Assert.Null(vm.NextCell(0, 0, GradingMove.Up));
+ }
+
+ [Fact]
+ public void Spaltenüberschriften_ZählenBonusaufgabenNichtMit()
+ {
+ var vm = CreateGrid(1,
+ new ExamTask { Nr = 1, MaxPoints = 5 },
+ new ExamTask { Nr = 2, MaxPoints = 2, IsBonus = true },
+ new ExamTask { Nr = 3, MaxPoints = 5, Title = "Redox" });
+
+ Assert.Equal(["Aufgabe 1", "★ Bonus", "2. Redox"], vm.TaskHeaders);
+ }
}
diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs
index 2e1cd74..c8d026f 100644
--- a/LehrerApp.Desktop/AppBootstrapper.cs
+++ b/LehrerApp.Desktop/AppBootstrapper.cs
@@ -208,6 +208,7 @@ public static class AppBootstrapper
services.AddSingleton(_ => new DashboardSettingsService(appData));
services.AddSingleton(_ => new PatternScoreSettingsService(appData));
services.AddSingleton(_ => new WindowSettingsService(appData));
+ services.AddSingleton(_ => new ExamGradingSettingsService(appData));
services.AddSingleton(_ => new AppearanceSettingsService(appData));
services.AddSingleton();
diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs
index 5f0f7d8..50d5181 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs
@@ -226,6 +226,7 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject
{
TaskAnalysis.Clear();
var tasks = _exam.Tasks.OrderBy(t => t.Nr).ToList();
+ var labels = ExamScoringService.TaskLabels(tasks);
for (var i = 0; i < tasks.Count; i++)
{
var task = tasks[i];
@@ -234,9 +235,8 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject
var avgPercent = task.MaxPoints <= 0 || achieved.Count == 0
? 0
: achieved.Average() / task.MaxPoints * 100.0;
- var suffix = (task.Variant is null ? "" : $" ({task.Variant})")
- + (task.IsBonus ? " · Bonus" : "");
- TaskAnalysis.Add(new TaskAnalysisItem(task.Nr, (task.Title ?? "") + suffix, avgPercent));
+ var suffix = task.Variant is null ? "" : $" ({task.Variant})";
+ TaskAnalysis.Add(new TaskAnalysisItem(labels[i] + suffix, avgPercent));
}
}
@@ -316,9 +316,9 @@ public class TaskAnalysisItem
public bool IsWeak { get; }
public double BarWidth { get; }
- public TaskAnalysisItem(int nr, string? title, double avgPercent)
+ public TaskAnalysisItem(string label, double avgPercent)
{
- Label = string.IsNullOrWhiteSpace(title) ? $"Aufgabe {nr}" : $"{nr}. {title}";
+ Label = label;
AvgPercent = avgPercent;
AvgPercentDisplay = $"{avgPercent.ToString("0.#", CultureInfo.InvariantCulture)} %";
IsWeak = avgPercent < 50.0;
diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs
index f452d80..ecc34e8 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs
@@ -10,11 +10,16 @@ namespace LehrerApp.Desktop.ViewModels.Groups;
// ── Punkteeingabe & Korrektur (1.4) ──────────────────────────────────────────
+/// Tastatur-Bewegung im Punkteraster: Next/Previous folgen dem gewählten Korrekturmodus
+/// (Enter / Umschalt+Enter), die übrigen bewegen sich räumlich (Pfeiltasten).
+public enum GradingMove { Next, Previous, Up, Down, Left, Right }
+
public partial class ExamGradingDialogViewModel : ObservableObject
{
private readonly IExamResultRepository _results;
private readonly GradingService _grading;
private readonly Exam _exam;
+ private readonly ExamGradingSettingsService? _settings;
public string ExamTitle => _exam.Title;
public string ExamDateLabel => _exam.Date.ToString("dd.MM.yyyy");
@@ -22,13 +27,28 @@ public partial class ExamGradingDialogViewModel : ObservableObject
public List Tasks { get; }
public bool HasVariants => Tasks.Any(t => t.Variant is not null);
+ /// Spaltenüberschriften — Bonusaufgaben zählen bei der Nummerierung nicht mit.
+ public List TaskHeaders { get; }
+
public ObservableCollection Rows { get; } = [];
+ public string[] EntryOrderOptions => ["Schüler für Schüler", "Aufgabe für Aufgabe"];
+ [ObservableProperty] private string _entryOrderName;
+ public GradingEntryOrder EntryOrder =>
+ EntryOrderName == EntryOrderOptions[1] ? GradingEntryOrder.ByTask : GradingEntryOrder.ByStudent;
+
public ExamGradingDialogViewModel(IExamResultRepository results, IStudentRepository students,
- IGroupMembershipRepository memberships, GradingService grading, Exam exam, Guid groupId)
+ IGroupMembershipRepository memberships, GradingService grading, Exam exam, Guid groupId,
+ ExamGradingSettingsService? settings = null)
{
- _results = results; _grading = grading; _exam = exam;
+ _results = results; _grading = grading; _exam = exam; _settings = settings;
Tasks = exam.Tasks.OrderBy(t => t.Nr).ToList();
+ TaskHeaders = ExamScoringService.TaskLabels(Tasks)
+ .Select((label, i) => (Tasks[i].IsBonus ? "★ " : "") + label
+ + (Tasks[i].Variant is null ? "" : $" ({Tasks[i].Variant})"))
+ .ToList();
+ _entryOrderName = EntryOrderOptions[
+ settings?.Load().EntryOrder == GradingEntryOrder.ByTask ? 1 : 0];
var enrolled = students.GetByGroup(groupId);
var membershipList = memberships.GetByGroup(groupId);
@@ -53,6 +73,56 @@ public partial class ExamGradingDialogViewModel : ObservableObject
private void SaveRow(ExamResultRow row) => _results.Save(row.ToModel(_exam.Id));
+ partial void OnEntryOrderNameChanged(string value) =>
+ _settings?.Save(new ExamGradingSettings { EntryOrder = EntryOrder });
+
+ /// Nur Felder, in die tatsächlich etwas eingetragen werden kann: abwesende Schüler und
+ /// Aufgaben der jeweils anderen Fassung werden beim Weiterspringen übersprungen.
+ public bool IsEditable(int rowIdx, int taskIdx) =>
+ rowIdx >= 0 && rowIdx < Rows.Count && taskIdx >= 0 && taskIdx < Tasks.Count
+ && !Rows[rowIdx].Absent && Rows[rowIdx].Cells[taskIdx].IsApplicable;
+
+ /// Erstes Feld in Korrekturreihenfolge — dort steht der Cursor beim Öffnen des Dialogs.
+ public (int Row, int Task)? FirstCell() =>
+ IsEditable(0, 0) ? (0, 0) : NextCell(0, 0, GradingMove.Next);
+
+ /// Zielfeld für eine Tastatur-Bewegung oder null am Rand. Enter läuft je nach Modus
+ /// zeilen- oder spaltenweise und am Ende einer Zeile/Spalte in die nächste weiter;
+ /// Pfeiltasten bewegen sich nur innerhalb der Zeile bzw. Spalte und halten am Rand an.
+ public (int Row, int Task)? NextCell(int rowIdx, int taskIdx, GradingMove move)
+ {
+ if (move is GradingMove.Next or GradingMove.Previous)
+ {
+ var step = move == GradingMove.Next ? 1 : -1;
+ var byTask = EntryOrder == GradingEntryOrder.ByTask;
+ var total = Rows.Count * Tasks.Count;
+ var pos = byTask ? taskIdx * Rows.Count + rowIdx : rowIdx * Tasks.Count + taskIdx;
+ for (pos += step; pos >= 0 && pos < total; pos += step)
+ {
+ var (r, t) = byTask
+ ? (pos % Rows.Count, pos / Rows.Count)
+ : (pos / Tasks.Count, pos % Tasks.Count);
+ if (IsEditable(r, t)) return (r, t);
+ }
+ return null;
+ }
+
+ var (dRow, dTask) = move switch
+ {
+ GradingMove.Up => (-1, 0),
+ GradingMove.Down => (1, 0),
+ GradingMove.Left => (0, -1),
+ _ => (0, 1),
+ };
+ for (int r = rowIdx + dRow, t = taskIdx + dTask;
+ r >= 0 && r < Rows.Count && t >= 0 && t < Tasks.Count;
+ r += dRow, t += dTask)
+ {
+ if (IsEditable(r, t)) return (r, t);
+ }
+ return null;
+ }
+
/// Rest als abwesend markieren (Nutzerwunsch): Schüler, die nie nachschreiben, blockieren
/// sonst dauerhaft den live abgeleiteten Korrekturstatus (ExamPriorityService), weil niemand
/// eine leere Zeile anfasst, für die es nichts einzutragen gibt. Rührt bewusst nur unberührte
diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs
index 1896730..e85e7a2 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs
@@ -229,10 +229,13 @@ public partial class ExamDialogViewModel : ObservableObject
private void RenumberTasks()
{
for (var i = 0; i < Tasks.Count; i++) Tasks[i].Nr = i + 1;
+ var numbers = ExamScoringService.TaskNumbers(Tasks.Select(t => t.IsBonus));
+ for (var i = 0; i < Tasks.Count; i++) Tasks[i].DisplayNr = Tasks[i].IsBonus ? "★" : numbers[i];
}
private void RecomputeTotals()
{
+ RenumberTasks();
TotalPoints = Tasks.Where(t => !t.IsBonus && t.VariantName != "B").Sum(t => t.MaxPoints);
OnPropertyChanged(nameof(TotalPointsWarning));
OnPropertyChanged(nameof(TotalPointsDisplay));
@@ -422,6 +425,8 @@ public partial class GradingKeyEntryEditItem : ObservableObject
public partial class ExamTaskEditItem : ObservableObject
{
[ObservableProperty] private int _nr;
+ /// Angezeigte Nummer — Bonusaufgaben zählen nicht mit (siehe ExamScoringService.TaskNumbers).
+ [ObservableProperty] private string _displayNr = "";
[ObservableProperty] private string _title = "";
[ObservableProperty] private double _maxPoints;
[ObservableProperty] private double _weight = 1.0;
diff --git a/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml.cs b/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml.cs
index d4af1cd..d26f7d9 100644
--- a/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml.cs
+++ b/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml.cs
@@ -38,7 +38,8 @@ public partial class ExamsOverviewView : UserControl
App.Services.GetRequiredService(),
App.Services.GetRequiredService(),
App.Services.GetRequiredService(),
- exam, group.Id);
+ exam, group.Id,
+ App.Services.GetRequiredService());
var dialog = new ExamGradingDialog { DataContext = dialogVm };
var owner = TopLevel.GetTopLevel(this) as Window;
diff --git a/LehrerApp.Desktop/Views/Groups/ExamDialog.axaml b/LehrerApp.Desktop/Views/Groups/ExamDialog.axaml
index 7d01a17..ec23978 100644
--- a/LehrerApp.Desktop/Views/Groups/ExamDialog.axaml
+++ b/LehrerApp.Desktop/Views/Groups/ExamDialog.axaml
@@ -105,7 +105,7 @@
BorderThickness="1" CornerRadius="4" Padding="8" Margin="0,0,0,6">
-
+
diff --git a/LehrerApp.Desktop/Views/Groups/ExamGradingDialog.axaml b/LehrerApp.Desktop/Views/Groups/ExamGradingDialog.axaml
index e359db8..84732ab 100644
--- a/LehrerApp.Desktop/Views/Groups/ExamGradingDialog.axaml
+++ b/LehrerApp.Desktop/Views/Groups/ExamGradingDialog.axaml
@@ -18,6 +18,10 @@
+
+
InitializeComponent();
+ /// Aufgaben-Spalten in Aufgabenreihenfolge — nicht über einen festen Index-Offset in
+ /// grid.Columns auflösen, weil bei A/B-Arbeiten noch die "Fassung"-Spalte davor steht.
+ private readonly List _taskColumns = [];
+
+ public ExamGradingDialog()
+ {
+ InitializeComponent();
+ // Tunnel statt KeyDown am TextBox: so kommt die Navigation vor TextBox und DataGrid an
+ // die Reihe. Vorher hat die DataGrid-eigene Tastenbehandlung Enter/Pfeile teilweise selbst
+ // ausgewertet (Sprung in die erste Zeile, Zellauswahl ohne offenes Eingabefeld).
+ AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel);
+ }
+
+ private DataGrid? Grid => this.FindControl("ResultGrid");
protected override void OnOpened(EventArgs e)
{
base.OnOpened(e);
- if (DataContext is ExamGradingDialogViewModel vm) BuildColumns(vm);
+ if (DataContext is not ExamGradingDialogViewModel vm || Grid is not { } grid) return;
+ BuildColumns(vm, grid);
+ grid.CurrentCellChanged += (_, _) => OnGridCurrentCellChanged(vm, grid);
+ if (vm.FirstCell() is { } first) FocusPointsCell(vm, first.Row, first.Task);
}
- private void BuildColumns(ExamGradingDialogViewModel vm)
+ private void BuildColumns(ExamGradingDialogViewModel vm, DataGrid grid)
{
- var grid = this.FindControl("ResultGrid");
- if (grid is null) return;
-
grid.Columns.Clear();
+ _taskColumns.Clear();
grid.Columns.Add(new DataGridTextColumn
{
@@ -45,26 +62,26 @@ public partial class ExamGradingDialog : Window
grid.Columns.Add(new DataGridTemplateColumn
{
Header = "Fassung", Width = new DataGridLength(82, DataGridLengthUnitType.Pixel),
- CellTemplate = new FuncDataTemplate((row, _) =>
+ CellTemplate = new FuncDataTemplate((_, _) =>
{
- if (row is null) return new TextBlock();
- var choice = new ComboBox { ItemsSource = row.VariantOptions, SelectedItem = row.VariantName };
- choice.SelectionChanged += (_, _) => row.VariantName = choice.SelectedItem as string ?? "A";
+ var choice = new ComboBox();
+ choice.Bind(ItemsControl.ItemsSourceProperty, new Binding(nameof(ExamResultRow.VariantOptions)));
+ choice.Bind(SelectingItemsControl.SelectedItemProperty,
+ new Binding(nameof(ExamResultRow.VariantName)) { Mode = BindingMode.TwoWay });
return choice;
}),
});
for (var i = 0; i < vm.Tasks.Count; i++)
{
- var task = vm.Tasks[i];
- grid.Columns.Add(new DataGridTemplateColumn
+ var column = new DataGridTemplateColumn
{
- Header = (string.IsNullOrWhiteSpace(task.Title) ? $"Aufg. {task.Nr}" : $"{task.Nr}. {task.Title}")
- + (task.Variant is null ? "" : $" ({task.Variant})")
- + (task.IsBonus ? " ★ Bonus" : ""),
+ Header = vm.TaskHeaders[i],
Width = new DataGridLength(1, DataGridLengthUnitType.Star),
- CellTemplate = BuildPointsCellTemplate(i, grid, vm.Tasks.Count),
- });
+ CellTemplate = BuildPointsCellTemplate(i),
+ };
+ _taskColumns.Add(column);
+ grid.Columns.Add(column);
}
grid.Columns.Add(new DataGridTextColumn
@@ -80,47 +97,76 @@ public partial class ExamGradingDialog : Window
grid.Columns.Add(new DataGridTemplateColumn
{
Header = "Abw.", Width = new DataGridLength(50, DataGridLengthUnitType.Pixel),
- CellTemplate = BuildAbsentCellTemplate(),
+ CellTemplate = new FuncDataTemplate((_, _) =>
+ {
+ var cb = new CheckBox { HorizontalAlignment = HorizontalAlignment.Center };
+ cb.Bind(ToggleButton.IsCheckedProperty,
+ new Binding(nameof(ExamResultRow.Absent)) { Mode = BindingMode.TwoWay });
+ return cb;
+ }),
});
grid.Columns.Add(new DataGridTemplateColumn
{
Header = "Kommentar", Width = new DataGridLength(180, DataGridLengthUnitType.Pixel),
- CellTemplate = BuildCommentCellTemplate(grid),
+ CellTemplate = new FuncDataTemplate((_, _) =>
+ {
+ var tb = new TextBox
+ {
+ Name = CommentCellName,
+ BorderThickness = new Thickness(0),
+ Background = Brushes.Transparent,
+ Padding = new Thickness(6, 4),
+ };
+ tb.Bind(TextBox.TextProperty, new Binding(nameof(ExamResultRow.Comment))
+ { Mode = BindingMode.TwoWay, UpdateSourceTrigger = UpdateSourceTrigger.LostFocus });
+ return tb;
+ }),
});
}
- private IDataTemplate BuildPointsCellTemplate(int taskIndex, DataGrid grid, int taskCount)
+ /// Die Zelle hängt sich bei jedem DataContext-Wechsel neu an die passende PointsCell, statt
+ /// die Zeile beim Erzeugen im Closure festzuhalten: Das DataGrid recycelt Zeilen beim
+ /// Scrollen, ein festgehaltener Verweis würde dann Punkte in die Zeile eines anderen
+ /// Schülers schreiben.
+ private static IDataTemplate BuildPointsCellTemplate(int taskIndex)
{
- var cellName = $"Pts_{taskIndex}";
- return new FuncDataTemplate((row, _) =>
+ return new FuncDataTemplate((_, _) =>
{
- 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),
+ Name = PointsCellName,
+ Tag = taskIndex,
BorderThickness = new Thickness(0),
Background = Brushes.Transparent,
Padding = new Thickness(6, 4),
HorizontalContentAlignment = HorizontalAlignment.Center,
- IsEnabled = cell.IsApplicable,
};
- ToolTip.SetTip(tb, $"max. {FormatPoints(cell.MaxPoints)} Punkte");
- UpdateInvalidVisual(tb, cell.IsInvalid);
- cell.PropertyChanged += (_, pe) =>
+ PointsCell? cell = null;
+ void OnCellPropertyChanged(object? _, PropertyChangedEventArgs pe)
{
- if (pe.PropertyName == nameof(PointsCell.IsInvalid))
- UpdateInvalidVisual(tb, cell.IsInvalid);
- if (pe.PropertyName == nameof(PointsCell.IsApplicable))
- tb.IsEnabled = cell.IsApplicable;
- };
+ if (cell is null) return;
+ if (pe.PropertyName == nameof(PointsCell.IsInvalid)) UpdateInvalidVisual(tb, cell.IsInvalid);
+ if (pe.PropertyName == nameof(PointsCell.IsApplicable)) tb.IsEnabled = cell.IsApplicable;
+ }
- tb.LostFocus += (_, _) => CommitPointsCell(tb, cell);
- tb.KeyDown += (_, e) => HandlePointsCellKeyDown(e, grid, row, taskIndex, taskCount);
+ tb.DataContextChanged += (_, _) =>
+ {
+ if (cell is not null) cell.PropertyChanged -= OnCellPropertyChanged;
+ cell = (tb.DataContext as ExamResultRow)?.Cells.ElementAtOrDefault(taskIndex);
+ if (cell is null)
+ {
+ tb.Text = "";
+ tb.IsEnabled = false;
+ return;
+ }
+ cell.PropertyChanged += OnCellPropertyChanged;
+ tb.Text = FormatPoints(cell.Value);
+ tb.IsEnabled = cell.IsApplicable;
+ ToolTip.SetTip(tb, $"max. {FormatPoints(cell.MaxPoints)} Punkte");
+ UpdateInvalidVisual(tb, cell.IsInvalid);
+ };
+ tb.LostFocus += (_, _) => { if (cell is not null) CommitPointsCell(tb, cell); };
return tb;
});
@@ -142,119 +188,87 @@ public partial class ExamGradingDialog : Window
private static string FormatPoints(double? value) =>
value?.ToString("0.##", CultureInfo.InvariantCulture) ?? "";
- private IDataTemplate BuildAbsentCellTemplate()
+ /// Zwei Korrektur-Workflows (Nutzer-Feedback), umschaltbar oben im Dialog: "Schüler für
+ /// Schüler" (Enter läuft die Zeile entlang, am Ende weiter zur nächsten Arbeit) und "Aufgabe
+ /// für Aufgabe" (Enter läuft die Spalte hinunter, am Ende weiter zur nächsten Aufgabe).
+ /// Umschalt+Enter geht in derselben Reihenfolge zurück. Pfeiltasten bewegen sich unabhängig
+ /// vom Modus direkt in ihre Richtung — links/rechts nur, wenn der Cursor am Rand steht oder
+ /// alles markiert ist, damit man innerhalb einer Zahl noch korrigieren kann.
+ private void OnPreviewKeyDown(object? sender, KeyEventArgs e)
{
- return new FuncDataTemplate((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((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 (DataContext is not ExamGradingDialogViewModel vm) return;
+ if ((e.Source as Visual)?.FindAncestorOfType(includeSelf: true) is not { } tb) return;
+ if (tb.DataContext is not ExamResultRow row) return;
+ var rowIdx = vm.Rows.IndexOf(row);
if (rowIdx < 0) return;
- int targetRowIdx;
- int targetTaskIndex;
-
- if (e.Key == Key.Enter)
+ if (tb.Name == PointsCellName && tb.Tag is int taskIdx)
{
- targetTaskIndex = taskIndex + 1;
- targetRowIdx = rowIdx;
- if (targetTaskIndex >= taskCount) { targetTaskIndex = 0; targetRowIdx = rowIdx + 1; }
+ if (ToMove(e, tb) is not { } move) return;
+ e.Handled = true;
+ if (vm.NextCell(rowIdx, taskIdx, move) is { } target)
+ FocusPointsCell(vm, target.Row, target.Task);
}
- else
+ else if (tb.Name == CommentCellName && e.Key is Key.Down or Key.Up or Key.Enter)
{
- 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; }
+ e.Handled = true;
+ var targetIdx = e.Key == Key.Up || e.KeyModifiers.HasFlag(KeyModifiers.Shift) ? rowIdx - 1 : rowIdx + 1;
+ if (targetIdx < 0 || targetIdx >= vm.Rows.Count || Grid is not { } grid) return;
+ FocusCell(grid, vm.Rows[targetIdx], grid.Columns[^1],
+ t => t.Name == CommentCellName);
}
-
- 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)
+ private static GradingMove? ToMove(KeyEventArgs e, TextBox tb)
{
- if (e.Key is not (Key.Down or Key.Up or Key.Enter)) return;
- e.Handled = true;
+ var len = tb.Text?.Length ?? 0;
+ var selStart = Math.Min(tb.SelectionStart, tb.SelectionEnd);
+ var selEnd = Math.Max(tb.SelectionStart, tb.SelectionEnd);
+ var allSelected = selStart == 0 && selEnd == len;
+ var noSelection = selStart == selEnd;
- 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(() =>
+ return e.Key switch
{
- var targetTb = grid.GetVisualDescendants().OfType()
- .FirstOrDefault(t => t.DataContext == targetItem && t.Name == cellName);
- if (targetTb is null) return;
- targetTb.Focus();
- targetTb.SelectAll();
- }, DispatcherPriority.Loaded);
+ Key.Enter when e.KeyModifiers.HasFlag(KeyModifiers.Shift) => GradingMove.Previous,
+ Key.Enter => GradingMove.Next,
+ Key.Up => GradingMove.Up,
+ Key.Down => GradingMove.Down,
+ Key.Left when allSelected || (noSelection && tb.CaretIndex == 0) => GradingMove.Left,
+ Key.Right when allSelected || (noSelection && tb.CaretIndex == len) => GradingMove.Right,
+ _ => null,
+ };
}
- /// 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)
+ /// Navigiert das DataGrid selbst (z. B. nach Klick auf den Schülernamen per Pfeiltaste) in
+ /// eine Aufgabenzelle, wird deren Eingabefeld direkt geöffnet, statt nur die Zelle zu markieren.
+ private void OnGridCurrentCellChanged(ExamGradingDialogViewModel vm, DataGrid grid)
{
- var targetColumn = grid.Columns.ElementAtOrDefault(1 + targetTaskIndex);
- if (targetColumn is null) return;
- grid.ScrollIntoView(targetItem, targetColumn);
+ if (FocusManager?.GetFocusedElement() is TextBox) return;
+ var taskIdx = grid.CurrentColumn is { } col ? _taskColumns.IndexOf(col) : -1;
+ if (taskIdx < 0 || grid.SelectedItem is not ExamResultRow row) return;
+ var rowIdx = vm.Rows.IndexOf(row);
+ if (vm.IsEditable(rowIdx, taskIdx)) FocusPointsCell(vm, rowIdx, taskIdx);
+ }
- var cellName = $"Pts_{targetTaskIndex}";
+ private void FocusPointsCell(ExamGradingDialogViewModel vm, int rowIdx, int taskIdx)
+ {
+ if (Grid is not { } grid || taskIdx >= _taskColumns.Count) return;
+ FocusCell(grid, vm.Rows[rowIdx], _taskColumns[taskIdx],
+ t => t.Name == PointsCellName && t.Tag is int i && i == taskIdx);
+ }
+
+ /// Erst scrollen (auch horizontal), dann nach dem Layout-Durchlauf das TextBox-Element der
+ /// Zielzelle suchen — vorher existiert es bei virtualisierten Zeilen eventuell noch nicht.
+ private static void FocusCell(DataGrid grid, object rowItem, DataGridColumn column, Func match)
+ {
+ grid.ScrollIntoView(rowItem, column);
Dispatcher.UIThread.Post(() =>
{
- var targetTb = grid.GetVisualDescendants().OfType()
- .FirstOrDefault(t => t.DataContext == targetItem && t.Name == cellName);
- if (targetTb is null) return;
- targetTb.Focus();
- targetTb.SelectAll();
+ var target = grid.GetVisualDescendants().OfType()
+ .FirstOrDefault(t => t.DataContext == rowItem && t.IsEffectivelyVisible && match(t));
+ if (target is null) return;
+ target.Focus(NavigationMethod.Tab);
+ target.SelectAll();
}, DispatcherPriority.Loaded);
}
diff --git a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml.cs b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml.cs
index caa96e6..84af6a7 100644
--- a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml.cs
+++ b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml.cs
@@ -281,7 +281,8 @@ public partial class GroupDetailView : UserControl
App.Services.GetRequiredService(),
App.Services.GetRequiredService(),
App.Services.GetRequiredService(),
- exam, vm.Group.Id);
+ exam, vm.Group.Id,
+ App.Services.GetRequiredService());
var dialog = new ExamGradingDialog { DataContext = dialogVm };
var owner = TopLevel.GetTopLevel(this) as Window;
diff --git a/LehrerApp.Tests/ExamScoringServiceTests.cs b/LehrerApp.Tests/ExamScoringServiceTests.cs
index c684d35..3aac91b 100644
--- a/LehrerApp.Tests/ExamScoringServiceTests.cs
+++ b/LehrerApp.Tests/ExamScoringServiceTests.cs
@@ -23,6 +23,27 @@ public class ExamScoringServiceTests
Assert.Equal(20, ExamScoringService.Achieved(tasks, [10, 5, 8, 2], ExamVariant.B));
}
+ [Fact]
+ public void TaskLabels_ZählenBonusaufgabenNichtMit()
+ {
+ var tasks = new List
+ {
+ new() { Nr = 1 },
+ new() { Nr = 2, IsBonus = true },
+ new() { Nr = 3, Title = "Titration" },
+ new() { Nr = 4, IsBonus = true, Title = "Knobelei" },
+ };
+
+ Assert.Equal(["1", "Bonus 1", "2", "Bonus 2"],
+ ExamScoringService.TaskNumbers(tasks.Select(t => t.IsBonus)));
+ Assert.Equal(["Aufgabe 1", "Bonus 1", "2. Titration", "Bonus 2: Knobelei"],
+ ExamScoringService.TaskLabels(tasks));
+ }
+
+ [Fact]
+ public void TaskNumbers_EinzelneBonusaufgabeHeisstNurBonus() =>
+ Assert.Equal(["1", "Bonus", "2"], ExamScoringService.TaskNumbers([false, true, false]));
+
[Theory]
[InlineData(RepeatGradeRule.Better, GradingSystem.Grades1To6, "2", "4", "2")]
[InlineData(RepeatGradeRule.Better, GradingSystem.Points0To15, "8", "11", "11")]
diff --git a/TODO.md b/TODO.md
index 8b99158..c0b860d 100644
--- a/TODO.md
+++ b/TODO.md
@@ -92,6 +92,18 @@ pragmatisch über "Abwesend" bei den übrigen Schülern statt über eine eigene
scrollt dabei automatisch auch horizontal mit) — für "Aufgabe für Aufgabe". Beides bricht
am Tabellenende einfach ab, kein Rundlauf zurück zum Anfang. Direkte Zifferneingabe und
halbe Punkte (Komma oder Punkt) weiterhin erlaubt.
+ **Nachtrag 2026-10 (Nutzer-Feedback: Enter sprang in die erste Zeile, Pfeiltasten öffneten
+ das Eingabefeld nicht):** Der Korrekturmodus ist jetzt oben im Dialog wählbar („Enter
+ springt: Schüler für Schüler / Aufgabe für Aufgabe“) und wird in
+ `examgradingsettings.json` gemerkt. Enter folgt dem Modus, Umschalt+Enter geht zurück;
+ Pfeiltasten bewegen sich immer direkt in ihre Richtung (links/rechts nur am Textrand oder
+ bei voller Markierung). Die Tasten werden per Tunnel-Handler am Fenster abgefangen, bevor
+ DataGrid/TextBox sie selbst auswerten; Ziel ist immer das Eingabefeld, abwesende Schüler
+ und Aufgaben der anderen Fassung werden übersprungen. Beim Öffnen steht der Cursor im
+ ersten Feld. Zellen lesen ihre Zeile aus dem DataContext statt aus einem Closure, damit
+ recycelte DataGrid-Zeilen keine Punkte in die falsche Zeile schreiben.
+ Bonusaufgaben zählen bei der angezeigten Nummerierung nicht mit (Editor, Punkteeingabe,
+ Auswertung: „Bonus“ bzw. „Bonus 1/2“), `ExamTask.Nr` bleibt reine Sortiernummer.
- [x] **1.4.3** Kennzeichnung "Abwesend" (`ExamResult.Absent`) per Checkbox — Note zeigt "abwesend"
statt einer berechneten Note. Ausschluss aus Statistiken über 1.5 umgesetzt.
- [x] **1.4.4** Kommentarfeld pro Schüler (`ExamResult.Comment`).