diff --git a/ProcessManager.App/CommandLine.cs b/ProcessManager.App/CommandLine.cs
index 729c615..1d35087 100644
--- a/ProcessManager.App/CommandLine.cs
+++ b/ProcessManager.App/CommandLine.cs
@@ -16,7 +16,7 @@ internal enum RunMode : byte { Desktop, Terminal, List, Find, Kill, SelfTest, He
internal sealed record CommandLineOptions {
public RunMode Mode { get; init; } = RunMode.Desktop;
- public ProcessColumn SortColumn { get; init; } = ProcessColumn.CpuPercent;
+ public ProcessField SortColumn { get; init; } = ProcessField.CpuPercent;
public bool SortDescending { get; init; } = true;
public bool TreeMode { get; init; }
@@ -118,7 +118,7 @@ public static CommandLineOptions Parse(string[] args) {
case "--sort": {
if (!TryValue(args, ref i, inlineValue, out var column))
return options with { Error = "--sort needs a column" };
- if (!ProcessColumnExtensions.TryParse(column, out var parsed))
+ if (!FieldRegistry.TryParse(column, out var parsed))
return options with { Error = $"unknown sort column '{column}'" };
options = options with { SortColumn = parsed, SortDescending = parsed.PrefersDescending() };
diff --git a/ProcessManager.Benchmarks/Program.cs b/ProcessManager.Benchmarks/Program.cs
index 7d676a0..90bad2a 100644
--- a/ProcessManager.Benchmarks/Program.cs
+++ b/ProcessManager.Benchmarks/Program.cs
@@ -128,7 +128,7 @@ private static int Main(string[] args) {
);
// The view is rebuilt once per sample by both front-ends, so its cost is part of the frame.
- var view = new ProcessView { TreeMode = true, SortColumn = ProcessColumn.CpuPercent };
+ var view = new ProcessView { TreeMode = true, SortColumn = ProcessField.CpuPercent };
view.Rebuild(sampler.Current, sampler.Delta);
var viewStart = Stopwatch.GetTimestamp();
for (var i = 0; i < 50; ++i)
diff --git a/ProcessManager.Core/Query/FieldAccessor.cs b/ProcessManager.Core/Query/FieldAccessor.cs
new file mode 100644
index 0000000..56430ff
--- /dev/null
+++ b/ProcessManager.Core/Query/FieldAccessor.cs
@@ -0,0 +1,208 @@
+using System.Globalization;
+using Hawkynt.ProcessManager.Model;
+using Hawkynt.ProcessManager.Sampling;
+
+namespace Hawkynt.ProcessManager.Query;
+
+///
+/// Reads one field out of a process: as text to display, as a number to compare, and as an ordering.
+///
+///
+/// The single place any field is turned into anything. Both front-ends render through
+/// , the view sorts through , and the filter compares through
+/// — so a value reads the same in the window and in the terminal, and sorting by
+/// a column can never disagree with what that column shows (PRD §5.1).
+///
+public static class FieldAccessor {
+
+ ///
+ /// What the field shows, including the reason when it shows no value (PRD §72.3).
+ ///
+ ///
+ /// May be before a second sample exists, in which case every derived field
+ /// reads as "not sampled yet" rather than as zero.
+ ///
+ public static string Text(ProcessField field, in ProcessRecord process, SnapshotDelta? delta, int index) {
+ switch (field) {
+ case ProcessField.Name: return process.Name;
+ case ProcessField.Pid: return process.Pid.ToString(CultureInfo.InvariantCulture);
+ case ProcessField.PidHex: return "0x" + process.Pid.ToString("X", CultureInfo.InvariantCulture);
+ case ProcessField.ParentPid:
+ return process.ParentPid > 0 ? process.ParentPid.ToString(CultureInfo.InvariantCulture) : "—";
+ case ProcessField.UserName:
+ return process.UserName ?? Humanize.Placeholder(UnknownReason.NotPermitted);
+ case ProcessField.State: return Humanize.State(process.State);
+
+ case ProcessField.CpuPercent: return Humanize.Percent(Rated(delta, index, field));
+ case ProcessField.CpuPercentPerCore: return Humanize.Percent(Rated(delta, index, field));
+ case ProcessField.CpuTime: return Humanize.Duration(process.CpuTimeNs);
+ case ProcessField.CyclesDelta: return Humanize.Rate(Rated(delta, index, field));
+ case ProcessField.ContextSwitchesDelta: return Humanize.Rate(Rated(delta, index, field));
+ case ProcessField.PageFaultsDelta: return Humanize.Rate(Rated(delta, index, field));
+
+ case ProcessField.PrivateBytes: return Humanize.Bytes(process.PrivateBytes);
+ case ProcessField.PrivateBytesDelta: return Humanize.SignedBytesPerSecond(Rated(delta, index, field));
+ case ProcessField.PrivateWorkingSet: return Humanize.Bytes(process.PrivateWorkingSetBytes);
+ case ProcessField.WorkingSetBytes: return Humanize.Bytes(process.WorkingSetBytes);
+ case ProcessField.PeakWorkingSet: return Humanize.Bytes(process.PeakWorkingSetBytes);
+ case ProcessField.VirtualBytes: return Humanize.Bytes(process.VirtualBytes);
+ case ProcessField.PeakVirtualBytes: return Humanize.Bytes(process.PeakVirtualBytes);
+ case ProcessField.PagedPool: return Humanize.Bytes(process.PagedPoolBytes);
+ case ProcessField.PeakPagedPool: return Humanize.Bytes(process.PeakPagedPoolBytes);
+ case ProcessField.NonPagedPool: return Humanize.Bytes(process.NonPagedPoolBytes);
+ case ProcessField.PeakNonPagedPool: return Humanize.Bytes(process.PeakNonPagedPoolBytes);
+ case ProcessField.Swap: return Humanize.Bytes(process.SwapBytes);
+
+ case ProcessField.IoTotalRate:
+ case ProcessField.ReadBytesPerSecond:
+ case ProcessField.WriteBytesPerSecond:
+ return Humanize.BytesPerSecond(Rated(delta, index, field));
+
+ case ProcessField.ThreadCount: return process.ThreadCount.ToString(CultureInfo.InvariantCulture);
+ case ProcessField.HandleCount: return Humanize.Count(process.HandleCount);
+ case ProcessField.Priority: return process.Priority.ToString(CultureInfo.InvariantCulture);
+ case ProcessField.SessionId:
+ return process.SessionId >= 0 ? process.SessionId.ToString(CultureInfo.InvariantCulture) : "—";
+ case ProcessField.StartTime:
+ return process.StartTimeUtcTicks > 0
+ ? new DateTime(process.StartTimeUtcTicks, DateTimeKind.Utc).ToLocalTime()
+ .ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
+ : "—";
+ case ProcessField.Container: return process.ContainerPath ?? "—";
+ case ProcessField.ImagePath: return process.ImagePath ?? "—";
+ case ProcessField.CommandLine: return process.CommandLine ?? string.Empty;
+
+ // The graphs are drawn, not written. Asking for their text is a caller bug, but returning
+ // empty is friendlier than throwing in a render loop.
+ case ProcessField.CpuHistory:
+ case ProcessField.MemoryHistory:
+ case ProcessField.IoHistory:
+ default:
+ return string.Empty;
+ }
+ }
+
+ ///
+ /// The field as a plain number, for filtering and for sorting.
+ ///
+ ///
+ /// when the field has no number at all — either because it is text, or
+ /// because this platform does not report it. A filter must treat those two the same way: a process
+ /// whose value is unknown does not match > 0, and it does not match == 0 either.
+ ///
+ public static double? Number(ProcessField field, in ProcessRecord process, SnapshotDelta? delta, int index) {
+ switch (field) {
+ case ProcessField.Pid:
+ case ProcessField.PidHex: return process.Pid;
+ case ProcessField.ParentPid: return process.ParentPid;
+ case ProcessField.State: return (byte)process.State;
+ case ProcessField.ThreadCount: return process.ThreadCount;
+ case ProcessField.Priority: return process.Priority;
+ case ProcessField.SessionId: return process.SessionId;
+ case ProcessField.StartTime: return process.StartTimeUtcTicks;
+
+ case ProcessField.CpuTime: return Number(process.CpuTimeNs);
+ case ProcessField.PrivateBytes: return Number(process.PrivateBytes);
+ case ProcessField.PrivateWorkingSet: return Number(process.PrivateWorkingSetBytes);
+ case ProcessField.WorkingSetBytes: return Number(process.WorkingSetBytes);
+ case ProcessField.PeakWorkingSet: return Number(process.PeakWorkingSetBytes);
+ case ProcessField.VirtualBytes: return Number(process.VirtualBytes);
+ case ProcessField.PeakVirtualBytes: return Number(process.PeakVirtualBytes);
+ case ProcessField.PagedPool: return Number(process.PagedPoolBytes);
+ case ProcessField.PeakPagedPool: return Number(process.PeakPagedPoolBytes);
+ case ProcessField.NonPagedPool: return Number(process.NonPagedPoolBytes);
+ case ProcessField.PeakNonPagedPool: return Number(process.PeakNonPagedPoolBytes);
+ case ProcessField.Swap: return Number(process.SwapBytes);
+ case ProcessField.HandleCount: return Number(process.HandleCount);
+
+ case ProcessField.CpuPercent:
+ case ProcessField.CpuPercentPerCore:
+ case ProcessField.CyclesDelta:
+ case ProcessField.ContextSwitchesDelta:
+ case ProcessField.PageFaultsDelta:
+ case ProcessField.PrivateBytesDelta:
+ case ProcessField.IoTotalRate:
+ case ProcessField.ReadBytesPerSecond:
+ case ProcessField.WriteBytesPerSecond: {
+ var rate = Rated(delta, index, field);
+ return rate.HasValue ? rate.Value : null;
+ }
+
+ default: return null;
+ }
+ }
+
+ /// The field as raw text, for substring and regular-expression filtering.
+ ///
+ /// Deliberately not : a filter must match what the value is, not how it
+ /// was abbreviated for a column. Searching for a path should not fail because the column showed
+ /// an em dash, and searching "1024" should not match a cell that reads "1.0K".
+ ///
+ public static string? RawText(ProcessField field, in ProcessRecord process) => field switch {
+ ProcessField.Name => process.Name,
+ ProcessField.UserName => process.UserName,
+ ProcessField.ImagePath => process.ImagePath,
+ ProcessField.CommandLine => process.CommandLine,
+ ProcessField.Container => process.ContainerPath,
+ ProcessField.State => Humanize.State(process.State),
+ ProcessField.Pid => process.Pid.ToString(CultureInfo.InvariantCulture),
+ ProcessField.ParentPid => process.ParentPid.ToString(CultureInfo.InvariantCulture),
+ _ => null,
+ };
+
+ ///
+ /// Orders two rows by one field. Text compares case-insensitively; numbers compare numerically;
+ /// a value that is unknown sorts below every known one, whichever direction is chosen.
+ ///
+ public static int Compare(
+ ProcessField field,
+ in ProcessRecord a,
+ int indexA,
+ in ProcessRecord b,
+ int indexB,
+ SnapshotDelta? delta
+ ) {
+ switch (field) {
+ case ProcessField.Name:
+ return string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase);
+ case ProcessField.UserName:
+ return string.Compare(a.UserName, b.UserName, StringComparison.OrdinalIgnoreCase);
+ case ProcessField.CommandLine:
+ return string.Compare(a.CommandLine, b.CommandLine, StringComparison.OrdinalIgnoreCase);
+ case ProcessField.ImagePath:
+ return string.Compare(a.ImagePath, b.ImagePath, StringComparison.OrdinalIgnoreCase);
+ case ProcessField.Container:
+ return string.Compare(a.ContainerPath, b.ContainerPath, StringComparison.OrdinalIgnoreCase);
+ }
+
+ var left = Number(field, in a, delta, indexA);
+ var right = Number(field, in b, delta, indexB);
+ if (left is null)
+ return right is null ? 0 : -1;
+ if (right is null)
+ return 1;
+
+ return left.Value.CompareTo(right.Value);
+ }
+
+ private static double? Number(Counter counter) => counter.HasValue ? counter.Value : null;
+
+ private static Rate Rated(SnapshotDelta? delta, int index, ProcessField field) {
+ if (delta is null)
+ return Rate.NotSampledYet;
+
+ return field switch {
+ ProcessField.CpuPercent => delta.CpuPercent(index),
+ ProcessField.CpuPercentPerCore => delta.CpuPercentPerCore(index),
+ ProcessField.CyclesDelta => delta.CyclesPerSecond(index),
+ ProcessField.ContextSwitchesDelta => delta.ContextSwitchesPerSecond(index),
+ ProcessField.PageFaultsDelta => delta.PageFaultsPerSecond(index),
+ ProcessField.PrivateBytesDelta => delta.PrivateBytesDelta(index),
+ ProcessField.IoTotalRate => delta.IoTotalBytesPerSecond(index),
+ ProcessField.ReadBytesPerSecond => delta.ReadBytesPerSecond(index),
+ ProcessField.WriteBytesPerSecond => delta.WriteBytesPerSecond(index),
+ _ => Rate.NotSampledYet,
+ };
+ }
+
+}
diff --git a/ProcessManager.Core/Query/FieldRegistry.cs b/ProcessManager.Core/Query/FieldRegistry.cs
new file mode 100644
index 0000000..ee2809b
--- /dev/null
+++ b/ProcessManager.Core/Query/FieldRegistry.cs
@@ -0,0 +1,236 @@
+using Hawkynt.ProcessManager.Sampling;
+
+namespace Hawkynt.ProcessManager.Query;
+
+///
+/// The canonical field catalogue: the one place a field is declared, and the thing both front-ends,
+/// the CLI and the filter are built from (PRD §5.1).
+///
+///
+/// A static array rather than anything reflective, so it survives trimming and NativeAOT intact
+/// (PRD §8.3). Adding a field here gives it a header, a width, a sort order, a formatter and a filter
+/// term in every front-end at once — which is the whole reason it exists.
+///
+public static class FieldRegistry {
+
+ private const FieldPlatforms _WINDOWS = FieldPlatforms.Windows;
+ private const FieldPlatforms _LINUX = FieldPlatforms.Linux;
+ private const FieldPlatforms _POSIX = FieldPlatforms.Linux | FieldPlatforms.MacOS;
+ private const FieldPlatforms _ALL = FieldPlatforms.All;
+
+ /// Every field, in default column order.
+ public static readonly FieldDescriptor[] All = [
+ new(ProcessField.Name, "name", "Process", "Process",
+ "The short name: comm on Linux, the image file name on Windows.",
+ FieldKind.Text, FieldUnit.None, _ALL, FieldCost.Free, 260, 120, false, false,
+ Aliases: "process comm"),
+ new(ProcessField.Pid, "pid", "PID", "PID",
+ "The process identifier.",
+ FieldKind.Identifier, FieldUnit.None, _ALL, FieldCost.Free, 70, 7, true, false),
+ new(ProcessField.PidHex, "pid.hex", "PID (hex)", "PIDx",
+ "The same identifier in hexadecimal, which is how a debugger will show it.",
+ FieldKind.Identifier, FieldUnit.None, _ALL, FieldCost.Free, 78, 9, true, false),
+ new(ProcessField.ParentPid, "ppid", "Parent PID", "PPID",
+ "The parent's identifier, or none when the parent has exited.",
+ FieldKind.Identifier, FieldUnit.None, _ALL, FieldCost.Free, 88, 7, true, false,
+ Aliases: "parent"),
+ new(ProcessField.UserName, "user", "User", "User",
+ "The account the process runs as.",
+ FieldKind.Text, FieldUnit.None, _ALL, FieldCost.Free, 130, 10, false, false,
+ Aliases: "owner username"),
+ new(ProcessField.State, "state", "State", "S",
+ "What the scheduler thinks of the process right now.",
+ FieldKind.State, FieldUnit.None, _ALL, FieldCost.Free, 62, 5, false, false,
+ Aliases: "status"),
+
+ new(ProcessField.CpuPercent, "cpu", "CPU %", "CPU%",
+ "Processor use where 100% is the whole machine.",
+ FieldKind.Rate, FieldUnit.Percent, _ALL, FieldCost.Derived, 78, 5, true, true,
+ Aliases: "cpu.percent"),
+ new(ProcessField.CpuPercentPerCore, "cpu.raw", "CPU % (per core)", "CPU%c",
+ "Processor use where 100% is one core, the way top reports it.",
+ FieldKind.Rate, FieldUnit.Percent, _ALL, FieldCost.Derived, 118, 6, true, true,
+ Aliases: "cpu.percore"),
+ new(ProcessField.CpuTime, "cpu.time", "CPU time", "Time",
+ "Total processor time consumed since the process started.",
+ FieldKind.Cumulative, FieldUnit.Nanoseconds, _ALL, FieldCost.Free, 88, 9, true, true),
+ new(ProcessField.CyclesDelta, "cpu.cycles.delta", "Cycles delta", "Cyc/s",
+ "Processor cycles this interval. Unlike CPU time it does not flatter a process that ran while the clock was throttled.",
+ FieldKind.Rate, FieldUnit.CountPerSecond, _WINDOWS, FieldCost.Derived, 100, 8, true, true),
+ new(ProcessField.ContextSwitchesDelta, "ctx.delta", "Ctx switch delta", "Ctx/s",
+ "Context switches this interval.",
+ FieldKind.Rate, FieldUnit.CountPerSecond, _POSIX, FieldCost.Derived, 116, 8, true, true),
+ new(ProcessField.CpuHistory, "cpu.history", "CPU history", "CPU hist",
+ "The last sixty seconds of processor use.",
+ FieldKind.Graph, FieldUnit.Percent, _ALL, FieldCost.Derived, 90, 12, false, false,
+ HistorySeries.Cpu),
+
+ new(ProcessField.PrivateBytes, "private", "Private bytes", "Private",
+ "Private memory the process has committed — what it would give back if it exited.",
+ FieldKind.Instant, FieldUnit.Bytes, _ALL, FieldCost.Free, 96, 7, true, true,
+ Aliases: "mem memory commit"),
+ new(ProcessField.PrivateBytesDelta, "private.delta", "Private delta", "Priv/s",
+ "How fast committed private memory is moving. A process whose private bytes only climb is the one leaking.",
+ FieldKind.Rate, FieldUnit.BytesPerSecond, _ALL, FieldCost.Derived, 100, 9, true, true),
+ new(ProcessField.PrivateWorkingSet, "private.ws", "Private WS", "PrivWS",
+ "The resident part of the committed private memory.",
+ FieldKind.Instant, FieldUnit.Bytes, _ALL, FieldCost.Free, 88, 7, true, true,
+ Aliases: "uss"),
+ new(ProcessField.MemoryHistory, "memory.history", "Memory history", "Mem hist",
+ "The last sixty seconds of committed private memory.",
+ FieldKind.Graph, FieldUnit.Bytes, _ALL, FieldCost.Derived, 90, 12, false, false,
+ HistorySeries.Memory),
+ new(ProcessField.WorkingSetBytes, "ws", "Working set", "RSS",
+ "Resident memory including every shared page, which is why it double-counts.",
+ FieldKind.Instant, FieldUnit.Bytes, _ALL, FieldCost.Free, 92, 7, true, true,
+ Aliases: "rss workingset resident"),
+ new(ProcessField.PeakWorkingSet, "ws.peak", "Peak WS", "PkRSS",
+ "The largest working set this process has ever held.",
+ FieldKind.Instant, FieldUnit.Bytes, _ALL, FieldCost.Free, 84, 7, true, true),
+ new(ProcessField.VirtualBytes, "virtual", "Virtual size", "Virt",
+ "Size of the mapped address space, most of which is usually not resident.",
+ FieldKind.Instant, FieldUnit.Bytes, _ALL, FieldCost.Free, 92, 7, true, true,
+ Aliases: "virt vsize"),
+ new(ProcessField.PeakVirtualBytes, "virtual.peak", "Peak virtual", "PkVirt",
+ "The largest address space this process has ever mapped.",
+ FieldKind.Instant, FieldUnit.Bytes, _ALL, FieldCost.Free, 96, 7, true, true),
+ new(ProcessField.PagedPool, "pool.paged", "Paged pool", "PgPool",
+ "Kernel memory charged to this process from the paged pool.",
+ FieldKind.Instant, FieldUnit.Bytes, _WINDOWS, FieldCost.Free, 90, 7, true, true),
+ new(ProcessField.PeakPagedPool, "pool.paged.peak", "Peak paged pool", "PkPgPool",
+ "The largest paged-pool charge this process has held.",
+ FieldKind.Instant, FieldUnit.Bytes, _WINDOWS, FieldCost.Free, 116, 8, true, true),
+ new(ProcessField.NonPagedPool, "pool.nonpaged", "Non-paged pool", "NpPool",
+ "Kernel memory charged to this process from the non-paged pool.",
+ FieldKind.Instant, FieldUnit.Bytes, _WINDOWS, FieldCost.Free, 110, 7, true, true),
+ new(ProcessField.PeakNonPagedPool, "pool.nonpaged.peak", "Peak non-paged", "PkNpPool",
+ "The largest non-paged-pool charge this process has held.",
+ FieldKind.Instant, FieldUnit.Bytes, _WINDOWS, FieldCost.Free, 112, 8, true, true),
+ new(ProcessField.PageFaultsDelta, "faults.delta", "Page fault delta", "Flt/s",
+ "Page faults this interval. A process faulting steadily is one the machine is paging for.",
+ FieldKind.Rate, FieldUnit.CountPerSecond, _ALL, FieldCost.Derived, 116, 8, true, true),
+ new(ProcessField.Swap, "swap", "Swap", "Swap",
+ "How much of this process the machine has pushed out to swap.",
+ FieldKind.Instant, FieldUnit.Bytes, _ALL, FieldCost.Free, 78, 7, true, true),
+
+ new(ProcessField.IoTotalRate, "io.total", "I/O total rate", "IO/s",
+ "Bytes read, written and neither, per second.",
+ FieldKind.Rate, FieldUnit.BytesPerSecond, _ALL, FieldCost.Derived, 104, 8, true, true,
+ Aliases: "io"),
+ new(ProcessField.ReadBytesPerSecond, "io.read", "I/O read rate", "Read/s",
+ "Bytes this process caused to be read, per second.",
+ FieldKind.Rate, FieldUnit.BytesPerSecond, _ALL, FieldCost.Derived, 100, 8, true, true,
+ Aliases: "read"),
+ new(ProcessField.WriteBytesPerSecond, "io.write", "I/O write rate", "Write/s",
+ "Bytes this process caused to be written, per second.",
+ FieldKind.Rate, FieldUnit.BytesPerSecond, _ALL, FieldCost.Derived, 104, 8, true, true,
+ Aliases: "write"),
+ new(ProcessField.IoHistory, "io.history", "I/O history", "I/O hist",
+ "The last sixty seconds of read and write traffic.",
+ FieldKind.Graph, FieldUnit.BytesPerSecond, _ALL, FieldCost.Derived, 90, 12, false, false,
+ HistorySeries.Io),
+
+ new(ProcessField.ThreadCount, "threads", "Threads", "Thr",
+ "How many threads the process currently has.",
+ FieldKind.Instant, FieldUnit.Count, _ALL, FieldCost.Free, 64, 4, true, true),
+ new(ProcessField.HandleCount, "handles", "Handles", "Hnd",
+ "Open handles on Windows, open file descriptors on Unix.",
+ FieldKind.Instant, FieldUnit.Count, _ALL, FieldCost.High, 66, 5, true, true,
+ Aliases: "fds fd"),
+ new(ProcessField.Priority, "priority", "Priority", "Pri",
+ "Scheduler priority in the platform's own scale.",
+ FieldKind.Instant, FieldUnit.Count, _ALL, FieldCost.Free, 74, 4, true, true,
+ Aliases: "prio"),
+ new(ProcessField.SessionId, "session", "Session", "Ses",
+ "The login or terminal session the process belongs to.",
+ FieldKind.Identifier, FieldUnit.None, _ALL, FieldCost.Free, 74, 5, true, false),
+ new(ProcessField.StartTime, "start", "Start time", "Started",
+ "When the process was created.",
+ FieldKind.Instant, FieldUnit.Timestamp, _ALL, FieldCost.Free, 140, 19, false, true,
+ Aliases: "started starttime"),
+ new(ProcessField.Container, "cgroup", "Container / cgroup", "Cgroup",
+ "The cgroup or container the process belongs to.",
+ FieldKind.Text, FieldUnit.None, _LINUX, FieldCost.Free, 240, 40, false, false,
+ Aliases: "container"),
+ new(ProcessField.ImagePath, "path", "Image path", "Path",
+ "Full path of the executable image.",
+ FieldKind.Text, FieldUnit.None, _ALL, FieldCost.Free, 320, 60, false, false,
+ Aliases: "image exe"),
+ new(ProcessField.CommandLine, "cmdline", "Command line", "Command",
+ "The complete command the process was started with.",
+ FieldKind.Text, FieldUnit.None, _ALL, FieldCost.Free, 420, 120, false, false,
+ Aliases: "cmd commandline"),
+ ];
+
+ private static readonly FieldDescriptor[] _byId = BuildIndex();
+
+ private static FieldDescriptor[] BuildIndex() {
+ var highest = 0;
+ foreach (var descriptor in All)
+ highest = Math.Max(highest, (int)descriptor.Id);
+
+ var index = new FieldDescriptor[highest + 1];
+ foreach (var descriptor in All)
+ index[(int)descriptor.Id] = descriptor;
+
+ return index;
+ }
+
+ /// Everything known about one field.
+ public static FieldDescriptor Get(ProcessField field) {
+ var index = (int)field;
+ return (uint)index < (uint)_byId.Length && _byId[index] is { } descriptor ? descriptor : _byId[0];
+ }
+
+ public static string Header(this ProcessField field) => Get(field).Header;
+
+ public static string ShortHeader(this ProcessField field) => Get(field).ShortHeader;
+
+ public static string Key(this ProcessField field) => Get(field).Key;
+
+ public static bool PrefersDescending(this ProcessField field) => Get(field).PrefersDescending;
+
+ ///
+ /// Resolves a field from text: its key, one of its aliases, or its header, case-insensitively.
+ ///
+ ///
+ /// This is what --sort, a saved layout and a search term all go through, so all three accept
+ /// the same spellings and none of them can drift from the others.
+ ///
+ public static bool TryParse(string? text, out ProcessField field) {
+ field = ProcessField.CpuPercent;
+ if (string.IsNullOrWhiteSpace(text))
+ return false;
+
+ var wanted = text.Trim();
+ foreach (var descriptor in All) {
+ if (string.Equals(descriptor.Key, wanted, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(descriptor.Header, wanted, StringComparison.OrdinalIgnoreCase)) {
+ field = descriptor.Id;
+ return true;
+ }
+
+ if (descriptor.Aliases is not { } aliases)
+ continue;
+
+ foreach (var alias in aliases.Split(' ', StringSplitOptions.RemoveEmptyEntries))
+ if (string.Equals(alias, wanted, StringComparison.OrdinalIgnoreCase)) {
+ field = descriptor.Id;
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /// Every spelling accepts, for the help text.
+ public static string SortableKeys() {
+ var keys = new List();
+ foreach (var descriptor in All)
+ if (descriptor.IsSortable)
+ keys.Add(descriptor.Key);
+
+ return string.Join(", ", keys);
+ }
+
+}
diff --git a/ProcessManager.Core/Query/ProcessColumn.cs b/ProcessManager.Core/Query/ProcessColumn.cs
deleted file mode 100644
index ef4e2ce..0000000
--- a/ProcessManager.Core/Query/ProcessColumn.cs
+++ /dev/null
@@ -1,88 +0,0 @@
-namespace Hawkynt.ProcessManager.Query;
-
-///
-/// Everything a row can be sorted by. The order here is the default column order in both front-ends,
-/// so it is the Process-Explorer order rather than alphabetical.
-///
-public enum ProcessColumn : byte {
- Name = 0,
- Pid,
- ParentPid,
- UserName,
- State,
- CpuPercent,
- PrivateBytes,
- WorkingSetBytes,
- VirtualBytes,
- ReadBytesPerSecond,
- WriteBytesPerSecond,
- HandleCount,
- ThreadCount,
- StartTime,
- Priority,
- SessionId,
- CommandLine,
-}
-
-public static class ProcessColumnExtensions {
-
- /// The header text, and what a `--sort=` argument accepts (case-insensitively).
- public static string ToHeader(this ProcessColumn column) => column switch {
- ProcessColumn.Name => "Process",
- ProcessColumn.Pid => "PID",
- ProcessColumn.ParentPid => "PPID",
- ProcessColumn.UserName => "User",
- ProcessColumn.State => "State",
- ProcessColumn.CpuPercent => "CPU",
- ProcessColumn.PrivateBytes => "Private",
- ProcessColumn.WorkingSetBytes => "Working set",
- ProcessColumn.VirtualBytes => "Virtual",
- ProcessColumn.ReadBytesPerSecond => "Read/s",
- ProcessColumn.WriteBytesPerSecond => "Write/s",
- ProcessColumn.HandleCount => "Handles",
- ProcessColumn.ThreadCount => "Threads",
- ProcessColumn.StartTime => "Started",
- ProcessColumn.Priority => "Priority",
- ProcessColumn.SessionId => "Session",
- ProcessColumn.CommandLine => "Command line",
- _ => column.ToString(),
- };
-
- ///
- /// Whether bigger should come first when the column is picked. Sorting by CPU ascending is not
- /// what anybody wants from one keypress, and sorting names descending is not either.
- ///
- public static bool PrefersDescending(this ProcessColumn column) => column switch {
- ProcessColumn.Name or ProcessColumn.UserName or ProcessColumn.CommandLine
- or ProcessColumn.State or ProcessColumn.Pid or ProcessColumn.ParentPid => false,
- _ => true,
- };
-
- public static bool TryParse(string? text, out ProcessColumn column) {
- column = ProcessColumn.CpuPercent;
- if (string.IsNullOrWhiteSpace(text))
- return false;
-
- switch (text.Trim().ToLowerInvariant()) {
- case "name" or "process" or "comm": column = ProcessColumn.Name; return true;
- case "pid": column = ProcessColumn.Pid; return true;
- case "ppid" or "parent": column = ProcessColumn.ParentPid; return true;
- case "user" or "owner": column = ProcessColumn.UserName; return true;
- case "state" or "status": column = ProcessColumn.State; return true;
- case "cpu": column = ProcessColumn.CpuPercent; return true;
- case "mem" or "memory" or "private" or "pss": column = ProcessColumn.PrivateBytes; return true;
- case "rss" or "ws" or "workingset": column = ProcessColumn.WorkingSetBytes; return true;
- case "virt" or "virtual": column = ProcessColumn.VirtualBytes; return true;
- case "read": column = ProcessColumn.ReadBytesPerSecond; return true;
- case "write": column = ProcessColumn.WriteBytesPerSecond; return true;
- case "handles" or "fds": column = ProcessColumn.HandleCount; return true;
- case "threads": column = ProcessColumn.ThreadCount; return true;
- case "start" or "started": column = ProcessColumn.StartTime; return true;
- case "prio" or "priority": column = ProcessColumn.Priority; return true;
- case "session": column = ProcessColumn.SessionId; return true;
- case "cmd" or "cmdline" or "commandline": column = ProcessColumn.CommandLine; return true;
- default: return false;
- }
- }
-
-}
diff --git a/ProcessManager.Core/Query/ProcessField.cs b/ProcessManager.Core/Query/ProcessField.cs
new file mode 100644
index 0000000..e4969e3
--- /dev/null
+++ b/ProcessManager.Core/Query/ProcessField.cs
@@ -0,0 +1,190 @@
+using Hawkynt.ProcessManager.Sampling;
+
+namespace Hawkynt.ProcessManager.Query;
+
+///
+/// Every value a process row can show, sort by or be filtered on.
+///
+///
+/// One enum for the whole program, deliberately. There used to be three lists — a sort-key enum here,
+/// a column set in the window and a third in the terminal — which meant adding a field meant editing
+/// three places, and three places is three places to forget one. PRD §5.1 and §103.
+///
+/// The order is the default column order, which is Process Explorer's rather than alphabetical.
+///
+///
+public enum ProcessField : byte {
+
+ Name = 0,
+ Pid,
+ PidHex,
+ ParentPid,
+ UserName,
+ State,
+
+ CpuPercent,
+ CpuPercentPerCore,
+ CpuTime,
+ CyclesDelta,
+ ContextSwitchesDelta,
+ CpuHistory,
+
+ PrivateBytes,
+ PrivateBytesDelta,
+ PrivateWorkingSet,
+ MemoryHistory,
+ WorkingSetBytes,
+ PeakWorkingSet,
+ VirtualBytes,
+ PeakVirtualBytes,
+ PagedPool,
+ PeakPagedPool,
+ NonPagedPool,
+ PeakNonPagedPool,
+ PageFaultsDelta,
+ Swap,
+
+ IoTotalRate,
+ ReadBytesPerSecond,
+ WriteBytesPerSecond,
+ IoHistory,
+
+ ThreadCount,
+ HandleCount,
+ Priority,
+ SessionId,
+ StartTime,
+ Container,
+ ImagePath,
+ CommandLine,
+
+}
+
+///
+/// What kind of number a field is, which decides whether it may be averaged, summed, or graphed at
+/// all (PRD §5.1).
+///
+public enum FieldKind : byte {
+
+ /// Free text — a name, a path, a command line.
+ Text,
+
+ /// An identifier that happens to be numeric. Sorts numerically, never summed.
+ Identifier,
+
+ /// A value that is true right now and has no history of its own.
+ Instant,
+
+ /// Monotonic since the process started. The interesting figure is its derivative.
+ Cumulative,
+
+ /// The change in a cumulative counter over one interval.
+ Delta,
+
+ /// A per-second figure derived from two samples.
+ Rate,
+
+ /// One of a fixed set of states.
+ State,
+
+ /// A drawn history rather than a value; has no text and cannot be sorted.
+ Graph,
+
+}
+
+/// What the number counts, which decides how it is formatted and how a filter parses it.
+public enum FieldUnit : byte {
+ None,
+ Bytes,
+ BytesPerSecond,
+ Percent,
+ Nanoseconds,
+ Count,
+ CountPerSecond,
+ Timestamp,
+}
+
+///
+/// What reading the field costs, so an expensive one is never made default-visible by accident
+/// (PRD §5.4).
+///
+public enum FieldCost : byte {
+
+ /// Already in the snapshot; showing it costs nothing at all.
+ Free,
+
+ /// Needs a second sample, and nothing else.
+ Derived,
+
+ /// Costs a syscall or more per process. Never default-visible.
+ High,
+
+}
+
+/// Which platforms can fill a field. A platform not listed renders n/a, not zero.
+[Flags]
+public enum FieldPlatforms : byte {
+ None = 0,
+ Windows = 1,
+ Linux = 2,
+ MacOS = 4,
+ All = Windows | Linux | MacOS,
+}
+
+///
+/// Everything the program knows about one field: how to label it, how wide to draw it, what it
+/// means, and who can fill it.
+///
+/// The enum value.
+///
+/// The stable identifier. This is what a saved layout, a --sort argument and a search term all
+/// use, and it never changes even when the header does — including when the header differs per
+/// platform, which is the point (PRD §5.3).
+///
+/// The full label, for the window.
+/// The narrow label, for the terminal.
+/// One sentence, for the tooltip and the column chooser.
+/// Pixels.
+/// Character cells.
+///
+/// Whether biggest-first is what a single click should give. Sorting by CPU ascending is not what
+/// anybody wants from one keypress, and sorting names descending is not either.
+///
+public sealed record FieldDescriptor(
+ ProcessField Id,
+ string Key,
+ string Header,
+ string ShortHeader,
+ string Description,
+ FieldKind Kind,
+ FieldUnit Unit,
+ FieldPlatforms Platforms,
+ FieldCost Cost,
+ int DesktopWidth,
+ int TerminalWidth,
+ bool RightAligned,
+ bool PrefersDescending,
+ HistorySeries? Series = null,
+ string? Aliases = null
+) {
+
+ /// True for the three drawn histories, which have no text and no sort order.
+ public bool IsGraph => this.Kind == FieldKind.Graph;
+
+ /// False for graphs, true for everything else.
+ public bool IsSortable => this.Kind != FieldKind.Graph;
+
+ ///
+ /// Whether this field can hold a number on this machine at all — used to decide between showing a
+ /// value and showing why there is none.
+ ///
+ public bool IsSupportedHere => (this.Platforms & CurrentPlatform) != 0;
+
+ /// Which flag this machine is.
+ public static FieldPlatforms CurrentPlatform { get; } =
+ OperatingSystem.IsWindows() ? FieldPlatforms.Windows
+ : OperatingSystem.IsLinux() ? FieldPlatforms.Linux
+ : OperatingSystem.IsMacOS() ? FieldPlatforms.MacOS
+ : FieldPlatforms.None;
+
+}
diff --git a/ProcessManager.Core/Query/ProcessView.cs b/ProcessManager.Core/Query/ProcessView.cs
index cb6b481..cab933d 100644
--- a/ProcessManager.Core/Query/ProcessView.cs
+++ b/ProcessManager.Core/Query/ProcessView.cs
@@ -31,7 +31,7 @@ public sealed class ProcessView {
public ProcessView() => this._comparer = Comparer.Create(this.Compare);
- public ProcessColumn SortColumn { get; set; } = ProcessColumn.CpuPercent;
+ public ProcessField SortColumn { get; set; } = ProcessField.CpuPercent;
public bool SortDescending { get; set; } = true;
@@ -249,48 +249,11 @@ private int Compare(int left, int right) {
private int CompareAscending(int left, int right) {
var processes = this._snapshot!.Processes;
- ref readonly var a = ref processes[left];
- ref readonly var b = ref processes[right];
-
- return this.SortColumn switch {
- ProcessColumn.Name => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase),
- ProcessColumn.Pid => a.Pid.CompareTo(b.Pid),
- ProcessColumn.ParentPid => a.ParentPid.CompareTo(b.ParentPid),
- ProcessColumn.UserName => string.Compare(a.UserName, b.UserName, StringComparison.OrdinalIgnoreCase),
- ProcessColumn.State => ((byte)a.State).CompareTo((byte)b.State),
- ProcessColumn.CpuPercent => CompareRate(this._delta!.CpuPercent(left), this._delta.CpuPercent(right)),
- ProcessColumn.PrivateBytes => CompareCounter(a.PrivateBytes, b.PrivateBytes),
- ProcessColumn.WorkingSetBytes => CompareCounter(a.WorkingSetBytes, b.WorkingSetBytes),
- ProcessColumn.VirtualBytes => CompareCounter(a.VirtualBytes, b.VirtualBytes),
- ProcessColumn.ReadBytesPerSecond
- => CompareRate(this._delta!.ReadBytesPerSecond(left), this._delta.ReadBytesPerSecond(right)),
- ProcessColumn.WriteBytesPerSecond
- => CompareRate(this._delta!.WriteBytesPerSecond(left), this._delta.WriteBytesPerSecond(right)),
- ProcessColumn.HandleCount => CompareCounter(a.HandleCount, b.HandleCount),
- ProcessColumn.ThreadCount => a.ThreadCount.CompareTo(b.ThreadCount),
- ProcessColumn.StartTime => a.StartTimeUtcTicks.CompareTo(b.StartTimeUtcTicks),
- ProcessColumn.Priority => a.Priority.CompareTo(b.Priority),
- ProcessColumn.SessionId => a.SessionId.CompareTo(b.SessionId),
- ProcessColumn.CommandLine => string.Compare(a.CommandLine, b.CommandLine, StringComparison.OrdinalIgnoreCase),
- _ => 0,
- };
- }
- // A value that is not there sorts below every value that is, in ascending order — so reversing the
- // sort puts the readable rows on top either way, instead of a block of dashes.
- private static int CompareCounter(Counter left, Counter right) => (left.HasValue, right.HasValue) switch {
- (true, true) => left.Value.CompareTo(right.Value),
- (true, false) => 1,
- (false, true) => -1,
- _ => 0,
- };
-
- private static int CompareRate(Rate left, Rate right) => (left.HasValue, right.HasValue) switch {
- (true, true) => left.Value.CompareTo(right.Value),
- (true, false) => 1,
- (false, true) => -1,
- _ => 0,
- };
+ // Every field, in one place, shared with both front-ends: sorting by a column and the text that
+ // column shows are now read out of the same accessor, so they cannot drift apart (PRD §5.1).
+ return FieldAccessor.Compare(this.SortColumn, in processes[left], left, in processes[right], right, this._delta);
+ }
private static void EnsureLength(ref T[] array, int length) {
if (array.Length < length)
diff --git a/ProcessManager.Tests/FieldRegistryTests.cs b/ProcessManager.Tests/FieldRegistryTests.cs
new file mode 100644
index 0000000..594cfa5
--- /dev/null
+++ b/ProcessManager.Tests/FieldRegistryTests.cs
@@ -0,0 +1,265 @@
+using Hawkynt.ProcessManager.Model;
+using Hawkynt.ProcessManager.Query;
+using Hawkynt.ProcessManager.Sampling;
+
+namespace Hawkynt.ProcessManager.Tests;
+
+///
+/// The field catalogue (PRD §5.1) and the rule that nothing may be added to a front-end without
+/// going through it (PRD §103).
+///
+///
+/// §103 said "a CI check enforces this" and nothing did, which made it a convention rather than a
+/// rule. is that check: adding a value to
+/// and forgetting the descriptor now fails the build rather than
+/// producing a column with no header that sorts by nothing.
+///
+[TestFixture]
+public sealed class FieldRegistryTests {
+
+ [Test]
+ public void EveryFieldInTheEnumIsRegistered() {
+ var missing = new List();
+ foreach (ProcessField field in Enum.GetValues())
+ if (FieldRegistry.Get(field).Id != field)
+ missing.Add(field);
+
+ Assert.That(missing, Is.Empty, "these fields have no descriptor in FieldRegistry.All");
+ }
+
+ [Test]
+ public void EveryRegisteredFieldIsInTheEnum() {
+ foreach (var descriptor in FieldRegistry.All)
+ Assert.That(Enum.IsDefined(descriptor.Id), Is.True, $"{descriptor.Key} is not a ProcessField");
+ }
+
+ [Test]
+ public void KeysAreUniqueAndSoAreHeaders() {
+ var keys = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var headers = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var descriptor in FieldRegistry.All) {
+ Assert.That(keys.Add(descriptor.Key), Is.True, $"duplicate key: {descriptor.Key}");
+ Assert.That(headers.Add(descriptor.Header), Is.True, $"duplicate header: {descriptor.Header}");
+ }
+ }
+
+ ///
+ /// An alias that collides with another field's key would resolve to whichever came first in the
+ /// array, which is a sorting order nobody chose.
+ ///
+ [Test]
+ public void NoAliasCollidesWithAnotherFieldsKeyOrAlias() {
+ var seen = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var descriptor in FieldRegistry.All) {
+ Register(descriptor.Key, descriptor.Key);
+ if (descriptor.Aliases is not { } aliases)
+ continue;
+
+ foreach (var alias in aliases.Split(' ', StringSplitOptions.RemoveEmptyEntries))
+ Register(alias, descriptor.Key);
+ }
+
+ void Register(string spelling, string owner) {
+ Assert.That(
+ seen.TryAdd(spelling, owner),
+ Is.True,
+ $"'{spelling}' is claimed by both {owner} and {(seen.TryGetValue(spelling, out var other) ? other : "?")}"
+ );
+ }
+ }
+
+ [Test]
+ public void EveryKeyAndAliasParsesBackToItsOwnField() {
+ foreach (var descriptor in FieldRegistry.All) {
+ Assert.That(FieldRegistry.TryParse(descriptor.Key, out var byKey), Is.True, descriptor.Key);
+ Assert.That(byKey, Is.EqualTo(descriptor.Id));
+
+ Assert.That(FieldRegistry.TryParse(descriptor.Header, out var byHeader), Is.True, descriptor.Header);
+ Assert.That(byHeader, Is.EqualTo(descriptor.Id));
+
+ // Case and surrounding space must not matter: this is what a command line hands us.
+ Assert.That(FieldRegistry.TryParse($" {descriptor.Key.ToUpperInvariant()} ", out var loose), Is.True);
+ Assert.That(loose, Is.EqualTo(descriptor.Id));
+
+ if (descriptor.Aliases is not { } aliases)
+ continue;
+
+ foreach (var alias in aliases.Split(' ', StringSplitOptions.RemoveEmptyEntries)) {
+ Assert.That(FieldRegistry.TryParse(alias, out var byAlias), Is.True, alias);
+ Assert.That(byAlias, Is.EqualTo(descriptor.Id), alias);
+ }
+ }
+ }
+
+ [Test]
+ public void NonsenseDoesNotParse() {
+ Assert.That(FieldRegistry.TryParse("not-a-field", out _), Is.False);
+ Assert.That(FieldRegistry.TryParse("", out _), Is.False);
+ Assert.That(FieldRegistry.TryParse(null, out _), Is.False);
+ Assert.That(FieldRegistry.TryParse(" ", out _), Is.False);
+ }
+
+ [Test]
+ public void GraphsAreNotSortableAndEverythingElseIs() {
+ foreach (var descriptor in FieldRegistry.All)
+ Assert.That(
+ descriptor.IsSortable,
+ Is.EqualTo(descriptor.Kind != FieldKind.Graph),
+ $"{descriptor.Key}: a drawn column has no order and a written one must have"
+ );
+ }
+
+ ///
+ /// Widths are what stop a header being clipped to something that names nothing — "PU %" was a real
+ /// one. Every header must fit the column that carries it.
+ ///
+ [Test]
+ public void EveryShortHeaderFitsItsTerminalColumn() {
+ foreach (var descriptor in FieldRegistry.All)
+ Assert.That(
+ descriptor.ShortHeader.Length,
+ Is.LessThanOrEqualTo(descriptor.TerminalWidth),
+ $"{descriptor.Key}: '{descriptor.ShortHeader}' does not fit {descriptor.TerminalWidth} cells"
+ );
+ }
+
+ [Test]
+ public void AnExpensiveFieldIsNeverInTheDefaultTerminalColumns() {
+ // PRD §5.4: displaying the ordinary process table must not require an expensive collector. The
+ // handle count is the one exception, and it is sampled on its own schedule for that reason.
+ foreach (var field in new[] { ProcessField.CpuPercent, ProcessField.PrivateBytes, ProcessField.Name })
+ Assert.That(FieldRegistry.Get(field).Cost, Is.Not.EqualTo(FieldCost.High), field.ToString());
+ }
+
+ #region reading a field
+
+ [Test]
+ public void EveryFieldCanBeReadFromAProcessWithoutThrowing() {
+ var snapshot = OneProcess();
+ var delta = new SnapshotDelta();
+ delta.Update(null, snapshot, CpuPercentMode.Normalized);
+
+ foreach (var descriptor in FieldRegistry.All) {
+ // The point is that none of these throw, whether or not the platform fills the field.
+ var text = FieldAccessor.Text(descriptor.Id, in snapshot.Processes[0], delta, 0);
+ Assert.That(text, Is.Not.Null, descriptor.Key);
+ _ = FieldAccessor.Number(descriptor.Id, in snapshot.Processes[0], delta, 0);
+ _ = FieldAccessor.RawText(descriptor.Id, in snapshot.Processes[0]);
+ }
+ }
+
+ ///
+ /// Before a second sample every derived field must read as "not sampled yet" rather than as zero —
+ /// a fresh window showing 0.0% CPU for everything is a window that is lying (PRD §72.3).
+ ///
+ [Test]
+ public void ADerivedFieldWithNoSecondSampleReadsAsPendingRatherThanZero() {
+ var snapshot = OneProcess();
+ var delta = new SnapshotDelta();
+ delta.Update(null, snapshot, CpuPercentMode.Normalized);
+
+ var pending = Humanize.Placeholder(UnknownReason.NotSampledYet);
+ foreach (var field in new[] {
+ ProcessField.CpuPercent, ProcessField.CpuPercentPerCore, ProcessField.ReadBytesPerSecond,
+ ProcessField.WriteBytesPerSecond, ProcessField.IoTotalRate, ProcessField.PageFaultsDelta,
+ }) {
+ Assert.That(FieldAccessor.Text(field, in snapshot.Processes[0], delta, 0), Is.EqualTo(pending), field.ToString());
+ Assert.That(FieldAccessor.Number(field, in snapshot.Processes[0], delta, 0), Is.Null, field.ToString());
+ }
+ }
+
+ ///
+ /// A field the platform does not report has no number, and a filter must not treat that as zero:
+ /// "memory > 0" should not match a process whose memory is unknown, and neither should
+ /// "memory == 0".
+ ///
+ [Test]
+ public void AnUnknownCounterHasNoNumberAtAll() {
+ var snapshot = new SystemSnapshot();
+ var records = snapshot.PrepareProcesses(1);
+ records[0] = default;
+ records[0].Key = new(1, 1);
+ records[0].Name = "test";
+ records[0].PrivateBytes = Counter.NotSupported;
+
+ var delta = new SnapshotDelta();
+ delta.Update(null, snapshot, CpuPercentMode.Normalized);
+
+ Assert.That(FieldAccessor.Number(ProcessField.PrivateBytes, in snapshot.Processes[0], delta, 0), Is.Null);
+ Assert.That(
+ FieldAccessor.Text(ProcessField.PrivateBytes, in snapshot.Processes[0], delta, 0),
+ Is.EqualTo(Humanize.Placeholder(UnknownReason.NotSupportedOnPlatform))
+ );
+ }
+
+ ///
+ /// Sorting by a column and the text that column shows are read from the same place, so an order
+ /// that disagrees with the display is not possible. This checks the halves agree.
+ ///
+ [Test]
+ public void SortingAgreesWithTheNumbersTheColumnShows() {
+ var snapshot = new SystemSnapshot();
+ var records = snapshot.PrepareProcesses(3);
+ for (var i = 0; i < 3; ++i) {
+ records[i] = default;
+ records[i].Key = new(i + 1, (ulong)(i + 1));
+ records[i].Name = "p" + i;
+ records[i].WorkingSetBytes = Counter.Of((ulong)((3 - i) * 1024));
+ records[i].ThreadCount = i;
+ }
+
+ var delta = new SnapshotDelta();
+ delta.Update(null, snapshot, CpuPercentMode.Normalized);
+ var processes = snapshot.Processes;
+
+ // Working set descends across the three; thread count ascends. Compare must say so both times.
+ Assert.That(FieldAccessor.Compare(ProcessField.WorkingSetBytes, in processes[0], 0, in processes[1], 1, delta), Is.GreaterThan(0));
+ Assert.That(FieldAccessor.Compare(ProcessField.ThreadCount, in processes[0], 0, in processes[1], 1, delta), Is.LessThan(0));
+ Assert.That(FieldAccessor.Compare(ProcessField.Name, in processes[0], 0, in processes[1], 1, delta), Is.LessThan(0));
+ }
+
+ /// An unknown value sorts below every known one, so reversing puts readable rows on top.
+ [Test]
+ public void AnUnknownValueSortsBelowEveryKnownOne() {
+ var snapshot = new SystemSnapshot();
+ var records = snapshot.PrepareProcesses(2);
+ for (var i = 0; i < 2; ++i) {
+ records[i] = default;
+ records[i].Key = new(i + 1, (ulong)(i + 1));
+ records[i].Name = "p" + i;
+ }
+
+ records[0].PrivateBytes = Counter.NotSupported;
+ records[1].PrivateBytes = Counter.Of(0ul);
+
+ var delta = new SnapshotDelta();
+ delta.Update(null, snapshot, CpuPercentMode.Normalized);
+ var processes = snapshot.Processes;
+
+ // Even against a real zero, which is the case that matters: unknown is not zero.
+ Assert.That(FieldAccessor.Compare(ProcessField.PrivateBytes, in processes[0], 0, in processes[1], 1, delta), Is.LessThan(0));
+ Assert.That(FieldAccessor.Compare(ProcessField.PrivateBytes, in processes[1], 1, in processes[0], 0, delta), Is.GreaterThan(0));
+ }
+
+ #endregion
+
+ private static SystemSnapshot OneProcess() {
+ var snapshot = new SystemSnapshot();
+ var records = snapshot.PrepareProcesses(1);
+ records[0] = default;
+ records[0].Key = new(4242, 100);
+ records[0].Name = "test";
+ records[0].UserName = "alice";
+ records[0].ParentPid = 1;
+ records[0].ThreadCount = 3;
+ records[0].SessionId = 1;
+ records[0].StartTimeUtcTicks = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks;
+ records[0].CpuTimeNs = Counter.Of(1_000_000_000ul);
+ records[0].PrivateBytes = Counter.Of(1024ul * 1024);
+ records[0].WorkingSetBytes = Counter.Of(2048ul * 1024);
+ records[0].CommandLine = "/usr/bin/test --flag";
+ records[0].ImagePath = "/usr/bin/test";
+ return snapshot;
+ }
+
+}
diff --git a/ProcessManager.Tests/LinuxProbeTests.cs b/ProcessManager.Tests/LinuxProbeTests.cs
index b1d6e1d..6f3a22f 100644
--- a/ProcessManager.Tests/LinuxProbeTests.cs
+++ b/ProcessManager.Tests/LinuxProbeTests.cs
@@ -194,7 +194,7 @@ public void TheProcessTreeMatchesTheFixture() {
using var sampler = new Sampler(probe);
sampler.Sample();
- var view = new ProcessView { TreeMode = true, SortColumn = ProcessColumn.Pid, SortDescending = false };
+ var view = new ProcessView { TreeMode = true, SortColumn = ProcessField.Pid, SortDescending = false };
view.Rebuild(sampler.Current, sampler.Delta);
var lines = new List();
diff --git a/ProcessManager.Tests/ProcessTreeBinderTests.cs b/ProcessManager.Tests/ProcessTreeBinderTests.cs
index 261119b..ac08e71 100644
--- a/ProcessManager.Tests/ProcessTreeBinderTests.cs
+++ b/ProcessManager.Tests/ProcessTreeBinderTests.cs
@@ -126,7 +126,7 @@ public void TheTreeIsReorderedToMatchTheSort() {
var binder = new ProcessTreeBinder(tree);
var (snapshot, delta, view) = Build((10, 0), (20, 0), (30, 0));
- view.SortColumn = ProcessColumn.Pid;
+ view.SortColumn = ProcessField.Pid;
view.SortDescending = false;
view.Rebuild(snapshot, delta);
binder.Sync(snapshot, delta, view);
@@ -145,7 +145,7 @@ public void ReorderingKeepsTheSameNodeObjects() {
var tree = new TreeListView();
var binder = new ProcessTreeBinder(tree);
var (snapshot, delta, view) = Build((10, 0), (20, 0));
- view.SortColumn = ProcessColumn.Pid;
+ view.SortColumn = ProcessField.Pid;
view.SortDescending = false;
view.Rebuild(snapshot, delta);
binder.Sync(snapshot, delta, view);
@@ -166,7 +166,7 @@ public void ChildrenAreOrderedWithinTheirParent() {
var tree = new TreeListView();
var binder = new ProcessTreeBinder(tree);
var (snapshot, delta, view) = Build((1, 0), (10, 1), (20, 1), (30, 1));
- view.SortColumn = ProcessColumn.Pid;
+ view.SortColumn = ProcessField.Pid;
view.SortDescending = true;
view.Rebuild(snapshot, delta);
binder.Sync(snapshot, delta, view);
@@ -221,7 +221,7 @@ private static (SystemSnapshot Snapshot, SnapshotDelta Delta, ProcessView View)
var delta = new SnapshotDelta();
delta.Update(null, snapshot, CpuPercentMode.Normalized);
- var view = new ProcessView { TreeMode = true, SortColumn = ProcessColumn.Pid, SortDescending = false };
+ var view = new ProcessView { TreeMode = true, SortColumn = ProcessField.Pid, SortDescending = false };
view.Rebuild(snapshot, delta);
return (snapshot, delta, view);
}
diff --git a/ProcessManager.Tests/ProcessViewTests.cs b/ProcessManager.Tests/ProcessViewTests.cs
index 11f4bbb..59f3e7b 100644
--- a/ProcessManager.Tests/ProcessViewTests.cs
+++ b/ProcessManager.Tests/ProcessViewTests.cs
@@ -14,7 +14,7 @@ public sealed class ProcessViewTests {
[Test]
public void AFlatViewShowsEveryProcessOnce() {
var (snapshot, delta) = Build((1, 0), (2, 1), (3, 2));
- var view = new ProcessView { TreeMode = false, SortColumn = ProcessColumn.Pid, SortDescending = false };
+ var view = new ProcessView { TreeMode = false, SortColumn = ProcessField.Pid, SortDescending = false };
view.Rebuild(snapshot, delta);
Assert.That(view.RowCount, Is.EqualTo(3));
@@ -24,7 +24,7 @@ public void AFlatViewShowsEveryProcessOnce() {
[Test]
public void ATreeNestsChildrenUnderParents() {
var (snapshot, delta) = Build((1, 0), (2, 1), (3, 2), (4, 1));
- var view = new ProcessView { TreeMode = true, SortColumn = ProcessColumn.Pid, SortDescending = false };
+ var view = new ProcessView { TreeMode = true, SortColumn = ProcessField.Pid, SortDescending = false };
view.Rebuild(snapshot, delta);
Assert.That(Pids(snapshot, view), Is.EqualTo(new[] { 1, 2, 3, 4 }));
@@ -38,7 +38,7 @@ public void AProcessWhoseParentIsGoneBecomesARoot() {
// Its parent exited and it was reparented, or it lives in another pid namespace. Either way it
// is still running and must still be listed.
var (snapshot, delta) = Build((1, 0), (5, 999));
- var view = new ProcessView { TreeMode = true, SortColumn = ProcessColumn.Pid, SortDescending = false };
+ var view = new ProcessView { TreeMode = true, SortColumn = ProcessField.Pid, SortDescending = false };
view.Rebuild(snapshot, delta);
Assert.That(view.RowCount, Is.EqualTo(2));
@@ -50,7 +50,7 @@ public void ACycleDoesNotHangTheWalk() {
// Should be impossible; observed anyway across namespace boundaries. The link that closes the
// cycle is cut, and every process still appears exactly once.
var (snapshot, delta) = Build((10, 11), (11, 10));
- var view = new ProcessView { TreeMode = true, SortColumn = ProcessColumn.Pid, SortDescending = false };
+ var view = new ProcessView { TreeMode = true, SortColumn = ProcessField.Pid, SortDescending = false };
Assert.That(() => view.Rebuild(snapshot, delta), Throws.Nothing);
Assert.That(view.RowCount, Is.EqualTo(2));
@@ -72,7 +72,7 @@ public void FilteringInTreeModeKeepsTheAncestorsOfAMatch() {
var (snapshot, delta) = Build((1, 0), (2, 1), (3, 2));
Rename(snapshot, 3, "needle");
- var view = new ProcessView { TreeMode = true, TextFilter = "needle", SortColumn = ProcessColumn.Pid, SortDescending = false };
+ var view = new ProcessView { TreeMode = true, TextFilter = "needle", SortColumn = ProcessField.Pid, SortDescending = false };
view.Rebuild(snapshot, delta);
Assert.That(Pids(snapshot, view), Is.EqualTo(new[] { 1, 2, 3 }));
@@ -96,7 +96,7 @@ public void ATieIsBrokenByPidSoRowsDoNotJumpBetweenSamples() {
// Everything below has the same sort key. If the order were not pinned, a re-sort could move a
// row under the pointer between hover and click — which is how the wrong process gets killed.
var (snapshot, delta) = Build((30, 0), (10, 0), (20, 0));
- var view = new ProcessView { SortColumn = ProcessColumn.ThreadCount, SortDescending = true };
+ var view = new ProcessView { SortColumn = ProcessField.ThreadCount, SortDescending = true };
view.Rebuild(snapshot, delta);
Assert.That(Pids(snapshot, view), Is.EqualTo(new[] { 10, 20, 30 }));
@@ -109,7 +109,7 @@ public void AValueThatIsNotThereSortsBelowEveryValueThatIs() {
SetPrivate(snapshot, 2, Counter.NotPermitted);
SetPrivate(snapshot, 3, Counter.Of(500ul));
- var view = new ProcessView { SortColumn = ProcessColumn.PrivateBytes, SortDescending = true };
+ var view = new ProcessView { SortColumn = ProcessField.PrivateBytes, SortDescending = true };
view.Rebuild(snapshot, delta);
Assert.That(Pids(snapshot, view), Is.EqualTo(new[] { 3, 1, 2 }));
@@ -118,7 +118,7 @@ public void AValueThatIsNotThereSortsBelowEveryValueThatIs() {
[Test]
public void FindRowLocatesAProcessByIdentityRatherThanByPosition() {
var (snapshot, delta) = Build((1, 0), (2, 1), (3, 1));
- var view = new ProcessView { SortColumn = ProcessColumn.Pid, SortDescending = false };
+ var view = new ProcessView { SortColumn = ProcessField.Pid, SortDescending = false };
view.Rebuild(snapshot, delta);
var key = snapshot.Processes[1].Key;
diff --git a/ProcessManager.Tests/SparklineTests.cs b/ProcessManager.Tests/SparklineTests.cs
index d8bb6e9..a93f4f9 100644
--- a/ProcessManager.Tests/SparklineTests.cs
+++ b/ProcessManager.Tests/SparklineTests.cs
@@ -101,7 +101,7 @@ private static (SystemSnapshot Snapshot, SnapshotDelta Delta, ProcessView View)
var delta = new SnapshotDelta();
delta.Update(null, snapshot, CpuPercentMode.Normalized);
- var view = new ProcessView { SortColumn = ProcessColumn.Pid, SortDescending = false };
+ var view = new ProcessView { SortColumn = ProcessField.Pid, SortDescending = false };
view.Rebuild(snapshot, delta);
return (snapshot, delta, view);
}
diff --git a/ProcessManager.Tests/TerminalUiTests.cs b/ProcessManager.Tests/TerminalUiTests.cs
index 21b6c89..24d422d 100644
--- a/ProcessManager.Tests/TerminalUiTests.cs
+++ b/ProcessManager.Tests/TerminalUiTests.cs
@@ -126,7 +126,7 @@ public void TheComposedFrameMatchesTheGoldenOne() {
UseBlockCharacters = true,
};
ui.View.TreeMode = true;
- ui.View.SortColumn = ProcessColumn.Pid;
+ ui.View.SortColumn = ProcessField.Pid;
ui.View.SortDescending = false;
ui.Update();
ui.Update();
@@ -254,7 +254,7 @@ static string Compose(Sampler sampler, LinuxProbe probe, bool unicode) {
};
ui.View.TreeMode = true;
- ui.View.SortColumn = ProcessColumn.Pid;
+ ui.View.SortColumn = ProcessField.Pid;
ui.View.SortDescending = false;
ui.Update();
ui.Update();
diff --git a/ProcessManager.Ui.Desktop/ColumnChooser.cs b/ProcessManager.Ui.Desktop/ColumnChooser.cs
index aff5cbc..83e5304 100644
--- a/ProcessManager.Ui.Desktop/ColumnChooser.cs
+++ b/ProcessManager.Ui.Desktop/ColumnChooser.cs
@@ -1,3 +1,4 @@
+using Hawkynt.ProcessManager.Query;
using Hawkynt.NativeForms;
namespace Hawkynt.ProcessManager.Ui.Desktop;
@@ -13,9 +14,9 @@ namespace Hawkynt.ProcessManager.Ui.Desktop;
public sealed class ColumnChooser : Form {
private readonly CheckedListBox _list = new();
- private readonly List _order = [];
+ private readonly List _order = [];
- public ColumnChooser(IReadOnlyCollection visible) {
+ public ColumnChooser(IReadOnlyCollection visible) {
ArgumentNullException.ThrowIfNull(visible);
this.Text = "Select columns";
@@ -23,9 +24,9 @@ public ColumnChooser(IReadOnlyCollection visible) {
this._list.Bounds = new(12, 12, 344, 372);
foreach (var info in ColumnSet.All) {
- this._order.Add(info.Column);
+ this._order.Add(info.Id);
this._list.Items.Add(info.Header);
- this._list.SetItemChecked(this._list.Items.Count - 1, visible.Contains(info.Column));
+ this._list.SetItemChecked(this._list.Items.Count - 1, visible.Contains(info.Id));
}
var ok = new Button { Text = "OK", Bounds = new(180, 394, 80, 28) };
@@ -49,15 +50,15 @@ public ColumnChooser(IReadOnlyCollection visible) {
/// What was ticked. The name column is forced on: a list whose rows have no name is a list of
/// numbers.
///
- public List Selection {
+ public List Selection {
get {
- var result = new List();
+ var result = new List();
for (var i = 0; i < this._order.Count; ++i)
if (this._list.GetItemChecked(i))
result.Add(this._order[i]);
- if (!result.Contains(DesktopColumn.Name))
- result.Insert(0, DesktopColumn.Name);
+ if (!result.Contains(ProcessField.Name))
+ result.Insert(0, ProcessField.Name);
return result;
}
diff --git a/ProcessManager.Ui.Desktop/ColumnSet.cs b/ProcessManager.Ui.Desktop/ColumnSet.cs
index f38df02..56bb9f8 100644
--- a/ProcessManager.Ui.Desktop/ColumnSet.cs
+++ b/ProcessManager.Ui.Desktop/ColumnSet.cs
@@ -1,141 +1,47 @@
using Hawkynt.ProcessManager.Query;
-using Hawkynt.ProcessManager.Sampling;
namespace Hawkynt.ProcessManager.Ui.Desktop;
-/// Every column the process list can show.
-public enum DesktopColumn : byte {
- Name = 0,
- Pid,
- PidHex,
- ParentPid,
- User,
- State,
- CpuPercent,
- CpuPerCore,
- CpuTime,
- CyclesDelta,
- ContextSwitchDelta,
- CpuHistory,
- PrivateBytes,
- PrivateBytesDelta,
- PrivateWorkingSet,
- MemoryHistory,
- WorkingSet,
- PeakWorkingSet,
- VirtualBytes,
- PeakVirtualBytes,
- PagedPool,
- PeakPagedPool,
- NonPagedPool,
- PeakNonPagedPool,
- PageFaultDelta,
- Swap,
- IoTotalRate,
- ReadRate,
- WriteRate,
- IoHistory,
- Threads,
- Handles,
- Priority,
- Session,
- Started,
- Container,
- ImagePath,
- CommandLine,
-}
-
///
-/// What each column is: its header, its width, whether it is a graph, and how to sort by it.
+/// Which columns the window shows, and which it offers.
///
///
-/// The three history columns are the reason the whole set is data rather than a hard-coded list of
-/// Columns.Add calls — they are drawn rather than written, they need a series from
-/// , and they have no text to sort by.
+/// There is no list of columns here any more. Every column the window can show is a field in
+/// , with its header, width, alignment and sort order declared once and
+/// shared with the terminal (PRD §5.1). What is left is the two things that really are the window's
+/// own business: which columns it opens with, and in what order the chooser lists them.
///
-public readonly record struct ColumnInfo(
- DesktopColumn Column,
- string Header,
- int Width,
- bool RightAligned,
- ProcessColumn? SortBy,
- HistorySeries? Series
-);
-
-public static class ColumnSet {
+internal static class ColumnSet {
- ///
- /// Every column, in the order the chooser lists them.
- ///
- ///
- /// A column exists here when the engine can actually fill it on at least one platform. Where the
- /// other platform cannot, the cell says which reason applies rather than showing a zero (§3.4) —
- /// cycles and the pool quotas are Windows-only, and read as n/a on Linux.
- ///
- public static readonly ColumnInfo[] All = [
- new(DesktopColumn.Name, "Process", 260, false, ProcessColumn.Name, null),
- new(DesktopColumn.Pid, "PID", 70, true, ProcessColumn.Pid, null),
- new(DesktopColumn.PidHex, "PID (hex)", 78, true, ProcessColumn.Pid, null),
- new(DesktopColumn.ParentPid, "Parent PID", 88, true, ProcessColumn.ParentPid, null),
- new(DesktopColumn.User, "User", 130, false, ProcessColumn.UserName, null),
- new(DesktopColumn.State, "State", 62, false, ProcessColumn.State, null),
- // Wide enough for the header *and* its sort arrow: at 62 the caption clipped to "PU %".
- new(DesktopColumn.CpuPercent, "CPU %", 78, true, ProcessColumn.CpuPercent, null),
- new(DesktopColumn.CpuPerCore, "CPU % (per core)", 118, true, ProcessColumn.CpuPercent, null),
- new(DesktopColumn.CpuTime, "CPU time", 88, true, ProcessColumn.CpuPercent, null),
- new(DesktopColumn.CyclesDelta, "Cycles delta", 100, true, null, null),
- new(DesktopColumn.ContextSwitchDelta, "Ctx switch delta", 116, true, null, null),
- new(DesktopColumn.CpuHistory, "CPU history", 90, false, null, HistorySeries.Cpu),
- new(DesktopColumn.PrivateBytes, "Private bytes", 96, true, ProcessColumn.PrivateBytes, null),
- new(DesktopColumn.PrivateBytesDelta, "Private delta", 100, true, null, null),
- new(DesktopColumn.PrivateWorkingSet, "Private WS", 88, true, null, null),
- new(DesktopColumn.MemoryHistory, "Memory history", 90, false, null, HistorySeries.Memory),
- new(DesktopColumn.WorkingSet, "Working set", 92, true, ProcessColumn.WorkingSetBytes, null),
- new(DesktopColumn.PeakWorkingSet, "Peak WS", 84, true, null, null),
- new(DesktopColumn.VirtualBytes, "Virtual size", 92, true, ProcessColumn.VirtualBytes, null),
- new(DesktopColumn.PeakVirtualBytes, "Peak virtual", 96, true, null, null),
- new(DesktopColumn.PagedPool, "Paged pool", 90, true, null, null),
- new(DesktopColumn.PeakPagedPool, "Peak paged pool", 116, true, null, null),
- new(DesktopColumn.NonPagedPool, "Non-paged pool", 110, true, null, null),
- new(DesktopColumn.PeakNonPagedPool, "Peak non-paged", 112, true, null, null),
- new(DesktopColumn.PageFaultDelta, "Page fault delta", 116, true, null, null),
- new(DesktopColumn.Swap, "Swap", 78, true, null, null),
- new(DesktopColumn.IoTotalRate, "I/O total rate", 104, true, null, null),
- new(DesktopColumn.ReadRate, "I/O read rate", 100, true, ProcessColumn.ReadBytesPerSecond, null),
- new(DesktopColumn.WriteRate, "I/O write rate", 104, true, ProcessColumn.WriteBytesPerSecond, null),
- new(DesktopColumn.IoHistory, "I/O history", 90, false, null, HistorySeries.Io),
- new(DesktopColumn.Threads, "Threads", 64, true, ProcessColumn.ThreadCount, null),
- new(DesktopColumn.Handles, "Handles", 66, true, ProcessColumn.HandleCount, null),
- new(DesktopColumn.Priority, "Priority", 74, true, ProcessColumn.Priority, null),
- new(DesktopColumn.Session, "Session", 74, true, ProcessColumn.SessionId, null),
- new(DesktopColumn.Started, "Start time", 140, false, ProcessColumn.StartTime, null),
- new(DesktopColumn.Container, "Container / cgroup", 240, false, null, null),
- new(DesktopColumn.ImagePath, "Image path", 320, false, null, null),
- new(DesktopColumn.CommandLine, "Command line", 420, false, ProcessColumn.CommandLine, null),
- ];
+ /// Every column the chooser offers, in registry order.
+ public static FieldDescriptor[] All => FieldRegistry.All;
///
/// What the window opens with: the Process Explorer set plus the three graphs, which are the point
/// of having them.
///
- public static readonly DesktopColumn[] Default = [
- DesktopColumn.Name,
- DesktopColumn.Pid,
- DesktopColumn.User,
- DesktopColumn.CpuPercent,
- DesktopColumn.CpuHistory,
- DesktopColumn.PrivateBytes,
- DesktopColumn.MemoryHistory,
- DesktopColumn.IoHistory,
- DesktopColumn.Threads,
+ public static readonly ProcessField[] Default = [
+ ProcessField.Name,
+ ProcessField.Pid,
+ ProcessField.UserName,
+ ProcessField.CpuPercent,
+ ProcessField.CpuHistory,
+ ProcessField.PrivateBytes,
+ ProcessField.MemoryHistory,
+ ProcessField.IoHistory,
+ ProcessField.ThreadCount,
];
- public static ColumnInfo Info(DesktopColumn column) {
- foreach (var info in All)
- if (info.Column == column)
- return info;
+ public static FieldDescriptor Info(ProcessField field) => FieldRegistry.Get(field);
- return All[0];
- }
+ ///
+ /// Whether a column is worth offering on this machine at all.
+ ///
+ ///
+ /// A column the platform cannot fill is still offered — it renders n/a, which is a true
+ /// statement and occasionally the one the user wanted (PRD §72.3). What it is not is
+ /// default-visible, which is what this decides.
+ ///
+ public static bool IsUsefulHere(FieldDescriptor descriptor) => descriptor.IsSupportedHere;
}
diff --git a/ProcessManager.Ui.Desktop/MainWindow.cs b/ProcessManager.Ui.Desktop/MainWindow.cs
index 5fd9dd7..677e064 100644
--- a/ProcessManager.Ui.Desktop/MainWindow.cs
+++ b/ProcessManager.Ui.Desktop/MainWindow.cs
@@ -18,7 +18,7 @@ public sealed class MainWindow : Form {
private readonly Sampler _sampler;
private readonly ISystemProbe _probe;
private readonly IProcessActions? _actions;
- private readonly ProcessView _view = new() { TreeMode = true, SortColumn = ProcessColumn.CpuPercent, SortDescending = true };
+ private readonly ProcessView _view = new() { TreeMode = true, SortColumn = ProcessField.CpuPercent, SortDescending = true };
private readonly ProcessTreeBinder _binder;
private readonly TreeListView _tree = new();
private readonly HistoryPlot _cpuPlot = new();
@@ -32,7 +32,7 @@ public sealed class MainWindow : Form {
private readonly HistoryRing _cpuHistory = new(600);
private readonly HistoryRing _memoryHistory = new(600);
private readonly ProcessHistory _rowHistory = new();
- private readonly List _columns = [.. ColumnSet.Default];
+ private readonly List _columns = [.. ColumnSet.Default];
private bool _splitPlaced;
private ITheme _theme = DefaultTheme.Instance;
private int _laidOutWidth = -1;
@@ -226,11 +226,11 @@ private void RebuildColumns() {
foreach (var column in this._columns) {
var info = ColumnSet.Info(column);
var header = info.Header;
- if (info.SortBy == this._view.SortColumn)
+ if (info.IsSortable && column == this._view.SortColumn)
header = this._view.SortDescending ? header + " ▾" : header + " ▴";
var which = column;
- this._tree.Columns.Add(new(header, info.Width, node => ((ProcessRow)node.Tag!).TextOf(which)) {
+ this._tree.Columns.Add(new(header, info.DesktopWidth, node => ((ProcessRow)node.Tag!).TextOf(which)) {
TextAlign = info.RightAligned ? ContentAlignment.MiddleRight : ContentAlignment.MiddleLeft,
});
}
@@ -261,7 +261,8 @@ private void OnColumnClick(object? sender, ColumnClickEventArgs e) {
if ((uint)e.Column >= (uint)this._columns.Count)
return;
- if (ColumnSet.Info(this._columns[e.Column]).SortBy is not { } sortBy)
+ var sortBy = this._columns[e.Column];
+ if (!ColumnSet.Info(sortBy).IsSortable)
// A history column has no text to sort by, and sorting by "the shape of a graph" is not a
// thing. Clicking one does nothing rather than doing something arbitrary.
return;
@@ -334,20 +335,20 @@ private void BuildMenu() {
// ColumnClick (its ListView does, and is flat). Click-to-sort is the gesture people expect, so
// this is a stand-in for a hook that has to come from the toolkit, not a preference.
var sort = new ToolStripMenuItem("Sort by");
- foreach (var column in (ReadOnlySpan)[
- ProcessColumn.CpuPercent,
- ProcessColumn.PrivateBytes,
- ProcessColumn.WorkingSetBytes,
- ProcessColumn.ReadBytesPerSecond,
- ProcessColumn.WriteBytesPerSecond,
- ProcessColumn.ThreadCount,
- ProcessColumn.Name,
- ProcessColumn.Pid,
- ProcessColumn.UserName,
- ProcessColumn.StartTime,
+ foreach (var column in (ReadOnlySpan)[
+ ProcessField.CpuPercent,
+ ProcessField.PrivateBytes,
+ ProcessField.WorkingSetBytes,
+ ProcessField.ReadBytesPerSecond,
+ ProcessField.WriteBytesPerSecond,
+ ProcessField.ThreadCount,
+ ProcessField.Name,
+ ProcessField.Pid,
+ ProcessField.UserName,
+ ProcessField.StartTime,
]) {
var chosen = column;
- sort.DropDownItems.Add(Item(column.ToHeader(), () => {
+ sort.DropDownItems.Add(Item(column.Header(), () => {
this._view.SortColumn = chosen;
this._view.SortDescending = chosen.PrefersDescending();
this.Refresh();
diff --git a/ProcessManager.Ui.Desktop/ProcessRow.cs b/ProcessManager.Ui.Desktop/ProcessRow.cs
index c8a346b..fb60445 100644
--- a/ProcessManager.Ui.Desktop/ProcessRow.cs
+++ b/ProcessManager.Ui.Desktop/ProcessRow.cs
@@ -11,50 +11,32 @@ namespace Hawkynt.ProcessManager.Ui.Desktop;
///
/// The tree's column selectors run on every paint, several times per row — so they read strings that
/// are already made rather than formatting a number each time. The strings are refreshed once per
-/// sample, in , and only when they changed: a row whose CPU still reads "0.0"
-/// hands back the same string instance and the control has nothing to repaint.
+/// sample, in .
+///
+/// One array indexed by rather than a property per column: there are
+/// thirty-eight of them, a property each meant a switch to match, and the switch is exactly the kind
+/// of thing that silently loses a field when the thirty-ninth is added (PRD §5.1).
+///
///
public sealed class ProcessRow(ProcessKey key) {
+ private static readonly int _slots = CountSlots();
+
+ private readonly string[] _text = new string[_slots];
+
+ /// Indexed by the enum value, so the array must be as long as the largest one plus one.
+ private static int CountSlots() {
+ var highest = 0;
+ foreach (var descriptor in FieldRegistry.All)
+ highest = Math.Max(highest, (int)descriptor.Id);
+
+ return highest + 1;
+ }
+
public ProcessKey Key { get; } = key;
public int Pid => this.Key.Pid;
- public string Name { get; private set; } = string.Empty;
- public string User { get; private set; } = string.Empty;
- public string Cpu { get; private set; } = string.Empty;
- public string Private { get; private set; } = string.Empty;
- public string WorkingSet { get; private set; } = string.Empty;
- public string Read { get; private set; } = string.Empty;
- public string Write { get; private set; } = string.Empty;
- public string Threads { get; private set; } = string.Empty;
- public string Handles { get; private set; } = string.Empty;
- public string State { get; private set; } = string.Empty;
- public string Started { get; private set; } = string.Empty;
- public string CommandLine { get; private set; } = string.Empty;
- public string PidHex { get; private set; } = string.Empty;
- public string ParentPid { get; private set; } = string.Empty;
- public string CpuPerCore { get; private set; } = string.Empty;
- public string CpuTime { get; private set; } = string.Empty;
- public string CyclesDelta { get; private set; } = string.Empty;
- public string ContextSwitchDelta { get; private set; } = string.Empty;
- public string PrivateDelta { get; private set; } = string.Empty;
- public string PrivateWorkingSet { get; private set; } = string.Empty;
- public string PeakWorkingSet { get; private set; } = string.Empty;
- public string VirtualBytes { get; private set; } = string.Empty;
- public string PeakVirtualBytes { get; private set; } = string.Empty;
- public string PagedPool { get; private set; } = string.Empty;
- public string PeakPagedPool { get; private set; } = string.Empty;
- public string NonPagedPool { get; private set; } = string.Empty;
- public string PeakNonPagedPool { get; private set; } = string.Empty;
- public string PageFaultDelta { get; private set; } = string.Empty;
- public string Swap { get; private set; } = string.Empty;
- public string IoTotal { get; private set; } = string.Empty;
- public string Priority { get; private set; } = string.Empty;
- public string Session { get; private set; } = string.Empty;
- public string Container { get; private set; } = string.Empty;
- public string ImagePath { get; private set; } = string.Empty;
-
/// True for one sample after the process appeared — the Process Explorer green flash.
public bool IsNew { get; private set; }
@@ -65,86 +47,53 @@ public sealed class ProcessRow(ProcessKey key) {
public int Generation { get; set; }
public void Update(in ProcessRecord process, SnapshotDelta delta, int index, Counter handles, int currentUserId) {
+ foreach (var descriptor in FieldRegistry.All) {
+ if (descriptor.IsGraph)
+ continue;
+
+ this._text[(int)descriptor.Id] = FieldAccessor.Text(descriptor.Id, in process, delta, index);
+ }
+
+ // Two exceptions to the shared formatter, both because the window knows something the engine
+ // does not. The handle count is sampled on its own schedule because it is expensive (PRD §5.4),
+ // so the row prefers the freshly measured one and falls back to the snapshot's; and a missing
+ // user name falls back to the numeric id, which is more use in a window than a dash.
+ if (handles.Reason != UnknownReason.NotSampledYet)
+ this._text[(int)ProcessField.HandleCount] = Humanize.Count(handles);
+
+ if (process.UserName is null)
+ this._text[(int)ProcessField.UserName] =
+ process.UserId >= 0 ? process.UserId.ToString(CultureInfo.InvariantCulture) : "?";
+
this.Name = process.Name;
- this.User = process.UserName ?? (process.UserId >= 0 ? process.UserId.ToString(CultureInfo.InvariantCulture) : "?");
- this.Cpu = Humanize.Percent(delta.CpuPercent(index));
- this.Private = Humanize.Bytes(process.PrivateBytes);
- this.WorkingSet = Humanize.Bytes(process.WorkingSetBytes);
- this.Read = Humanize.BytesPerSecond(delta.ReadBytesPerSecond(index));
- this.Write = Humanize.BytesPerSecond(delta.WriteBytesPerSecond(index));
- this.Threads = process.ThreadCount.ToString(CultureInfo.InvariantCulture);
- this.Handles = Humanize.Count(handles.Reason == UnknownReason.NotSampledYet ? process.HandleCount : handles);
- this.State = Humanize.State(process.State);
- this.Started = process.StartTimeUtcTicks > 0
- ? new DateTime(process.StartTimeUtcTicks, DateTimeKind.Utc).ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.CurrentCulture)
- : "—";
-
- this.CommandLine = process.CommandLine ?? string.Empty;
- this.PidHex = "0x" + process.Pid.ToString("X", CultureInfo.InvariantCulture);
- this.ParentPid = process.ParentPid > 0 ? process.ParentPid.ToString(CultureInfo.InvariantCulture) : "—";
- this.CpuPerCore = Humanize.Percent(delta.CpuPercentPerCore(index));
- this.CpuTime = Humanize.Duration(process.CpuTimeNs);
- this.CyclesDelta = Humanize.Rate(delta.CyclesPerSecond(index));
- this.ContextSwitchDelta = Humanize.Rate(delta.ContextSwitchesPerSecond(index));
- this.PrivateDelta = Humanize.SignedBytesPerSecond(delta.PrivateBytesDelta(index));
- this.PrivateWorkingSet = Humanize.Bytes(process.PrivateWorkingSetBytes);
- this.PeakWorkingSet = Humanize.Bytes(process.PeakWorkingSetBytes);
- this.VirtualBytes = Humanize.Bytes(process.VirtualBytes);
- this.PeakVirtualBytes = Humanize.Bytes(process.PeakVirtualBytes);
- this.PagedPool = Humanize.Bytes(process.PagedPoolBytes);
- this.PeakPagedPool = Humanize.Bytes(process.PeakPagedPoolBytes);
- this.NonPagedPool = Humanize.Bytes(process.NonPagedPoolBytes);
- this.PeakNonPagedPool = Humanize.Bytes(process.PeakNonPagedPoolBytes);
- this.PageFaultDelta = Humanize.Rate(delta.PageFaultsPerSecond(index));
- this.Swap = Humanize.Bytes(process.SwapBytes);
- this.IoTotal = Humanize.BytesPerSecond(delta.IoTotalBytesPerSecond(index));
- this.Priority = process.Priority.ToString(CultureInfo.InvariantCulture);
- this.Session = process.SessionId >= 0 ? process.SessionId.ToString(CultureInfo.InvariantCulture) : "—";
- this.Container = process.ContainerPath ?? "—";
- this.ImagePath = process.ImagePath ?? "—";
this.IsNew = delta.IsNew(index);
this.Category = ProcessCategories.Classify(in process, currentUserId, this.IsNew);
}
/// The text for one column, or empty for the ones that are drawn rather than written.
- public string TextOf(DesktopColumn column) => column switch {
- DesktopColumn.Name => this.Label,
- DesktopColumn.Pid => this.Pid.ToString(CultureInfo.InvariantCulture),
- DesktopColumn.User => this.User,
- DesktopColumn.State => this.State,
- DesktopColumn.CpuPercent => this.Cpu,
- DesktopColumn.PrivateBytes => this.Private,
- DesktopColumn.WorkingSet => this.WorkingSet,
- DesktopColumn.ReadRate => this.Read,
- DesktopColumn.WriteRate => this.Write,
- DesktopColumn.Threads => this.Threads,
- DesktopColumn.Handles => this.Handles,
- DesktopColumn.Started => this.Started,
- DesktopColumn.CommandLine => this.CommandLine,
- DesktopColumn.PidHex => this.PidHex,
- DesktopColumn.ParentPid => this.ParentPid,
- DesktopColumn.CpuPerCore => this.CpuPerCore,
- DesktopColumn.CpuTime => this.CpuTime,
- DesktopColumn.CyclesDelta => this.CyclesDelta,
- DesktopColumn.ContextSwitchDelta => this.ContextSwitchDelta,
- DesktopColumn.PrivateBytesDelta => this.PrivateDelta,
- DesktopColumn.PrivateWorkingSet => this.PrivateWorkingSet,
- DesktopColumn.PeakWorkingSet => this.PeakWorkingSet,
- DesktopColumn.VirtualBytes => this.VirtualBytes,
- DesktopColumn.PeakVirtualBytes => this.PeakVirtualBytes,
- DesktopColumn.PagedPool => this.PagedPool,
- DesktopColumn.PeakPagedPool => this.PeakPagedPool,
- DesktopColumn.NonPagedPool => this.NonPagedPool,
- DesktopColumn.PeakNonPagedPool => this.PeakNonPagedPool,
- DesktopColumn.PageFaultDelta => this.PageFaultDelta,
- DesktopColumn.Swap => this.Swap,
- DesktopColumn.IoTotalRate => this.IoTotal,
- DesktopColumn.Priority => this.Priority,
- DesktopColumn.Session => this.Session,
- DesktopColumn.Container => this.Container,
- DesktopColumn.ImagePath => this.ImagePath,
- _ => string.Empty,
- };
+ public string TextOf(ProcessField field) {
+ // The name column carries the pid alongside the name, which is a window convention rather than a
+ // property of the field, so it does not belong in the shared accessor.
+ if (field == ProcessField.Name)
+ return this.Label;
+
+ var index = (int)field;
+ return (uint)index < (uint)this._text.Length ? this._text[index] ?? string.Empty : string.Empty;
+ }
+
+ public string Name { get; private set; } = string.Empty;
+
+ public string User => this.TextOf(ProcessField.UserName);
+ public string Cpu => this.TextOf(ProcessField.CpuPercent);
+ public string Private => this.TextOf(ProcessField.PrivateBytes);
+ public string WorkingSet => this.TextOf(ProcessField.WorkingSetBytes);
+ public string Read => this.TextOf(ProcessField.ReadBytesPerSecond);
+ public string Write => this.TextOf(ProcessField.WriteBytesPerSecond);
+ public string Threads => this.TextOf(ProcessField.ThreadCount);
+ public string Handles => this.TextOf(ProcessField.HandleCount);
+ public string State => this.TextOf(ProcessField.State);
+ public string Started => this.TextOf(ProcessField.StartTime);
+ public string CommandLine => this.TextOf(ProcessField.CommandLine);
/// The text the tree column shows in the name column, with the pid appended.
public string Label => $"{this.Name} ({this.Pid})";
diff --git a/ProcessManager.Ui.Terminal/Layout.cs b/ProcessManager.Ui.Terminal/Layout.cs
index d9033f3..6dd84ee 100644
--- a/ProcessManager.Ui.Terminal/Layout.cs
+++ b/ProcessManager.Ui.Terminal/Layout.cs
@@ -1,5 +1,4 @@
using Hawkynt.ProcessManager.Query;
-using Hawkynt.ProcessManager.Sampling;
namespace Hawkynt.ProcessManager.Ui.Terminal;
@@ -10,50 +9,45 @@ namespace Hawkynt.ProcessManager.Ui.Terminal;
/// Fixed widths rather than measured ones. A column that resizes itself to its widest value jitters
/// every second as processes come and go, and a table whose columns move is harder to read than one
/// whose values are occasionally clipped (PRD §11).
+///
+/// The widths, headers and alignments come from , not from here: the
+/// terminal used to keep its own list, which is how it ended up one field behind the window
+/// (PRD §5.1).
+///
///
internal static class Layout {
- ///
- /// A terminal column: either one of the engine's sortable columns, or one of the three drawn
- /// histories, which have no text and no sort order.
- ///
- public readonly record struct TerminalColumn(string Header, int Width, ProcessColumn? Sortable, HistorySeries? Series) {
- public bool IsGraph => this.Series is not null;
- }
-
- public static readonly TerminalColumn[] Columns = [
- new("PID", 7, ProcessColumn.Pid, null),
- new("User", 10, ProcessColumn.UserName, null),
- new("S", 5, ProcessColumn.State, null),
- new("CPU%", 5, ProcessColumn.CpuPercent, null),
- new("CPU hist", 12, null, HistorySeries.Cpu),
- new("Private", 7, ProcessColumn.PrivateBytes, null),
- new("Mem hist", 12, null, HistorySeries.Memory),
- new("Read/s", 8, ProcessColumn.ReadBytesPerSecond, null),
- new("Write/s", 8, ProcessColumn.WriteBytesPerSecond, null),
- new("I/O hist", 12, null, HistorySeries.Io),
- new("Thr", 4, ProcessColumn.ThreadCount, null),
- new("Hnd", 5, ProcessColumn.HandleCount, null),
- new("Process", 120, ProcessColumn.Name, null),
+ /// The columns the terminal opens with, in order.
+ public static readonly ProcessField[] Columns = [
+ ProcessField.Pid,
+ ProcessField.UserName,
+ ProcessField.State,
+ ProcessField.CpuPercent,
+ ProcessField.CpuHistory,
+ ProcessField.PrivateBytes,
+ ProcessField.MemoryHistory,
+ ProcessField.ReadBytesPerSecond,
+ ProcessField.WriteBytesPerSecond,
+ ProcessField.IoHistory,
+ ProcessField.ThreadCount,
+ ProcessField.HandleCount,
+ ProcessField.Name,
];
+ public static FieldDescriptor Info(ProcessField field) => FieldRegistry.Get(field);
+
/// The columns a user can cycle the sort through — the graphs are not among them.
- public static ProcessColumn[] Sortable {
+ public static ProcessField[] Sortable {
get {
- var result = new List();
- foreach (var column in Columns)
- if (column.Sortable is { } sortable)
- result.Add(sortable);
+ var result = new List();
+ // Not "field": in C# 14 that is a keyword inside a property accessor and binds to the
+ // backing field instead of the loop variable.
+ foreach (var candidate in Columns)
+ if (FieldRegistry.Get(candidate).IsSortable)
+ result.Add(candidate);
return [.. result];
}
}
- /// Numbers right, text left — so the digits of a column line up under each other.
- public static bool IsRightAligned(in TerminalColumn column) => column.Sortable switch {
- ProcessColumn.Name or ProcessColumn.UserName or ProcessColumn.CommandLine or ProcessColumn.State => false,
- null => false,
- _ => true,
- };
-
}
diff --git a/ProcessManager.Ui.Terminal/TerminalUi.cs b/ProcessManager.Ui.Terminal/TerminalUi.cs
index e3f3ca9..3a060a5 100644
--- a/ProcessManager.Ui.Terminal/TerminalUi.cs
+++ b/ProcessManager.Ui.Terminal/TerminalUi.cs
@@ -43,7 +43,7 @@ public TerminalUi(Sampler sampler, ISystemProbe probe, IProcessActions? actions,
this._screen = new(width, height, depth);
this._detail = new(probe);
this._view.TreeMode = false;
- this._view.SortColumn = ProcessColumn.CpuPercent;
+ this._view.SortColumn = ProcessField.CpuPercent;
this._view.SortDescending = true;
}
@@ -197,10 +197,10 @@ private bool HandleNormal(ConsoleKeyInfo key) {
case 'i': this.OpenDetail(); return true;
case '<': this.PreviousSortColumn(); return true;
case '>': this.NextSortColumn(); return true;
- case 'P': this._view.SortColumn = ProcessColumn.CpuPercent; this._view.SortDescending = true; return true;
- case 'M': this._view.SortColumn = ProcessColumn.PrivateBytes; this._view.SortDescending = true; return true;
- case 'T': this._view.SortColumn = ProcessColumn.StartTime; this._view.SortDescending = false; return true;
- case 'N': this._view.SortColumn = ProcessColumn.Pid; this._view.SortDescending = false; return true;
+ case 'P': this._view.SortColumn = ProcessField.CpuPercent; this._view.SortDescending = true; return true;
+ case 'M': this._view.SortColumn = ProcessField.PrivateBytes; this._view.SortDescending = true; return true;
+ case 'T': this._view.SortColumn = ProcessField.StartTime; this._view.SortDescending = false; return true;
+ case 'N': this._view.SortColumn = ProcessField.Pid; this._view.SortDescending = false; return true;
case 'C':
this._sampler.CpuPercentMode = this._sampler.CpuPercentMode == CpuPercentMode.Normalized
? CpuPercentMode.PerCore
@@ -357,7 +357,7 @@ private void SetSortColumn(int direction) {
index = ((index < 0 ? 0 : index) + direction + columns.Length) % columns.Length;
this._view.SortColumn = columns[index];
this._view.SortDescending = columns[index].PrefersDescending();
- this.Say($"sorted by {columns[index].ToHeader()}", Attributes.Accent);
+ this.Say($"sorted by {columns[index].Header()}", Attributes.Accent);
}
private void BeginKill(bool tree) {
@@ -510,10 +510,11 @@ private static string FormatUptime(double seconds) {
private void DrawColumnHeader(int y) {
this._screen.Fill(0, y, this._screen.Width, ' ', Attributes.Header);
var x = 0;
- foreach (var column in Layout.Columns) {
- var width = column.Width;
- var header = column.Header;
- if (column.Sortable is { } sortable && sortable == this._view.SortColumn)
+ foreach (var field in Layout.Columns) {
+ var column = Layout.Info(field);
+ var width = column.TerminalWidth;
+ var header = column.ShortHeader;
+ if (column.IsSortable && field == this._view.SortColumn)
header = this._view.SortDescending ? header + "▾" : header + "▴";
// A header that does not fit loses its tail, not its head: "Working set" clipped to "ing set"
@@ -522,7 +523,7 @@ private void DrawColumnHeader(int y) {
if (header.Length > width)
header = header[..width];
- if (Layout.IsRightAligned(in column))
+ if (column.RightAligned)
this._screen.WriteRight(x, y, width, header, Attributes.Header);
else
this._screen.Write(x, y, header, Attributes.Header);
@@ -555,8 +556,9 @@ private void DrawRows(int top) {
this._screen.Fill(0, top + line, this._screen.Width, ' ', Attributes.Selected);
var x = 0;
- foreach (var column in Layout.Columns) {
- var width = column.Width;
+ foreach (var field in Layout.Columns) {
+ var column = Layout.Info(field);
+ var width = column.TerminalWidth;
if (column.Series is { } series) {
// Drawn, not written: the eighth-block ramp turns a column of text into a plot (PRD §11).
var plot = BlockSparkline.Render(
@@ -574,8 +576,8 @@ private void DrawRows(int top) {
continue;
}
- var text = this.CellText(in column, row, in process, delta);
- if (Layout.IsRightAligned(in column))
+ var text = this.CellText(field, row, in process, delta);
+ if (column.RightAligned)
this._screen.WriteRight(x, top + line, width, text, baseAttribute);
else
this._screen.Write(x, top + line, text.Length > width ? text[..width] : text, baseAttribute);
@@ -594,24 +596,31 @@ private void DrawRows(int top) {
_ => Attributes.Warn,
};
- private string CellText(in Layout.TerminalColumn column, ViewRow row, in ProcessRecord process, SnapshotDelta delta)
- => column.Sortable switch {
- ProcessColumn.Pid => process.Pid.ToString(CultureInfo.InvariantCulture),
- ProcessColumn.UserName => process.UserName ?? (process.UserId >= 0 ? process.UserId.ToString(CultureInfo.InvariantCulture) : "?"),
- ProcessColumn.PrivateBytes => Humanize.Bytes(process.PrivateBytes),
- ProcessColumn.WorkingSetBytes => Humanize.Bytes(process.WorkingSetBytes),
- ProcessColumn.State => Humanize.State(process.State),
- ProcessColumn.CpuPercent => Humanize.Percent(delta.CpuPercent(row.Index)),
- ProcessColumn.ReadBytesPerSecond => Humanize.BytesPerSecond(delta.ReadBytesPerSecond(row.Index)),
- ProcessColumn.WriteBytesPerSecond => Humanize.BytesPerSecond(delta.WriteBytesPerSecond(row.Index)),
- ProcessColumn.ThreadCount => process.ThreadCount.ToString(CultureInfo.InvariantCulture),
- ProcessColumn.HandleCount => Humanize.Count(this._handleCounts.TryGetValue(process.Key, out var handles) ? handles : process.HandleCount),
- ProcessColumn.Name => this._view.TreeMode
- ? new string(' ', Math.Min(row.Depth * 2, 32)) + (row.HasChildren ? "+ " : " ") + process.Name
- : process.Name,
- ProcessColumn.CommandLine => process.CommandLine ?? string.Empty,
- _ => string.Empty,
- };
+ ///
+ /// The text for one cell. Everything goes through the shared accessor so a value reads the same
+ /// here as in the window (PRD §5.1); the two exceptions below are things the terminal knows and
+ /// the engine does not.
+ ///
+ private string CellText(ProcessField field, ViewRow row, in ProcessRecord process, SnapshotDelta delta) {
+ switch (field) {
+ // The tree lives in the name column, so indentation and the expander are part of its text.
+ case ProcessField.Name:
+ return this._view.TreeMode
+ ? new string(' ', Math.Min(row.Depth * 2, 32)) + (row.HasChildren ? "+ " : " ") + process.Name
+ : process.Name;
+
+ // Handles are counted on their own schedule because counting them is expensive (PRD §5.4).
+ case ProcessField.HandleCount when this._handleCounts.TryGetValue(process.Key, out var handles):
+ return Humanize.Count(handles);
+
+ // A missing user name falls back to the numeric id, which is narrower and more use than a dash.
+ case ProcessField.UserName when process.UserName is null:
+ return process.UserId >= 0 ? process.UserId.ToString(CultureInfo.InvariantCulture) : "?";
+
+ default:
+ return FieldAccessor.Text(field, in process, delta, row.Index);
+ }
+ }
private void DrawStatus() {
var y = this._screen.Height - 1;
diff --git a/docs/PRD.md b/docs/PRD.md
index 455a809..056a22d 100644
--- a/docs/PRD.md
+++ b/docs/PRD.md
@@ -31,8 +31,8 @@ shorthand:
it is not known*. An unticked box must never become a zero on screen. This is restated here because
it is the single requirement most likely to be broken while filling the tables in.
-**Counting, as of the last update:** **374 of 1249 boxes are ticked** — 55 of 189 in the field
-registry (§14–22), 319 of 1060 across the capabilities. A further 116 are marked 🟡, meaning some of
+**Counting, as of the last update:** **382 of 1250 boxes are ticked** — 55 of 189 in the field
+registry (§14–22), 327 of 1061 across the capabilities. A further 114 are marked 🟡, meaning some of
the work behind them is already done. §100 tracks the phases; §101 defines when this may be called
finished.
@@ -182,34 +182,36 @@ reasoning, and the reasoning recorded in a comment.
There must not be separate "GUI fields" and "TUI fields".
-- [ ] Every field is registered in a central field catalogue. **This does not exist yet**, and it is
- the highest-priority piece of internal work in the document: today `ProcessColumn` is a sort-key
- enum in Core, the desktop keeps its own `ColumnSet`, and the TUI keeps a third list. Three
- places to add a field is three places to forget one. §103 cannot be enforced until this lands.
+- [x] Every field is registered in a central field catalogue — `FieldRegistry` in
+ `ProcessManager.Core/Query`. There were three lists before it (a sort-key enum in Core, the
+ window's own `ColumnSet`, and a third in the terminal), and three places to add a field is
+ three places to forget one.
-The ticks below therefore describe what a field *can* express today through the existing types
-(`Counter`, `Rate`, `UnknownReason`), not a catalogue that holds the metadata in one place.
+`FieldAccessor` reads a field three ways from that one declaration: as text to display, as a number
+to compare and filter, and as an ordering. The window and the terminal both render through it, and
+the view sorts through it — so sorting by a column cannot disagree with what the column shows, and a
+value reads identically in both front-ends because it is the same code producing it.
Each registry entry declares:
-- [x] Stable field ID
+- [x] Stable field ID — the `Key`, which is what `--sort`, a saved layout and a search term all use
- [x] Human-readable name
-- [ ] Short TUI label
-- [ ] Description
+- [x] Short TUI label
+- [x] Description
- [x] Data type
-- [ ] 🟡 Units
+- [x] Units
- [ ] Precision
- [x] Whether it is instantaneous, cumulative, delta, rate, state, enumeration or derived
-- [ ] 🟡 Supported platforms
+- [x] Supported platforms
- [ ] Required privilege
-- [ ] Collection cost
+- [x] Collection cost
- [x] Default visibility
- [x] Sort semantics
-- [ ] Filter semantics
+- [ ] 🟡 Filter semantics — `Number` and `RawText` are there; the query language of §56 is not
- [x] Formatting function
- [x] Null/unavailable semantics
- [ ] Export serialisation
-- [ ] Historical-storage eligibility
+- [ ] 🟡 Historical-storage eligibility — the graph fields declare their series
Worked example — `process.cpu.usage`: display "CPU", TUI "CPU%", percentage, normalised
instantaneous utilisation, 0–100 in default mode and 0–N×100 in raw logical-CPU mode, all platforms,
@@ -341,7 +343,7 @@ Platform backends → Core collector → Snapshot engine → Field registry →
| Core collector | `ProcessManager.Core` | ✅ |
| Platform backends | `ProcessManager.Platform.{Windows,Linux,MacOS}` | 🟡 macOS stub |
| Snapshot engine | `ProcessManager.Core/Sampling` | ✅ |
-| Field registry | *not yet built* — see §5.1 | ⬜ |
+| Field registry | `ProcessManager.Core/Query` | ✅ |
| Query engine | `ProcessManager.Core/Query` | 🟡 |
| Action broker | `ProcessManager.Core/Actions` | 🟡 |
| Privileged helper | `ProcessManager.Elevated` | ✅ |
@@ -1543,6 +1545,9 @@ Substring matching over name, PID, user and command line works in both front-end
language does not exist.
- [x] Plain substring search
+- [x] Every field is addressable by a stable key, which is the half of the query language the
+ registry supplies — `--sort=private.ws` and `--sort=faults.delta` work without either having
+ been written down anywhere as a sort key
- [ ] `field:value`
- [ ] `field=value`
- [ ] Comparison operators
@@ -2296,7 +2301,7 @@ a naive parser hands the attacker the parse.
- [x] Delta handling
- [x] Unit formatting
- [x] PID reuse
-- [ ] 🟡 Field registry
+- [x] Field registry — 14 tests, including the one that enforces §103
- [x] Filters
- [x] Sorting
- [ ] Export schemas
@@ -2498,7 +2503,9 @@ To add an **action**:
11. Add the audit event
12. Add tests
-- [ ] 🟡 A CI check enforces this — today it is a convention, and a convention is not a rule
+- [ ] 🟡 A CI check enforces this. Half of it is real: `EveryFieldInTheEnumIsRegistered` fails the
+ build when a field is added to the enum without a descriptor, so steps 1–8 cannot be skipped.
+ Steps 9–13 — GUI, TUI, CLI, export schema, tests — are still on the author to remember.
# 104. Internal object model