< Summary

Information
Class: Punch.CLI.PunchView
Assembly: punch
File(s): /home/runner/work/punch/punch/src/Punch.CLI/PunchView.cs
Line coverage
0%
Covered lines: 4
Uncovered lines: 416
Coverable lines: 420
Total lines: 548
Line coverage: 0.9%
Branch coverage
0%
Covered branches: 0
Total branches: 174
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
Render(...)100%210%
RenderTimeline(...)0%5550740%
SlotToPixel()0%620%
RenderMessages(...)0%4260%
BuildLogPanel(...)0%812280%
BuildHelpPanel()0%2040%
BuildTicketSummaryPanel(...)0%110100%
Truncate(...)0%4260%
BuildTicketPickerPanel(...)0%506220%
RenderInput(...)0%110100%
RenderStatusBar(...)0%2040%
RenderFieldLine(...)0%7280%

File(s)

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

#LineLine coverage
 1using System.Reflection;
 2using System.Text;
 3using Spectre.Console;
 4using Spectre.Console.Rendering;
 5
 6namespace Punch.CLI;
 7
 8// Renders the TUI panes from a PunchSession into the Spectre layout. Pure
 9// presentation: it reads session state and never mutates it.
 10internal sealed class PunchView
 11{
 12    private readonly Layout _layout;
 13
 7214    public PunchView(Layout layout)
 7215    {
 7216        _layout = layout;
 7217    }
 18
 19    public void Render(PunchSession session, bool confirming = false, bool confirmingDelete = false)
 020    {
 021        RenderTimeline(session);
 022        RenderMessages(session);
 023        RenderInput(session, confirming, confirmingDelete);
 024        RenderStatusBar(session);
 025    }
 26
 27    private void RenderTimeline(PunchSession session)
 028    {
 029        var cursorSlot = session.CursorSlot;
 030        var selectionLength = session.SelectionLength;
 031        var selectedBlock = session.SelectedBlock;
 32
 033        var consoleWidth = System.Console.WindowWidth;
 034        var barWidth = Math.Max(1, consoleWidth - 4); // account for panel border + padding
 035        var endSlot = cursorSlot + selectionLength;
 036        var timeLabel = SlotTime.FormatRange(cursorSlot, endSlot);
 37
 038        var sorted = session.Blocks.OrderBy(b => b.StartSlot).ToList();
 39
 40        // Use a fixed pixels-per-slot so every 15-min segment is the same width.
 41        // On terminals narrower than 96 columns barWidth/96 rounds to 0, so fall
 42        // back to proportional mapping to prevent bar overflow.
 043        var pixelsPerSlot = barWidth / 96;
 044        int SlotToPixel(int slot) => pixelsPerSlot > 0
 045            ? slot * pixelsPerSlot
 046            : (int)((long)slot * barWidth / 96);
 047        var totalBarWidth = pixelsPerSlot > 0 ? pixelsPerSlot * 96 : barWidth;
 048        var pixelState = new int[totalBarWidth]; // 0=free, 1=booked, 2=selected, 3=selected-existing
 049        var pixelBlockIndex = new int[totalBarWidth];
 050        Array.Fill(pixelBlockIndex, -1);
 051        for (var blockIdx = 0; blockIdx < sorted.Count; blockIdx++)
 052        {
 053            var block = sorted[blockIdx];
 054            var bStart = SlotToPixel(block.StartSlot);
 055            var bEndExcl = SlotToPixel(block.StartSlot + block.Length);
 056            bStart = Math.Clamp(bStart, 0, totalBarWidth);
 057            bEndExcl = Math.Clamp(bEndExcl, bStart, totalBarWidth);
 058            for (var px = bStart; px < bEndExcl; px++)
 059            {
 060                pixelState[px] = 1;
 061                pixelBlockIndex[px] = blockIdx;
 062            }
 063        }
 64
 065        var selStartPos = SlotToPixel(cursorSlot);
 066        var selEndExcl = SlotToPixel(endSlot);
 067        if (selEndExcl == selStartPos) selEndExcl = selStartPos + 1;
 068        selStartPos = Math.Clamp(selStartPos, 0, totalBarWidth - 1);
 069        var selEndPos = Math.Clamp(selEndExcl - 1, selStartPos, totalBarWidth - 1);
 70        // State 3 = selected existing block (cyan), State 2 = free selection (yellow)
 071        var selPixelState = selectedBlock != null ? 3 : 2;
 072        for (var i = selStartPos; i <= selEndPos; i++)
 073            pixelState[i] = selPixelState;
 74
 75        // Build hour labels line
 076        var labelChars = new char[totalBarWidth];
 077        Array.Fill(labelChars, ' ');
 78        // Hour ticks turn the label line into a ruler. Skip them on narrow
 79        // terminals (proportional fallback) where they'd land on top of each other.
 080        if (pixelsPerSlot >= 1)
 081        {
 082            for (var h = 1; h < 24; h++)
 083                labelChars[SlotToPixel(h * 4)] = '╵';
 084        }
 085        var hourMarkers = new[] { 0, 6, 12, 18, 24 };
 086        foreach (var h in hourMarkers)
 087        {
 088            var pos = SlotToPixel(h * 4);
 089            var label = h == 12 ? "12pm" : h < 12 ? $"{h}am" : $"{h - 12}pm";
 090            if (h == 0 || h == 24) label = "12am";
 91            // Right-align the end marker so it doesn't overflow
 092            if (h == 24) pos = totalBarWidth - label.Length;
 093            for (var i = 0; i < label.Length && pos + i < totalBarWidth; i++)
 094                labelChars[pos + i] = label[i];
 095        }
 96
 97        // "Now" marker: when viewing today, point at the current time under the
 98        // bar so the day has an anchor. Nudge off hour-label text if it collides.
 099        var nowMarkerPos = -1;
 0100        if (session.WorkingDate == DateOnly.FromDateTime(DateTime.Now))
 0101        {
 0102            var minutesOfDay = (int)DateTime.Now.TimeOfDay.TotalMinutes;
 0103            var pos = Math.Clamp((int)((long)minutesOfDay * totalBarWidth / 1440), 0, totalBarWidth - 1);
 0104            foreach (var offset in new[] { 0, 1, -1, 2, -2, 3, -3, 4, -4 })
 0105            {
 0106                var candidate = pos + offset;
 0107                if (candidate < 0 || candidate >= totalBarWidth) continue;
 0108                if (labelChars[candidate] == ' ' || labelChars[candidate] == '╵')
 0109                {
 0110                    pos = candidate;
 0111                    break;
 112                }
 0113            }
 0114            labelChars[pos] = '▲';
 0115            nowMarkerPos = pos;
 0116        }
 117
 118        // Position the time label above the selection midpoint
 0119        var midPos = (selStartPos + selEndPos) / 2;
 0120        var timeLabelStart = Math.Max(0, Math.Min(midPos - timeLabel.Length / 2, totalBarWidth - timeLabel.Length));
 0121        var topLine = new string(' ', timeLabelStart) + timeLabel;
 122
 123        // Build bar markup with colored segments (alternating colors for adjacent blocks)
 0124        var barMarkup = new StringBuilder();
 0125        var currentState = -1;
 0126        var currentBlockIndex = -1;
 0127        for (var i = 0; i < totalBarWidth; i++)
 0128        {
 0129            var needNewTag = pixelState[i] != currentState ||
 0130                             (pixelState[i] == 1 && pixelBlockIndex[i] != currentBlockIndex);
 0131            if (needNewTag)
 0132            {
 0133                if (currentState >= 0) barMarkup.Append("[/]");
 0134                currentState = pixelState[i];
 0135                currentBlockIndex = pixelBlockIndex[i];
 0136                barMarkup.Append(currentState switch
 0137                {
 0138                    1 => currentBlockIndex >= 0 && currentBlockIndex < sorted.Count && sorted[currentBlockIndex].IsUnpai
 0139                            ? "[grey50]"
 0140                            : currentBlockIndex % 2 == 0 ? "[orangered1]" : "[orange3]",
 0141                    2 => "[bold yellow]",
 0142                    3 => "[bold white]",
 0143                    _ => "[dim]"
 0144                });
 0145            }
 0146            barMarkup.Append(currentState switch
 0147            {
 0148                0 => '─',
 0149                3 => '▒',
 0150                _ => '█'
 0151            });
 0152        }
 0153        if (currentState >= 0) barMarkup.Append("[/]");
 154
 155        // Center the bar within the panel width
 0156        var pad = Math.Max(0, (barWidth - totalBarWidth) / 2);
 0157        var padStr = new string(' ', pad);
 158
 159        // Render the labels line, lifting the now marker out of the dim span so
 160        // it stands out in the accent color.
 0161        var labelsLine = new string(labelChars);
 162        string labelsMarkup;
 0163        if (nowMarkerPos >= 0)
 0164        {
 0165            var left = Markup.Escape(padStr + labelsLine[..nowMarkerPos]);
 0166            var right = Markup.Escape(labelsLine[(nowMarkerPos + 1)..]);
 0167            labelsMarkup = $"[dim]{left}[/][bold orangered1]▲[/][dim]{right}[/]";
 0168        }
 169        else
 0170        {
 0171            labelsMarkup = $"[dim]{Markup.Escape(padStr + labelsLine)}[/]";
 0172        }
 173
 0174        var timelineContent = new Rows(
 0175            new Markup($"[bold]{Markup.Escape(padStr + topLine)}[/]"),
 0176            new Markup(padStr + barMarkup.ToString()),
 0177            new Markup(labelsMarkup));
 178
 0179        _layout["Timeline"].Update(
 0180            new Panel(timelineContent)
 0181                .Header($"Timeline · {session.WorkingDate:dddd, MMM d}")
 0182                .Expand()
 0183                .Border(BoxBorder.Rounded));
 0184    }
 185
 186    private void RenderMessages(PunchSession session)
 0187    {
 0188        if (session.ShowHelp)
 189            // Split like the picker/summary so the time log stays visible on the
 190            // left while help occupies the right half.
 0191            _layout["Messages"].Update(new Layout("MessagesSplit")
 0192                .SplitColumns(
 0193                    new Layout("Log").Update(BuildLogPanel(session)),
 0194                    new Layout("Help").Update(BuildHelpPanel())));
 0195        else if (session.ShowTicketPicker)
 196            // Split the content area so the time log stays visible on the left
 197            // while the picker occupies the right half.
 0198            _layout["Messages"].Update(new Layout("MessagesSplit")
 0199                .SplitColumns(
 0200                    new Layout("Log").Update(BuildLogPanel(session)),
 0201                    new Layout("Picker").Update(BuildTicketPickerPanel(session))));
 0202        else if (session.ShowTicketSummary)
 203            // Split like the picker so the time log stays visible on the left
 204            // while the summary occupies the right half.
 0205            _layout["Messages"].Update(new Layout("MessagesSplit")
 0206                .SplitColumns(
 0207                    new Layout("Log").Update(BuildLogPanel(session)),
 0208                    new Layout("Summary").Update(BuildTicketSummaryPanel(session))));
 209        else
 0210            _layout["Messages"].Update(BuildLogPanel(session));
 0211    }
 212
 213    private static IRenderable BuildLogPanel(PunchSession session)
 0214    {
 0215        var selectedBlock = session.SelectedBlock;
 0216        var sorted = session.Blocks.OrderBy(b => b.StartSlot).ToList();
 217
 218        // Messages pane: show booked blocks sorted chronologically with scrolling
 0219        var consoleHeight = System.Console.WindowHeight;
 0220        var messagesHeight = Math.Max(1, consoleHeight - 10 - 2); // 10 = fixed panes (5+4+1), 2 = panel border
 221
 222        // Clamp scroll offset to valid range and reserve lines for scroll indicators
 0223        var availableLines = messagesHeight;
 0224        var clampedOffset = Math.Clamp(session.LogScrollOffset, 0, Math.Max(0, sorted.Count - 1));
 225
 0226        var hasMoreAbove = clampedOffset > 0;
 0227        if (hasMoreAbove) availableLines--;
 228
 0229        var hasMoreBelow = clampedOffset + availableLines < sorted.Count;
 0230        if (hasMoreBelow) availableLines--;
 231
 0232        availableLines = Math.Max(1, availableLines);
 233        // Re-clamp offset so we don't scroll past the end
 0234        var maxScrollOffset = Math.Max(0, sorted.Count - availableLines);
 0235        clampedOffset = Math.Min(clampedOffset, maxScrollOffset);
 236        // Recalculate indicators after clamping
 0237        hasMoreAbove = clampedOffset > 0;
 0238        hasMoreBelow = clampedOffset + availableLines < sorted.Count;
 239
 0240        var visibleBlocks = sorted.Skip(clampedOffset).Take(availableLines).ToList();
 241
 242        IRenderable messagesContent;
 0243        if (visibleBlocks.Count == 0)
 0244        {
 0245            messagesContent = new Markup("[dim]No entries yet. Select a time range and press Enter.[/]");
 0246        }
 247        else
 0248        {
 0249            var renderables = new List<IRenderable>();
 0250            if (hasMoreAbove)
 0251                renderables.Add(new Markup($"[dim]  ▲ {clampedOffset} more above (PgUp)[/]"));
 0252            foreach (var b in visibleBlocks)
 0253            {
 0254                var timeRange = SlotTime.FormatRange(b.StartSlot, b.StartSlot + b.Length);
 0255                var escaped = Markup.Escape(b.Label);
 0256                var isSelected = selectedBlock != null && b.StartSlot == selectedBlock.StartSlot && b.Length == selected
 0257                var blockIdx = sorted.IndexOf(b);
 0258                var squareColor = isSelected
 0259                    ? "white"
 0260                    : b.IsUnpaid
 0261                        ? "grey50"
 0262                        : blockIdx % 2 == 0 ? "orangered1" : "orange3";
 0263                var durationText = Duration.Humanize(b.Length * 15);
 0264                var ticketDisplay = string.IsNullOrEmpty(b.Ticket) ? "" : $"[cyan]{Markup.Escape(b.Ticket)}[/] ";
 0265                renderables.Add(new Markup($"[{squareColor}]■[/] [bold]{timeRange}[/] {ticketDisplay}{escaped} [dim grey
 0266            }
 0267            if (hasMoreBelow)
 0268            {
 0269                var belowCount = sorted.Count - clampedOffset - availableLines;
 0270                renderables.Add(new Markup($"[dim]  ▼ {belowCount} more below (PgDn)[/]"));
 0271            }
 0272            messagesContent = new Rows(renderables);
 0273        }
 274
 0275        var header = sorted.Count == 0
 0276            ? "Time Logged"
 0277            : $"Time Logged · {sorted.Count} {(sorted.Count == 1 ? "entry" : "entries")}";
 0278        return new Panel(messagesContent)
 0279            .Header(header)
 0280            .Expand()
 0281            .Border(BoxBorder.Rounded);
 0282    }
 283
 284    private static IRenderable BuildHelpPanel()
 0285    {
 0286        var version = Assembly.GetExecutingAssembly()
 0287            .GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "0.0.0";
 0288        var titleLine = new Markup($"[bold][red]p[/][orangered1]u[/][darkorange]n[/][orange3]c[/][orange1]h[/][/] [dim]v
 0289        var helpText = new Markup(
 0290            "[bold]Left/Right[/]  Move cursor / Jump between blocks\n" +
 0291            "[bold]Up/Down[/]     Resize selection\n" +
 0292            "[bold]PgUp/PgDn[/]   Scroll time log\n" +
 0293            "[bold]Enter[/]       Log time entry\n" +
 0294            "[bold]Tab[/]         Switch input field\n" +
 0295            "[bold]Ctrl+E[/]      Edit selected entry\n" +
 0296            "[bold]Ctrl+D[/]      Delete selected entry\n" +
 0297            "[bold]Ctrl+Q, Q[/]   Quit\n" +
 0298            "[bold]?[/]           Toggle this help\n" +
 0299            "[bold]F3, Ctrl+T[/]   Ticket summary\n" +
 0300            "[bold]F4, Ctrl+P[/]   Pick ticket for entry");
 0301        var helpContent = new Rows(
 0302            titleLine,
 0303            new Text(" "),
 0304            helpText,
 0305            new Text(" "),
 0306            new Markup("[dim]Esc/? cancel[/]"));
 307        // A single Expand-ed panel that fills the region height exactly, matching
 308        // the log panel beside it.
 0309        return new Panel(helpContent)
 0310            .Header("Help")
 0311            .Border(BoxBorder.Rounded)
 0312            .Expand();
 0313    }
 314
 315    private static IRenderable BuildTicketSummaryPanel(PunchSession session)
 0316    {
 0317        var blocks = session.Blocks;
 0318        var ticketGroups = blocks
 0319            .GroupBy(b => string.IsNullOrEmpty(b.Ticket) ? "" : b.Ticket)
 0320            .Select(g => new { Ticket = g.Key, TotalMinutes = g.Sum(b => b.Length * 15) })
 0321            .OrderBy(g => g.Ticket == "" ? 1 : 0)
 0322            .ThenBy(g => g.Ticket)
 0323            .ToList();
 324
 0325        var summaryLines = new List<IRenderable>();
 0326        foreach (var g in ticketGroups)
 0327        {
 0328            var dur = Duration.Humanize(g.TotalMinutes);
 0329            var visibleName = g.Ticket == "" ? "Other" : g.Ticket;
 0330            var paddedName = visibleName.PadRight(20);
 0331            var ticketLabel = g.Ticket == "" ? $"[dim]{paddedName}[/]" : $"[cyan]{Markup.Escape(paddedName)}[/]";
 0332            summaryLines.Add(new Markup($"  {ticketLabel} {dur}"));
 0333        }
 334
 0335        var billableMinutes = blocks.Where(b => !b.IsUnpaid).Sum(b => b.Length * 15);
 0336        var unbillableMinutes = blocks.Where(b => b.IsUnpaid).Sum(b => b.Length * 15);
 0337        var totalDur = Duration.HumanizeTotal(blocks.Sum(b => b.Length * 15));
 0338        summaryLines.Add(new Markup($"  [dim]{new string('─', 28)}[/]"));
 0339        summaryLines.Add(new Markup($"  {"Billable".PadRight(20)} {Duration.Humanize(billableMinutes)}"));
 0340        summaryLines.Add(new Markup($"  [grey50]{"Unbillable".PadRight(20)} {Duration.Humanize(unbillableMinutes)}[/]"))
 0341        summaryLines.Add(new Markup($"  [bold]{"Total".PadRight(20)} {totalDur}[/]"));
 342
 0343        summaryLines.Add(new Text(" "));
 0344        summaryLines.Add(new Markup("[dim]Esc/F3 close[/]"));
 345
 346        // A single Expand-ed panel that fills the region height exactly, matching
 347        // the log panel beside it.
 0348        return new Panel(new Rows(summaryLines))
 0349            .Header("Ticket Summary")
 0350            .Border(BoxBorder.Rounded)
 0351            .Expand();
 0352    }
 353
 354    // Clamps text to a maximum display width, appending an ellipsis when cut.
 355    private static string Truncate(string text, int maxWidth)
 0356    {
 0357        if (maxWidth <= 0 || text.Length <= maxWidth)
 0358            return text;
 0359        return maxWidth == 1 ? "…" : text[..(maxWidth - 1)] + "…";
 0360    }
 361
 362    private static IRenderable BuildTicketPickerPanel(PunchSession session)
 0363    {
 0364        var lines = new List<IRenderable>();
 0365        if (session.Tickets.Count == 0)
 0366        {
 0367            lines.Add(new Markup("[dim]No tickets found.[/]"));
 0368            lines.Add(new Text(" "));
 0369            lines.Add(new Markup("[dim]Create [/][cyan]~/.punch/tickets.txt[/][dim] with one ticket per line,[/]"));
 0370            lines.Add(new Markup("[dim]tab- or comma-delimited as [/][cyan]TICKET<tab|,>Title[/][dim].[/]"));
 0371        }
 372        else
 0373        {
 374            // Window the list around the cursor so long lists scroll instead of
 375            // overflowing the pane. Budget: panel interior (messages region minus
 376            // its border) minus the footer block (blank + hint = 2). Rows are
 377            // truncated to one physical line below so the budget holds exactly.
 0378            var consoleHeight = System.Console.WindowHeight;
 0379            var interior = Math.Max(3, consoleHeight - 10 - 2);
 0380            var maxRows = Math.Max(1, interior - 2);
 381
 382            // The picker occupies the right half of the content width; keep each
 383            // row to a single line so wrapping never eats into the row budget.
 0384            var textWidth = Math.Max(8, System.Console.WindowWidth / 2 - 4);
 385
 0386            var count = session.Tickets.Count;
 0387            var cursor = session.TicketPickerCursor;
 388
 389            // The ▲/▼ indicators each consume a row, but whether they appear
 390            // depends on the window position — which depends on how many rows fit.
 391            // Iterate to a fixed point so the indicator lines never overshoot the
 392            // budget (an overshoot of one would clip the footer).
 0393            var visibleRows = Math.Min(count, maxRows);
 0394            var offset = 0;
 0395            var hasMoreAbove = false;
 0396            var hasMoreBelow = false;
 0397            for (var iter = 0; iter < 4; iter++)
 0398            {
 0399                offset = count <= visibleRows
 0400                    ? 0
 0401                    : Math.Clamp(cursor - visibleRows / 2, 0, count - visibleRows);
 0402                hasMoreAbove = offset > 0;
 0403                hasMoreBelow = offset + visibleRows < count;
 0404                var fit = Math.Max(1, Math.Min(count,
 0405                    maxRows - (hasMoreAbove ? 1 : 0) - (hasMoreBelow ? 1 : 0)));
 0406                if (fit == visibleRows)
 0407                    break;
 0408                visibleRows = fit;
 0409            }
 410
 0411            if (hasMoreAbove)
 0412                lines.Add(new Markup($"  [dim]▲ {offset} more[/]"));
 0413            for (var i = offset; i < offset + visibleRows && i < count; i++)
 0414            {
 0415                var t = session.Tickets[i];
 416                // Prefix is 4 chars ("  > " / "    "); reserve the rest for the
 417                // ticket + two-space gap + title, truncating the title to fit.
 0418                var title = Truncate(t.Title, Math.Max(1, textWidth - 4 - t.Ticket.Length - 2));
 0419                var ticket = Markup.Escape(t.Ticket);
 0420                var titleEsc = Markup.Escape(title);
 0421                if (i == cursor)
 0422                    lines.Add(new Markup($"  [bold yellow]> {ticket}[/]  {titleEsc}"));
 423                else
 0424                    lines.Add(new Markup($"    [cyan]{ticket}[/]  [dim]{titleEsc}[/]"));
 0425            }
 0426            if (hasMoreBelow)
 0427                lines.Add(new Markup($"  [dim]▼ {count - offset - visibleRows} more[/]"));
 0428        }
 0429        lines.Add(new Text(" "));
 0430        lines.Add(new Markup("[dim]↑/↓ select · Enter assign · Esc/F4 cancel[/]"));
 431
 432        // A single Expand-ed panel that fills the region height exactly, matching
 433        // the log panel beside it so the footer never gets clipped.
 0434        return new Panel(new Rows(lines))
 0435            .Header("Pick Ticket")
 0436            .Border(BoxBorder.Rounded)
 0437            .Expand();
 0438    }
 439
 440    private void RenderInput(PunchSession session, bool confirming, bool confirmingDelete)
 0441    {
 0442        var selectedBlock = session.SelectedBlock;
 0443        var editing = session.Editing;
 444
 0445        if (confirming)
 0446        {
 0447            _layout["Input"].Update(
 0448                new Panel(new Markup("[bold yellow]Press Q again to quit[/]"))
 0449                    .Expand()
 0450                    .Border(BoxBorder.Rounded));
 0451        }
 0452        else if (confirmingDelete)
 0453        {
 0454            _layout["Input"].Update(
 0455                new Panel(new Markup("[bold yellow]Press D again to delete[/]"))
 0456                    .Expand()
 0457                    .Border(BoxBorder.Rounded));
 0458        }
 0459        else if (selectedBlock != null && editing)
 0460        {
 0461            var descLine = RenderFieldLine("Description", session.InputBuffer, session.InputCursor, session.ActiveField 
 0462            var tickLine = RenderFieldLine("Ticket", session.TicketBuffer, session.TicketCursor, session.ActiveField == 
 0463            _layout["Input"].Update(
 0464                new Panel(new Rows(new Markup(descLine), new Markup(tickLine)))
 0465                    .Header("Input [cyan](editing)[/]")
 0466                    .Expand()
 0467                    .Border(BoxBorder.Rounded));
 0468        }
 0469        else if (selectedBlock != null)
 0470        {
 0471            var labelText = Markup.Escape(selectedBlock.Label);
 0472            var ticketText = Markup.Escape(selectedBlock.Ticket);
 0473            var descLine = $"[bold]Description:[/] {labelText}";
 0474            var tickLine = $"[bold]Ticket:[/]      {(string.IsNullOrEmpty(ticketText) ? "[dim]none[/]" : ticketText)}";
 0475            _layout["Input"].Update(
 0476                new Panel(new Rows(new Markup(descLine), new Markup(tickLine)))
 0477                    .Header("Input")
 0478                    .Expand()
 0479                    .Border(BoxBorder.Rounded));
 0480        }
 481        else
 0482        {
 0483            var descLine = RenderFieldLine("Description", session.InputBuffer, session.InputCursor, session.ActiveField 
 0484            var tickLine = RenderFieldLine("Ticket", session.TicketBuffer, session.TicketCursor, session.ActiveField == 
 0485            _layout["Input"].Update(
 0486                new Panel(new Rows(new Markup(descLine), new Markup(tickLine)))
 0487                    .Header("Input")
 0488                    .Expand()
 0489                    .Border(BoxBorder.Rounded));
 0490        }
 0491    }
 492
 493    private void RenderStatusBar(PunchSession session)
 0494    {
 0495        var consoleWidth = System.Console.WindowWidth;
 0496        var filePath = session.FilePath;
 0497        var totalMinutesAll = session.Blocks.Where(b => !b.IsUnpaid).Sum(b => b.Length * 15);
 0498        var totalFormatted = Duration.HumanizeTotal(totalMinutesAll);
 0499        var statusLeftPlain = $"  {filePath}  ?=help F3=summary F4=tickets";
 500        string statusRightPlain;
 501        string statusRightMarkup;
 0502        if (session.TargetHours > 0)
 0503        {
 0504            var targetMinutes = session.TargetHours * 60;
 0505            var percent = totalMinutesAll * 100 / targetMinutes;
 506            // 10-cell gauge toward the daily target; the percent keeps counting
 507            // past 100 but the gauge pegs at full.
 0508            var filled = Math.Clamp(totalMinutesAll * 10 / targetMinutes, 0, 10);
 0509            var gaugeFilled = new string('▰', filled);
 0510            var gaugeEmpty = new string('▱', 10 - filled);
 0511            statusRightPlain = $"{totalFormatted}  {gaugeFilled}{gaugeEmpty}  {percent}% of {session.TargetHours}h  ";
 0512            statusRightMarkup = $"[bold white]{Markup.Escape(totalFormatted)}  [/][bold yellow]{gaugeFilled}[/][dim]{gau
 513            // On narrow terminals the gauge is the first thing to go.
 0514            if (statusLeftPlain.Length + statusRightPlain.Length > consoleWidth)
 0515            {
 0516                statusRightPlain = $"{totalFormatted}    {percent}% of {session.TargetHours}h  ";
 0517                statusRightMarkup = $"[bold white]{Markup.Escape(statusRightPlain)}[/]";
 0518            }
 0519        }
 520        else
 0521        {
 0522            statusRightPlain = $"{totalFormatted}  ";
 0523            statusRightMarkup = $"[bold white]{Markup.Escape(statusRightPlain)}[/]";
 0524        }
 0525        var padding = Math.Max(0, consoleWidth - statusLeftPlain.Length - statusRightPlain.Length);
 0526        var statusBar = $"[white on orangered1]  {Markup.Escape(filePath)}  [bold yellow]?=help F3=summary F4=tickets[/]
 0527        _layout["StatusBar"].Update(new Markup(statusBar));
 0528    }
 529
 530    private static string RenderFieldLine(string fieldName, StringBuilder buffer, int cursor, bool isActive)
 0531    {
 0532        var paddedName = fieldName.PadRight(11);
 0533        if (isActive)
 0534        {
 0535            var text = buffer.ToString();
 0536            var beforeCursor = Markup.Escape(text[..cursor]);
 0537            var cursorChar = cursor < text.Length ? Markup.Escape(text[cursor].ToString()) : " ";
 0538            var afterCursor = cursor < text.Length ? Markup.Escape(text[(cursor + 1)..]) : "";
 0539            return $"[bold]{Markup.Escape(paddedName)}:[/] {beforeCursor}[invert]{cursorChar}[/]{afterCursor}";
 540        }
 541        else
 0542        {
 0543            var escaped = Markup.Escape(buffer.ToString());
 0544            var display = string.IsNullOrEmpty(escaped) ? "[dim]empty[/]" : $"[dim]{escaped}[/]";
 0545            return $"[dim]{Markup.Escape(paddedName)}:[/] {display}";
 546        }
 0547    }
 548}