Show scroll bars at full width, drop the log status bar
- Turn off scroll bar auto-hide for ScrollViewer and DataGrid. - Make the splitter between the two log grids 5 px, in the grid line color. - Remove the log window status bar. The stats are in the entry window. - Show a refused log edit in an alert dialog. - Keep the log grid's rows in an ObservableCollection, so a new contact does not rebuild the list and move the view. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,17 @@
|
|||||||
<FluentTheme />
|
<FluentTheme />
|
||||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
|
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
|
||||||
|
|
||||||
|
<!-- the Fluent scroll bar shrinks to a thin line until the pointer is
|
||||||
|
over it -->
|
||||||
|
<Style Selector="ScrollBar">
|
||||||
|
<Setter Property="AllowAutoHide" Value="False" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ScrollViewer">
|
||||||
|
<Setter Property="AllowAutoHide" Value="False" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="DataGrid">
|
||||||
|
<Setter Property="ScrollViewer.AllowAutoHide" Value="False" />
|
||||||
|
</Style>
|
||||||
<Style Selector="Window">
|
<Style Selector="Window">
|
||||||
<Setter Property="Background" Value="{DynamicResource FormBackground}" />
|
<Setter Property="Background" Value="{DynamicResource FormBackground}" />
|
||||||
<Setter Property="Foreground" Value="{DynamicResource FormForeground}" />
|
<Setter Property="Foreground" Value="{DynamicResource FormForeground}" />
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="Nonemm.App.Dialogs.AlertDialog"
|
||||||
|
Width="380" SizeToContent="Height"
|
||||||
|
WindowStartupLocation="CenterOwner" CanResize="False">
|
||||||
|
<DockPanel Margin="14">
|
||||||
|
<Button DockPanel.Dock="Bottom" Content="OK" HorizontalAlignment="Right" Margin="0,12,0,0"
|
||||||
|
Click="OnClose" IsDefault="True" IsCancel="True" />
|
||||||
|
<TextBlock Name="MessageText" TextWrapping="Wrap" />
|
||||||
|
</DockPanel>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
|
||||||
|
namespace Nonemm.App.Dialogs;
|
||||||
|
|
||||||
|
public sealed partial class AlertDialog : Window
|
||||||
|
{
|
||||||
|
public AlertDialog(string title, string message)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
Title = title;
|
||||||
|
MessageText.Text = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnClose(object? sender, RoutedEventArgs e) => Close();
|
||||||
|
}
|
||||||
@@ -91,8 +91,6 @@ public sealed partial class LogWindow
|
|||||||
}
|
}
|
||||||
else if (next.Count == 0)
|
else if (next.Count == 0)
|
||||||
{
|
{
|
||||||
message = "the log needs at least one column";
|
|
||||||
Refresh();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Store(LogColumns.Store(next));
|
Store(LogColumns.Store(next));
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
x:Class="Nonemm.App.Windows.LogWindow"
|
x:Class="Nonemm.App.Windows.LogWindow"
|
||||||
Title="Log" Width="1000" Height="420">
|
Title="Log" Width="1000" Height="420">
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
<TextBlock DockPanel.Dock="Bottom" Name="SummaryText" Margin="8,4" FontSize="11" Opacity="0.75" />
|
|
||||||
<Grid RowDefinitions="2*,Auto,*">
|
<Grid RowDefinitions="2*,Auto,*">
|
||||||
<Grid.Styles>
|
<Grid.Styles>
|
||||||
<!-- the DataGrid's own font settings do not reach the cells, and the
|
<!-- the DataGrid's own font settings do not reach the cells, and the
|
||||||
@@ -55,7 +54,7 @@
|
|||||||
</Grid.Styles>
|
</Grid.Styles>
|
||||||
<DataGrid Name="Rows" Grid.Row="0" GridLinesVisibility="Horizontal" CanUserSortColumns="True"
|
<DataGrid Name="Rows" Grid.Row="0" GridLinesVisibility="Horizontal" CanUserSortColumns="True"
|
||||||
CanUserReorderColumns="True" FontSize="12" FontFamily="{x:Static theming:Fonts.Mono}" />
|
CanUserReorderColumns="True" FontSize="12" FontFamily="{x:Static theming:Fonts.Mono}" />
|
||||||
<GridSplitter Grid.Row="1" Height="3" ResizeDirection="Rows" />
|
<GridSplitter Grid.Row="1" Height="5" ResizeDirection="Rows" Background="{DynamicResource DataGridGridLinesBrush}" />
|
||||||
<DataGrid Name="Earlier" Grid.Row="2" IsReadOnly="True" GridLinesVisibility="Horizontal"
|
<DataGrid Name="Earlier" Grid.Row="2" IsReadOnly="True" GridLinesVisibility="Horizontal"
|
||||||
CanUserSortColumns="False" HeadersVisibility="None"
|
CanUserSortColumns="False" HeadersVisibility="None"
|
||||||
FontSize="12" FontFamily="{x:Static theming:Fonts.Mono}" />
|
FontSize="12" FontFamily="{x:Static theming:Fonts.Mono}" />
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Collections;
|
using Avalonia.Collections;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Primitives;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
@@ -19,11 +21,10 @@ namespace Nonemm.App.Windows;
|
|||||||
public sealed partial class LogWindow : RefreshableWindow
|
public sealed partial class LogWindow : RefreshableWindow
|
||||||
{
|
{
|
||||||
private readonly AppSession session;
|
private readonly AppSession session;
|
||||||
private string message = "";
|
|
||||||
/// The lines the grid is showing. They are kept and given the contact again
|
/// The lines the grid is showing. They are kept and given the contact again
|
||||||
/// when only the values changed, so that an edit does not rebuild the list
|
/// when only the values changed, so that an edit does not rebuild the list
|
||||||
/// and move the view.
|
/// and move the view.
|
||||||
private List<LogRow> shownRows = [];
|
private ObservableCollection<LogRow> shownRows = [];
|
||||||
/// The contacts the operator picked, by id. The grid is told to show them
|
/// The contacts the operator picked, by id. The grid is told to show them
|
||||||
/// as picked only while this window is in front: a marked line in a window
|
/// as picked only while this window is in front: a marked line in a window
|
||||||
/// behind says nothing about where the keyboard is.
|
/// behind says nothing about where the keyboard is.
|
||||||
@@ -190,33 +191,42 @@ public sealed partial class LogWindow : RefreshableWindow
|
|||||||
{
|
{
|
||||||
Rows.ItemsSource = Array.Empty<LogRow>();
|
Rows.ItemsSource = Array.Empty<LogRow>();
|
||||||
Earlier.ItemsSource = Array.Empty<LogRow>();
|
Earlier.ItemsSource = Array.Empty<LogRow>();
|
||||||
SummaryText.Text = "no contest is open";
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
BuildColumns(session.Logging.Editor);
|
BuildColumns(session.Logging.Editor);
|
||||||
IReadOnlyList<Qso> qsos = session.Logging.Log.Qsos;
|
IReadOnlyList<Qso> qsos = session.Logging.Log.Qsos;
|
||||||
if (SameContacts(qsos))
|
if (Rows.ItemsSource is DataGridCollectionView shown && shown.SourceCollection == shownRows && StartsWithShown(qsos))
|
||||||
{
|
{
|
||||||
for (int at = 0; at < qsos.Count; at++)
|
// an edit, or contacts added at the end: the grid keeps its list, so
|
||||||
|
// the view stays where it is and an open editing box stays open
|
||||||
|
bool atBottom = ScrolledToBottom();
|
||||||
|
for (int at = 0; at < shownRows.Count; at++)
|
||||||
{
|
{
|
||||||
shownRows[at].Show(qsos[at], VerdictFor(qsos[at]));
|
shownRows[at].Show(qsos[at], VerdictFor(qsos[at]));
|
||||||
}
|
}
|
||||||
|
bool grown = qsos.Count > shownRows.Count;
|
||||||
|
for (int at = shownRows.Count; at < qsos.Count; at++)
|
||||||
|
{
|
||||||
|
// the grid puts the row where the sort says
|
||||||
|
shownRows.Add(new LogRow(qsos[at], VerdictFor(qsos[at])));
|
||||||
|
}
|
||||||
Recolour(Rows);
|
Recolour(Rows);
|
||||||
|
// the view follows a new contact only when it was at the foot of
|
||||||
|
// the list; at the top the new row appears without a scroll
|
||||||
|
if (grown && atBottom && Rows.ItemsSource is DataGridCollectionView view && view.Count > 0)
|
||||||
|
{
|
||||||
|
object last = view.GetItemAt(view.Count - 1);
|
||||||
|
Dispatcher.UIThread.Post(() => Rows.ScrollIntoView(last, null), DispatcherPriority.Loaded);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
bool grown = qsos.Count > shownRows.Count;
|
// a contact was deleted or moved: the list is built again, which
|
||||||
|
// puts the view at the top, so it is wound back
|
||||||
Qso? bottom = BottomOfView();
|
Qso? bottom = BottomOfView();
|
||||||
shownRows = qsos.Select(q => new LogRow(q, VerdictFor(q))).ToList();
|
shownRows = new ObservableCollection<LogRow>(qsos.Select(q => new LogRow(q, VerdictFor(q))));
|
||||||
Rows.ItemsSource = Sorted(shownRows);
|
Rows.ItemsSource = Sorted(shownRows);
|
||||||
if (grown && shownRows.Count > 0)
|
ShowAgain(bottom, shownRows);
|
||||||
{
|
|
||||||
Rows.ScrollIntoView(shownRows[^1], null);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ShowAgain(bottom, shownRows);
|
|
||||||
}
|
|
||||||
// the new list comes with nothing picked
|
// the new list comes with nothing picked
|
||||||
if (IsActive)
|
if (IsActive)
|
||||||
{
|
{
|
||||||
@@ -225,20 +235,18 @@ public sealed partial class LogWindow : RefreshableWindow
|
|||||||
}
|
}
|
||||||
FollowQuickEdit();
|
FollowQuickEdit();
|
||||||
ShowEarlier();
|
ShowEarlier();
|
||||||
SummaryText.Text = message.Length > 0 ? message : Summary(session.Logging);
|
|
||||||
message = "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when the log holds the same contacts, in the same order, as the
|
/// True when the grid's lines are the first contacts of the log, in the
|
||||||
/// grid is showing. An edit leaves the list itself alone; logging or
|
/// same order. An edit leaves the list itself alone and logging a contact
|
||||||
/// deleting a contact does not.
|
/// adds one at the end; deleting a contact does neither.
|
||||||
private bool SameContacts(IReadOnlyList<Qso> qsos)
|
private bool StartsWithShown(IReadOnlyList<Qso> qsos)
|
||||||
{
|
{
|
||||||
if (qsos.Count != shownRows.Count)
|
if (qsos.Count < shownRows.Count)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
for (int at = 0; at < qsos.Count; at++)
|
for (int at = 0; at < shownRows.Count; at++)
|
||||||
{
|
{
|
||||||
if (qsos[at].Id != shownRows[at].Qso.Id)
|
if (qsos[at].Id != shownRows[at].Qso.Id)
|
||||||
{
|
{
|
||||||
@@ -262,6 +270,13 @@ public sealed partial class LogWindow : RefreshableWindow
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ScrollBar? VerticalBar =>
|
||||||
|
Rows.GetVisualDescendants().OfType<ScrollBar>()
|
||||||
|
.FirstOrDefault(b => b.Orientation == Avalonia.Layout.Orientation.Vertical);
|
||||||
|
|
||||||
|
/// True when the view is at the foot of the list, or when the list fits.
|
||||||
|
private bool ScrolledToBottom() => VerticalBar is not { } bar || bar.Value >= bar.Maximum - 0.5;
|
||||||
|
|
||||||
/// The contact drawn at the foot of the view, or null when the log is empty
|
/// The contact drawn at the foot of the view, or null when the log is empty
|
||||||
/// or has not been drawn yet. Setting `ItemsSource` puts the view back at
|
/// or has not been drawn yet. Setting `ItemsSource` puts the view back at
|
||||||
/// the top, so this is what the view is wound back to afterwards.
|
/// the top, so this is what the view is wound back to afterwards.
|
||||||
@@ -330,10 +345,6 @@ public sealed partial class LogWindow : RefreshableWindow
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private static string Summary(ContestSession logging) =>
|
|
||||||
$"{logging.Log.Tally.Qsos} contacts · {logging.Log.Tally.Points} points · " +
|
|
||||||
$"{logging.Log.Tally.TotalMultipliers} multipliers · score {logging.Log.TotalScore:N0}";
|
|
||||||
|
|
||||||
/// N1MM's Ctrl+F: shows the next contact with this call and selects it,
|
/// N1MM's Ctrl+F: shows the next contact with this call and selects it,
|
||||||
/// starting again from the top once the last one has been shown. Null when
|
/// starting again from the top once the last one has been shown. Null when
|
||||||
/// the call is not in the log.
|
/// the call is not in the log.
|
||||||
@@ -380,9 +391,8 @@ public sealed partial class LogWindow : RefreshableWindow
|
|||||||
Dispatcher.UIThread.Post(() => Edit(row.Qso, column.Field, text));
|
Dispatcher.UIThread.Post(() => Edit(row.Qso, column.Field, text));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A refused edit puts the reason in the summary line and the cell falls
|
/// A refused edit leaves the cell as it was and shows the reason.
|
||||||
/// back to what it held before.
|
private async void Edit(Qso qso, QsoField field, string text)
|
||||||
private void Edit(Qso qso, QsoField field, string text)
|
|
||||||
{
|
{
|
||||||
if (session.Logging is null)
|
if (session.Logging is null)
|
||||||
{
|
{
|
||||||
@@ -391,7 +401,7 @@ public sealed partial class LogWindow : RefreshableWindow
|
|||||||
QsoEdit edit = session.Logging.Edit(qso.Id, field, text);
|
QsoEdit edit = session.Logging.Edit(qso.Id, field, text);
|
||||||
if (!edit.IsAccepted)
|
if (!edit.IsAccepted)
|
||||||
{
|
{
|
||||||
SummaryText.Text = edit.Error;
|
await new AlertDialog("Edit refused", edit.Error).ShowDialog(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,8 +446,6 @@ public sealed partial class LogWindow : RefreshableWindow
|
|||||||
List<Qso> picked = [.. Rows.SelectedItems.OfType<LogRow>().Select(r => r.Qso)];
|
List<Qso> picked = [.. Rows.SelectedItems.OfType<LogRow>().Select(r => r.Qso)];
|
||||||
if (picked.Count == 0)
|
if (picked.Count == 0)
|
||||||
{
|
{
|
||||||
message = "pick the contacts to change first";
|
|
||||||
Refresh();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
string was = picked[0].Operator.Length > 0 ? picked[0].Operator : session.Settings.CurrentOperator;
|
string was = picked[0].Operator.Length > 0 ? picked[0].Operator : session.Settings.CurrentOperator;
|
||||||
@@ -450,9 +458,6 @@ public sealed partial class LogWindow : RefreshableWindow
|
|||||||
{
|
{
|
||||||
session.Logging.Edit(qso.Id, QsoField.Operator, callsign);
|
session.Logging.Edit(qso.Id, QsoField.Operator, callsign);
|
||||||
}
|
}
|
||||||
message = picked.Count == 1
|
|
||||||
? $"1 contact logged by {callsign}"
|
|
||||||
: $"{picked.Count} contacts logged by {callsign}";
|
|
||||||
Refresh();
|
Refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,7 +475,6 @@ public sealed partial class LogWindow : RefreshableWindow
|
|||||||
if (await dialog.ShowDialog<bool>(this))
|
if (await dialog.ShowDialog<bool>(this))
|
||||||
{
|
{
|
||||||
session.Logging.Delete(row.Qso.Id);
|
session.Logging.Delete(row.Qso.Id);
|
||||||
message = $"{row.Qso.Call.Text} deleted";
|
|
||||||
Refresh();
|
Refresh();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user