N1MM's band panel is a row per band and a column per mode the contest runs, with every cell painted by what the callsign being typed would be worth there: red for one new multiplier, green for more than one, blue for points, grey for a station already worked on that band and mode. With nothing typed every cell is grey. That is the panel's point — it says where a station is still needed without working the bands one at a time — and this had only the frame. BandPanel holds the rule and is unit-tested without a window. The bands are the six contest bands, with 30, 17 and 12 metres added for general logging: in N1MM that is the one entry of the hundred and thirty-five that shows the WARC bands, so Contest.ShowsWarcBands says so and GeneralLogging overrides it. The columns are the modes the contest runs, so CQ WW CW has one column and IARU has two, rather than always showing CW and phone. A digital entry gets one DIG column where N1MM has RTTY and PSK: dupes and points here go by mode category, and those two are the same category. The labels are N1MM's: the metres alone up to 10, then the unit from 6m up. The band the radio is on is ringed rather than filled, as N1MM rings it, so the ring does not hide the colour underneath. The cells are labels with a click handler rather than buttons, so the theme cannot paint over the colour under the pointer. Text on a filled cell is black or white by N1MM's rule: light is the three channels adding up to more than 384. Looked at under Xvfb in CQ WW CW: one CW column, six bands, grey with nothing typed, and with a JA typed red on every band but 20, where the zone and country are already claimed, and which carries the ring. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
341 lines
11 KiB
C#
341 lines
11 KiB
C#
using Nonemm.Contests;
|
|
using Nonemm.Core;
|
|
using Nonemm.Core.Calls;
|
|
using Nonemm.Core.Country;
|
|
using Nonemm.Storage;
|
|
|
|
namespace Nonemm.Session;
|
|
|
|
/// One radio and what the operator is typing on it. A single-radio station has
|
|
/// one of these; an SO2R station has two, sharing one `ContestSession` and so
|
|
/// one log, one score and one run of serial numbers.
|
|
public sealed class OperatingPosition
|
|
{
|
|
public OperatingPosition(ContestSession session, int radioNumber = 1)
|
|
{
|
|
Session = session;
|
|
RadioNumber = radioNumber;
|
|
Entry = new EntryFields(session.Contest.ExchangeFieldsFor(session.Me));
|
|
}
|
|
|
|
public ContestSession Session { get; }
|
|
|
|
/// 1 or 2. Contacts record which radio made them.
|
|
public int RadioNumber { get; }
|
|
|
|
public EntryFields Entry { get; }
|
|
|
|
/// With no radio connected this stays where it was last typed.
|
|
public Frequency Frequency { get; private set; } = Bands.Band20M.Low;
|
|
|
|
/// Where the radio transmits when working split, and zero when it is not.
|
|
public Frequency TransmitFrequency { get; private set; } = Frequency.Zero;
|
|
|
|
public Mode Mode { get; private set; } = Modes.Cw;
|
|
|
|
/// True while the operator is calling CQ on this radio rather than
|
|
/// searching. Each radio runs or searches on its own.
|
|
public bool IsRunning { get; set; }
|
|
|
|
/// The other operator's name and a note about the contact. Neither is part
|
|
/// of any exchange — N1MM keeps both beside the exchange boxes and writes
|
|
/// them into the log — so neither counts towards a complete exchange.
|
|
public string OtherName { get; set; } = "";
|
|
|
|
public string Comment { get; set; } = "";
|
|
|
|
public Contest Contest => Session.Contest;
|
|
|
|
public ContestInstance Instance => Session.Instance;
|
|
|
|
public StationInfo Me => Session.Me;
|
|
|
|
public ContestLog Log => Session.Log;
|
|
|
|
public QsoEditor Editor => Session.Editor;
|
|
|
|
public int SentNumber => Session.SentNumber;
|
|
|
|
public void Tune(Frequency frequency, Mode? mode = null, Frequency? transmitFrequency = null)
|
|
{
|
|
Frequency = frequency;
|
|
Mode = mode ?? Mode;
|
|
TransmitFrequency = transmitFrequency ?? TransmitFrequency;
|
|
Session.NotifyChanged();
|
|
}
|
|
|
|
/// What the log says about what is typed now. Null when there is no call to
|
|
/// judge yet.
|
|
public Verdict? Verdict()
|
|
{
|
|
string call = Entry.Call.Trim();
|
|
return call.Length == 0 ? null : Log.Judge(BuildQso(call));
|
|
}
|
|
|
|
/// The same on another band and mode, which is what the band panel asks for
|
|
/// each of its cells.
|
|
public Verdict? Verdict(Frequency frequency, Mode mode)
|
|
{
|
|
string call = Entry.Call.Trim();
|
|
return call.Length == 0
|
|
? null
|
|
: Log.Judge(BuildQso(call) with { Frequency = frequency, Mode = mode });
|
|
}
|
|
|
|
public CountryLookup? Country() =>
|
|
Entry.Call.Trim().Length == 0 ? null : Session.Countries?.Find(Entry.Call.Trim());
|
|
|
|
/// Every earlier contact with the call being typed, newest first.
|
|
public IReadOnlyList<Qso> WorkedBefore() =>
|
|
Entry.Call.Trim().Length == 0 ? [] : Log.WorkedBefore(Entry.Call.Trim());
|
|
|
|
/// The frequency typed into the callsign box, if that is what it holds.
|
|
public Frequency? PendingQsy() => FrequencyEntry.Parse(Entry.Call);
|
|
|
|
/// Logs what is typed and clears the entry. Throws when the entry is not
|
|
/// complete, so a caller that has not checked cannot lose an exchange.
|
|
public Qso LogContact()
|
|
{
|
|
if (!Entry.IsComplete)
|
|
{
|
|
throw new InvalidOperationException($"the exchange for {Entry.Call} is not complete");
|
|
}
|
|
Qso stored = Session.Add(Noted(BuildQso(Entry.Call.Trim())));
|
|
Entry.Clear();
|
|
OtherName = "";
|
|
Comment = "";
|
|
return stored;
|
|
}
|
|
|
|
/// Puts the report the operator would send anyway into the report boxes
|
|
/// that are empty: 599 on CW and digital modes, 59 on phone. N1MM does the
|
|
/// same when the callsign box is left, which is why neither program stops
|
|
/// on a report box.
|
|
public bool FillReports()
|
|
{
|
|
bool filled = false;
|
|
for (int at = 0; at < Entry.Exchange.Count; at++)
|
|
{
|
|
if (Entry.Exchange[at].Kind == ExchangeFieldKind.Report && Entry[at + 1].Trim().Length == 0)
|
|
{
|
|
Entry[at + 1] = DefaultReport();
|
|
filled = true;
|
|
}
|
|
}
|
|
if (filled)
|
|
{
|
|
Session.NotifyChanged();
|
|
}
|
|
return filled;
|
|
}
|
|
|
|
/// Fills what the call history knows into the exchange boxes that are still
|
|
/// empty. What the other station actually sends wins, so a box with
|
|
/// something in it is left alone. Returns true when anything was filled.
|
|
public bool FillFromHistory()
|
|
{
|
|
CallHistoryEntry? known = Session.History.Find(Entry.Call);
|
|
if (known is null)
|
|
{
|
|
return false;
|
|
}
|
|
bool filled = false;
|
|
for (int at = 0; at < Entry.Exchange.Count; at++)
|
|
{
|
|
string value = ExchangeSlots.ValueFrom(known, Entry.Exchange[at]);
|
|
if (value.Length == 0 || Entry[at + 1].Trim().Length > 0)
|
|
{
|
|
continue;
|
|
}
|
|
Entry[at + 1] = value;
|
|
filled = true;
|
|
}
|
|
if (filled)
|
|
{
|
|
Session.NotifyChanged();
|
|
}
|
|
return filled;
|
|
}
|
|
|
|
/// Moves to the next box the operator types in. On top of the report boxes
|
|
/// the entry itself steps over, a contest can say a box is not for this
|
|
/// station: CQ WW RTTY asks only the US and Canada for a state.
|
|
public void MoveFocus(bool forward)
|
|
{
|
|
CountryLookup? their = Country();
|
|
for (int step = 0; step < Entry.FieldCount; step++)
|
|
{
|
|
if (forward)
|
|
{
|
|
Entry.Advance();
|
|
}
|
|
else
|
|
{
|
|
Entry.Retreat();
|
|
}
|
|
if (Entry.Focus == 0 || !Contest.SkipsField(Entry.Exchange[Entry.Focus - 1], their))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The logged contact the boxes are re-editing, or null while a new one is
|
|
/// being typed. N1MM calls this quick edit: Ctrl+Q steps back through the
|
|
/// log, Ctrl+A forward, Enter writes the changes back and Esc leaves.
|
|
public Qso? Editing { get; private set; }
|
|
|
|
private (string Call, string[] Values, string Name, string Comment)? held;
|
|
|
|
/// Steps one contact back or forward and loads it into the boxes. False
|
|
/// when the log has nothing that way; stepping forward past the newest
|
|
/// contact leaves quick edit instead.
|
|
public bool QuickEdit(bool forward)
|
|
{
|
|
IReadOnlyList<Qso> qsos = Log.Qsos;
|
|
if (qsos.Count == 0)
|
|
{
|
|
return false;
|
|
}
|
|
int at = Editing is null ? qsos.Count : IndexOf(Editing.Id);
|
|
int wanted = forward ? at + 1 : at - 1;
|
|
if (wanted < 0 || at < 0)
|
|
{
|
|
return false;
|
|
}
|
|
if (wanted >= qsos.Count)
|
|
{
|
|
LeaveQuickEdit();
|
|
return true;
|
|
}
|
|
held ??= (Entry.Call, [.. Enumerable.Range(1, Entry.FieldCount - 1).Select(f => Entry[f])], OtherName, Comment);
|
|
Editing = qsos[wanted];
|
|
LoadFrom(Editing);
|
|
Session.NotifyChanged();
|
|
return true;
|
|
}
|
|
|
|
/// Puts the boxes back to the contact that was being typed.
|
|
public void LeaveQuickEdit()
|
|
{
|
|
if (Editing is null)
|
|
{
|
|
return;
|
|
}
|
|
Editing = null;
|
|
if (held is { } typed)
|
|
{
|
|
Entry.Call = typed.Call;
|
|
for (int field = 1; field < Entry.FieldCount; field++)
|
|
{
|
|
Entry[field] = typed.Values[field - 1];
|
|
}
|
|
OtherName = typed.Name;
|
|
Comment = typed.Comment;
|
|
held = null;
|
|
}
|
|
Session.NotifyChanged();
|
|
}
|
|
|
|
/// Writes the boxes back to the contact being quick edited and leaves quick
|
|
/// edit. Null when no contact is being edited.
|
|
public Qso? SaveQuickEdit()
|
|
{
|
|
if (Editing is null)
|
|
{
|
|
return null;
|
|
}
|
|
Qso saved = Noted(ApplyExchange(CountryFields.Apply(
|
|
Editing with { Call = Callsign.Parse(Entry.Call.Trim()) }, Session.Countries)));
|
|
Session.Update(saved);
|
|
LeaveQuickEdit();
|
|
return saved;
|
|
}
|
|
|
|
/// The contact as it would be logged now, for the dialog that edits the one
|
|
/// in progress.
|
|
public Qso InProgress() => Noted(BuildQso(Entry.Call.Trim()));
|
|
|
|
/// Fills the boxes from a contact, as quick edit does.
|
|
public void LoadFrom(Qso qso)
|
|
{
|
|
Entry.Call = qso.Call.Text;
|
|
for (int at = 0; at < Entry.Exchange.Count; at++)
|
|
{
|
|
Entry[at + 1] = ExchangeSlots.ValueOf(qso, Entry.Exchange[at].Slot);
|
|
}
|
|
OtherName = Entry.Exchange.Any(f => f.Slot == ExchangeSlot.Name) ? "" : qso.Name;
|
|
Comment = qso.Comment;
|
|
}
|
|
|
|
private int IndexOf(string id)
|
|
{
|
|
for (int at = 0; at < Log.Qsos.Count; at++)
|
|
{
|
|
if (Log.Qsos[at].Id == id)
|
|
{
|
|
return at;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
public void Wipe()
|
|
{
|
|
Editing = null;
|
|
held = null;
|
|
Entry.Clear();
|
|
OtherName = "";
|
|
Comment = "";
|
|
Session.NotifyChanged();
|
|
}
|
|
|
|
/// The name and comment boxes fill the log columns of the same names. A
|
|
/// contest that exchanges a name has already filled that column, and what
|
|
/// was exchanged wins.
|
|
private Qso Noted(Qso qso) => qso with
|
|
{
|
|
Name = qso.Name.Length > 0 ? qso.Name : OtherName.Trim(),
|
|
Comment = qso.Comment.Length > 0 ? qso.Comment : Comment.Trim(),
|
|
};
|
|
|
|
/// The report an operator sends without thinking about it: 599 on CW and
|
|
/// digital modes, 59 on phone.
|
|
public string DefaultReport() => Mode.Category switch
|
|
{
|
|
ModeCategory.Cw => "599",
|
|
ModeCategory.Phone => "59",
|
|
_ => "599",
|
|
};
|
|
|
|
private Qso BuildQso(string call)
|
|
{
|
|
Qso qso = new()
|
|
{
|
|
Id = Qso.NewId(),
|
|
TimestampUtc = DateTime.UtcNow.AddTicks(-(DateTime.UtcNow.Ticks % TimeSpan.TicksPerSecond)),
|
|
Call = Callsign.Parse(call),
|
|
Frequency = Frequency,
|
|
QsxFrequency = TransmitFrequency,
|
|
Mode = Mode,
|
|
RadioNumber = RadioNumber,
|
|
ContestName = Contest.Name,
|
|
ContestNumber = Instance.ContestNumber,
|
|
SentReport = DefaultReport(),
|
|
SentNumber = SentNumber,
|
|
Operator = Session.Operator.Length > 0 ? Session.Operator : Me.Callsign,
|
|
IsRunQso = IsRunning,
|
|
};
|
|
return ApplyExchange(CountryFields.Apply(qso, Session.Countries));
|
|
}
|
|
|
|
private Qso ApplyExchange(Qso qso)
|
|
{
|
|
foreach (ExchangeField field in Entry.Exchange)
|
|
{
|
|
qso = ExchangeSlots.Apply(qso, field.Slot, Entry.ValueOf(field.Slot).Trim());
|
|
}
|
|
return qso;
|
|
}
|
|
}
|