< Summary

Information
Class: Punch.CLI.PunchController
Assembly: punch
File(s): /home/runner/work/punch/punch/src/Punch.CLI/PunchController.cs
Line coverage
94%
Covered lines: 293
Uncovered lines: 16
Coverable lines: 309
Total lines: 458
Line coverage: 94.8%
Branch coverage
93%
Covered branches: 208
Total branches: 223
Branch coverage: 93.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)50%22100%
get_IsConfirmingQuit()100%11100%
get_IsConfirmingDelete()100%11100%
get_Schedule()100%11100%
Run(...)0%620%
HandleKey(...)100%7878100%
DispatchKey(...)91.48%4747100%
HandleLeftArrow()88.88%1818100%
HandleRightArrow()72.22%181896%
MoveTo(...)100%22100%
SelectBlock(...)100%11100%
HandleGrow()100%1010100%
HandleShrink()100%1010100%
HandleEditStart()100%44100%
HandleEnter()100%1010100%
ApplyTicketPick()75%44100%
HandleTextInput(...)100%88100%
DeleteSelected()100%11100%
AutoScrollToSelection()100%101092.85%
ClampScrollOffset()100%11100%
Save()100%11100%
Render(...)100%210%

File(s)

/home/runner/work/punch/punch/src/Punch.CLI/PunchController.cs

#LineLine coverage
 1using Spectre.Console;
 2
 3namespace Punch.CLI;
 4
 5// Drives the TUI event loop: reads keys and mutates the PunchSession, persisting
 6// and re-rendering after each keypress. Rendering lives in PunchView; the data
 7// model and occupancy invariants live in DaySchedule.
 8internal sealed class PunchController
 9{
 10    private readonly PunchSession _session;
 11    private readonly PunchView _view;
 12    private readonly Func<int> _windowHeight;
 13
 14    // Two-step confirmations for quit (Ctrl+Q then Q) and delete (Ctrl+D then D).
 15    private bool _confirming;
 16    private bool _confirmingDelete;
 17
 7218    public PunchController(PunchSession session, PunchView view, Func<int>? windowHeight = null)
 7219    {
 7220        _session = session;
 7221        _view = view;
 7222        _windowHeight = windowHeight ?? (() => System.Console.WindowHeight);
 7223    }
 24
 325    internal bool IsConfirmingQuit => _confirming;
 426    internal bool IsConfirmingDelete => _confirmingDelete;
 27
 32828    private DaySchedule Schedule => _session.Schedule;
 29
 30    public void Run(LiveDisplayContext ctx)
 031    {
 032        Render(ctx);
 33
 034        while (true)
 035        {
 036            var key = System.Console.ReadKey(true);
 037            if (HandleKey(key))
 038                break;
 039            Render(ctx);
 040        }
 041    }
 42
 43    // Processes one keypress. Returns true when the app should quit.
 44    internal bool HandleKey(ConsoleKeyInfo key)
 21745    {
 46        // Two-step quit: Q confirms, anything else cancels.
 21747        if (_confirming)
 348        {
 349            if (key.Key == ConsoleKey.Q)
 250                return true;
 151            _confirming = false;
 152            return false;
 53        }
 54
 55        // Two-step delete: D confirms, anything else cancels.
 21456        if (_confirmingDelete)
 257        {
 258            if (key.Key == ConsoleKey.D)
 159                DeleteSelected();
 260            _confirmingDelete = false;
 261            return false;
 62        }
 63
 21264        if (key.Key == ConsoleKey.Q && key.Modifiers.HasFlag(ConsoleModifiers.Control))
 465        {
 466            _confirming = true;
 467            return false;
 68        }
 69
 70        // Any key dismisses help.
 20871        if (_session.ShowHelp)
 172        {
 173            _session.ShowHelp = false;
 174            return false;
 75        }
 76
 77        // Ticket picker is modal: arrows move the highlight, Enter assigns,
 78        // Esc/F4/Ctrl+P cancel; all other keys are swallowed.
 20779        if (_session.ShowTicketPicker)
 1180        {
 1181            switch (key.Key)
 82            {
 83                case ConsoleKey.UpArrow:
 284                    if (_session.TicketPickerCursor > 0)
 185                        _session.TicketPickerCursor--;
 286                    break;
 87                case ConsoleKey.DownArrow:
 388                    if (_session.TicketPickerCursor < _session.Tickets.Count - 1)
 289                        _session.TicketPickerCursor++;
 390                    break;
 91                case ConsoleKey.Enter:
 292                    ApplyTicketPick();
 293                    break;
 94                case ConsoleKey.Escape:
 95                case ConsoleKey.F4:
 296                    _session.ShowTicketPicker = false;
 297                    break;
 198                case ConsoleKey.P when key.Modifiers.HasFlag(ConsoleModifiers.Control):
 199                    _session.ShowTicketPicker = false;
 1100                    break;
 101            }
 11102            return false;
 103        }
 104
 105        // F4 (or Ctrl+P, for terminals/recorders that can't send F-keys) opens
 106        // the ticket picker for the selected block (not while editing).
 196107        if ((key.Key == ConsoleKey.F4
 196108                || (key.Key == ConsoleKey.P && key.Modifiers.HasFlag(ConsoleModifiers.Control)))
 196109            && _session.SelectedBlock != null && !_session.Editing)
 9110        {
 9111            _session.Tickets = PunchStorage.LoadTickets();
 9112            _session.TicketPickerCursor = 0;
 9113            _session.ShowTicketPicker = true;
 9114            return false;
 115        }
 116
 117        // Esc/F3/Ctrl+T dismiss the ticket summary; other keys are swallowed.
 187118        if (_session.ShowTicketSummary)
 4119        {
 4120            if (key.Key == ConsoleKey.F3 || key.Key == ConsoleKey.Escape
 4121                || (key.Key == ConsoleKey.T && key.Modifiers.HasFlag(ConsoleModifiers.Control)))
 3122            {
 3123                _session.ShowTicketSummary = false;
 3124            }
 4125            return false;
 126        }
 127
 128        // F3 (or Ctrl+T, for terminals/recorders that can't send F-keys) opens
 129        // the ticket summary.
 183130        if (key.Key == ConsoleKey.F3
 183131            || (key.Key == ConsoleKey.T && key.Modifiers.HasFlag(ConsoleModifiers.Control)))
 6132        {
 6133            _session.ShowTicketSummary = true;
 6134            return false;
 135        }
 136
 137        // Toggle help when not typing: a selected block parks the input
 138        // fields, so '?' is free over an occupied slot. With a free cursor
 139        // it only toggles when the input fields are empty so '?' can still
 140        // be typed into a description.
 177141        if (key.KeyChar == '?' && !_session.Editing
 177142            && (_session.SelectedBlock != null
 177143                || (_session.InputBuffer.Length == 0 && _session.TicketBuffer.Length == 0)))
 4144        {
 4145            _session.ShowHelp = !_session.ShowHelp;
 4146            return false;
 147        }
 148
 173149        if (key.Key == ConsoleKey.D && key.Modifiers.HasFlag(ConsoleModifiers.Control) && _session.SelectedBlock != null
 3150        {
 3151            _confirmingDelete = true;
 3152            return false;
 153        }
 154
 170155        DispatchKey(key);
 156
 170157        AutoScrollToSelection();
 170158        ClampScrollOffset();
 170159        return false;
 217160    }
 161
 162    private void DispatchKey(ConsoleKeyInfo key)
 170163    {
 170164        switch (key.Key)
 165        {
 166            case ConsoleKey.LeftArrow:
 39167                HandleLeftArrow();
 39168                break;
 169            case ConsoleKey.RightArrow:
 10170                HandleRightArrow();
 10171                break;
 172            case ConsoleKey.UpArrow:
 12173                HandleGrow();
 12174                break;
 175            case ConsoleKey.DownArrow:
 5176                HandleShrink();
 5177                break;
 178            case ConsoleKey.PageUp:
 1179                _session.LogScrollOffset = Math.Max(0, _session.LogScrollOffset - 5);
 1180                break;
 181            case ConsoleKey.PageDown:
 1182                _session.LogScrollOffset = Math.Min(Math.Max(0, Schedule.Count - 1), _session.LogScrollOffset + 5);
 1183                break;
 11184            case ConsoleKey.E when key.Modifiers.HasFlag(ConsoleModifiers.Control):
 11185                HandleEditStart();
 11186                break;
 187            case ConsoleKey.Enter:
 5188                HandleEnter();
 5189                break;
 190            case ConsoleKey.Backspace:
 10191                if (_session.IsInputActive && _session.ActiveCursor > 0)
 9192                {
 9193                    _session.ActiveBuffer.Remove(_session.ActiveCursor - 1, 1);
 9194                    _session.ActiveCursor--;
 9195                }
 10196                break;
 197            case ConsoleKey.Delete:
 1198                if (_session.IsInputActive && _session.ActiveCursor < _session.ActiveBuffer.Length)
 1199                    _session.ActiveBuffer.Remove(_session.ActiveCursor, 1);
 1200                break;
 201            case ConsoleKey.End:
 1202                if (_session.IsInputActive && _session.ActiveBuffer.Length > 0)
 1203                    _session.ActiveCursor = _session.ActiveBuffer.Length;
 1204                break;
 205            case ConsoleKey.Home:
 4206                if (_session.IsInputActive && _session.ActiveBuffer.Length > 0)
 3207                    _session.ActiveCursor = 0;
 4208                break;
 209            case ConsoleKey.Tab:
 5210                if (_session.IsInputActive)
 5211                {
 5212                    _session.ActiveField = _session.ActiveField == 0 ? 1 : 0;
 5213                    _session.ActiveCursor = _session.ActiveBuffer.Length;
 5214                }
 5215                break;
 216            default:
 65217                HandleTextInput(key);
 65218                break;
 219        }
 170220    }
 221
 222    private void HandleLeftArrow()
 39223    {
 224        // While typing, Left moves the text cursor within the active field.
 39225        if (_session.ActiveBuffer.Length > 0)
 1226        {
 1227            if (_session.ActiveCursor > 0)
 1228                _session.ActiveCursor--;
 1229            return;
 230        }
 231
 232        // Otherwise it moves along the timeline.
 233        // If abandoning an edit, restore the block's original slots (freed on Ctrl+E).
 38234        if (_session.Editing && _session.SelectedBlock != null)
 1235            Schedule.FillSlots(_session.SelectedBlock);
 236
 38237        if (_session.SelectedBlock != null)
 8238        {
 239            // On an existing block: jump to the slot just before it.
 8240            var target = _session.SelectedBlock.StartSlot - 1;
 8241            if (target >= 0)
 7242                MoveTo(target);
 8243        }
 30244        else if (_session.CursorSlot > 0)
 29245        {
 246            // Free selection: slide left by 1, snapping onto an adjacent block.
 29247            var newStart = _session.CursorSlot - 1;
 29248            var adj = Schedule.FindAt(newStart);
 29249            if (adj != null)
 28250                SelectBlock(adj);
 1251            else if (Schedule.IsFree(newStart))
 1252                _session.CursorSlot = newStart;
 29253        }
 254
 38255        _session.ResetInput();
 39256    }
 257
 258    private void HandleRightArrow()
 10259    {
 260        // While typing, Right moves the text cursor within the active field.
 10261        if (_session.ActiveBuffer.Length > 0)
 1262        {
 1263            if (_session.ActiveCursor < _session.ActiveBuffer.Length)
 1264                _session.ActiveCursor++;
 1265            return;
 266        }
 267
 268        // Otherwise it moves along the timeline.
 269        // If abandoning an edit, restore the block's original slots (freed on Ctrl+E).
 9270        if (_session.Editing && _session.SelectedBlock != null)
 0271            Schedule.FillSlots(_session.SelectedBlock);
 272
 9273        if (_session.SelectedBlock != null)
 2274        {
 275            // On an existing block: jump to the slot just after it.
 2276            var target = _session.SelectedBlock.StartSlot + _session.SelectedBlock.Length;
 2277            if (target < 96)
 2278                MoveTo(target);
 2279        }
 7280        else if (_session.CursorSlot + _session.SelectionLength < 96)
 6281        {
 282            // Free selection: slide right by 1, snapping onto an adjacent block.
 6283            var newEnd = _session.CursorSlot + _session.SelectionLength;
 6284            var adj = Schedule.FindAt(newEnd);
 6285            if (adj != null)
 4286                SelectBlock(adj);
 2287            else if (Schedule.IsFree(newEnd))
 2288                _session.CursorSlot++;
 6289        }
 290
 9291        _session.ResetInput();
 10292    }
 293
 294    // Lands the cursor on the given slot, selecting a block if one covers it.
 295    private void MoveTo(int slot)
 9296    {
 9297        var adj = Schedule.FindAt(slot);
 9298        if (adj != null)
 6299        {
 6300            SelectBlock(adj);
 6301        }
 302        else
 3303        {
 3304            _session.CursorSlot = slot;
 3305            _session.SelectionLength = 1;
 3306            _session.SelectedBlock = null;
 3307        }
 9308    }
 309
 310    private void SelectBlock(TimeBlock block)
 38311    {
 38312        _session.CursorSlot = block.StartSlot;
 38313        _session.SelectionLength = block.Length;
 38314        _session.SelectedBlock = block;
 38315    }
 316
 317    private void HandleGrow()
 12318    {
 12319        if (_session.SelectedBlock == null)
 9320        {
 9321            var newLen = _session.SelectionLength + 1;
 9322            if (_session.CursorSlot + newLen <= 96 && !Schedule.IsOverlapping(_session.CursorSlot, newLen))
 7323                _session.SelectionLength = newLen;
 9324        }
 3325        else if (_session.Editing && Schedule.CanGrow(_session.SelectedBlock.StartSlot, _session.SelectionLength))
 1326        {
 1327            _session.SelectionLength++;
 1328        }
 12329    }
 330
 331    private void HandleShrink()
 5332    {
 5333        if (_session.SelectedBlock == null && _session.SelectionLength > 1)
 1334            _session.SelectionLength--;
 4335        else if (_session.Editing && _session.SelectedBlock != null && _session.SelectionLength > 1)
 2336            _session.SelectionLength--;
 5337    }
 338
 339    private void HandleEditStart()
 11340    {
 341        // Ctrl+E: edit the selected block's label, ticket, and length. Free the
 342        // block's slots so a live resize can reuse them; the abandon paths (Left/
 343        // Right) re-fill them, and Enter re-derives occupancy via Replace.
 11344        if (_session.SelectedBlock == null || _session.Editing)
 2345            return;
 346
 9347        var block = _session.SelectedBlock;
 9348        _session.Editing = true;
 9349        _session.CursorSlot = block.StartSlot;
 9350        _session.SelectionLength = block.Length;
 9351        Schedule.FreeSlots(block);
 9352        _session.InputBuffer.Clear();
 9353        _session.InputBuffer.Append(block.Label);
 9354        _session.InputCursor = _session.InputBuffer.Length;
 9355        _session.TicketBuffer.Clear();
 9356        _session.TicketBuffer.Append(block.Ticket);
 9357        _session.TicketCursor = _session.TicketBuffer.Length;
 9358        _session.ActiveField = 0;
 11359    }
 360
 361    private void HandleEnter()
 5362    {
 5363        if (_session.Editing && _session.SelectedBlock != null && _session.InputBuffer.Length > 0)
 1364        {
 365            // Save the edited label, ticket, and (possibly resized) length. The
 366            // block's original slots were freed on Ctrl+E; Replace re-derives them.
 1367            var edited = _session.SelectedBlock with
 1368            {
 1369                Length = _session.SelectionLength,
 1370                Label = _session.InputBuffer.ToString(),
 1371                Ticket = _session.TicketBuffer.ToString(),
 1372            };
 1373            _session.SelectedBlock = Schedule.Replace(_session.SelectedBlock, edited);
 1374            Save();
 1375            _session.ResetInput();
 1376        }
 4377        else if (_session.SelectedBlock == null && _session.InputBuffer.Length > 0)
 2378        {
 2379            var block = new TimeBlock(_session.CursorSlot, _session.SelectionLength,
 2380                _session.InputBuffer.ToString(), _session.TicketBuffer.ToString());
 2381            Schedule.Add(block);
 2382            Save();
 2383            _session.ResetInput();
 2384            (_session.CursorSlot, _session.SelectionLength, _session.SelectedBlock) =
 2385                Schedule.AdvanceAfterAdd(_session.CursorSlot);
 2386        }
 5387    }
 388
 389    private void ApplyTicketPick()
 2390    {
 391        // Write only the picked ticket onto the selected block; the label is left
 392        // untouched. The block keeps its slots, so Replace re-derives occupancy.
 2393        if (_session.SelectedBlock != null && _session.Tickets.Count > 0)
 1394        {
 1395            var picked = _session.Tickets[_session.TicketPickerCursor];
 1396            var updated = _session.SelectedBlock with { Ticket = picked.Ticket };
 1397            _session.SelectedBlock = Schedule.Replace(_session.SelectedBlock, updated);
 1398            Save();
 1399        }
 2400        _session.ShowTicketPicker = false;
 2401    }
 402
 403    private void HandleTextInput(ConsoleKeyInfo key)
 65404    {
 65405        if (key.KeyChar == '\0' || char.IsControl(key.KeyChar) || !_session.IsInputActive)
 7406            return;
 407
 408        // Ticket numbers are auto-uppercased.
 58409        var ch = _session.ActiveField == 1 ? char.ToUpperInvariant(key.KeyChar) : key.KeyChar;
 58410        _session.ActiveBuffer.Insert(_session.ActiveCursor, ch);
 58411        _session.ActiveCursor++;
 65412    }
 413
 414    private void DeleteSelected()
 1415    {
 1416        Schedule.Remove(_session.SelectedBlock!);
 1417        Save();
 1418        _session.SelectedBlock = null;
 1419        _session.SelectionLength = 1;
 1420        _session.ResetInput();
 1421    }
 422
 423    private void AutoScrollToSelection()
 170424    {
 170425        var selected = _session.SelectedBlock;
 170426        if (selected == null)
 92427            return;
 428
 130429        var sorted = Schedule.Blocks.OrderBy(b => b.StartSlot).ToList();
 177430        var selIdx = sorted.FindIndex(b => b.StartSlot == selected.StartSlot && b.Length == selected.Length);
 78431        if (selIdx < 0)
 0432            return;
 433
 434        // When scrolled, the "more above" indicator takes 1 line.
 78435        var visHeight = Math.Max(1, _windowHeight() - 10 - 2 - 1);
 78436        if (selIdx < _session.LogScrollOffset)
 4437            _session.LogScrollOffset = selIdx;
 74438        else if (selIdx >= _session.LogScrollOffset + visHeight)
 2439            _session.LogScrollOffset = selIdx - visHeight + 1;
 170440    }
 441
 442    private void ClampScrollOffset()
 170443    {
 444        // When scrolled down, the "▲ more above" indicator takes 1 line, so only
 445        // viewHeight-1 block rows are visible at the bottom.
 170446        var viewHeight = Math.Max(1, _windowHeight() - 10 - 2);
 170447        var maxOff = Math.Max(0, Schedule.Count - (viewHeight - 1));
 170448        _session.LogScrollOffset = Math.Clamp(_session.LogScrollOffset, 0, maxOff);
 170449    }
 450
 5451    private void Save() => PunchStorage.Save(_session.WorkingDate, Schedule.Blocks);
 452
 453    private void Render(LiveDisplayContext ctx)
 0454    {
 0455        _view.Render(_session, _confirming, _confirmingDelete);
 0456        ctx.Refresh();
 0457    }
 458}