master
h 675 lines 21.9 KB
Raw
1 /*++
2
3 Copyright (c) Microsoft. All rights reserved.
4
5 Module Name:
6
7 TableOutput.h
8
9 Abstract:
10
11 Structured table output for the WSLC CLI. Cells are either plain text or
12 format-string + Sequence args. Sequences are zero display width; the table
13 measures visible width by counting non-placeholder characters. At render
14 time, sequences are emitted or stripped based on Terminal color state.
15
16 --*/
17 #pragma once
18
19 #include <algorithm>
20 #include <array>
21 #include <initializer_list>
22 #include <optional>
23 #include <string>
24 #include <string_view>
25 #include <utility>
26 #include <variant>
27 #include <vector>
28 #include <wil/result_macros.h>
29 #include "Terminal.h"
30 #include "VTSupport.h"
31
32 namespace wsl::windows::wslc {
33
34 using wsl::windows::common::vt::Sequence;
35
36 // A table cell: either plain text or a format string with Sequence placeholders.
37 // Every {} in the format string corresponds to a Sequence (zero display width).
38 // Visible width is the count of non-placeholder characters in the format string.
39 struct FormattedCell
40 {
41 std::wstring fmt;
42 std::vector<const Sequence*> sequences;
43
44 // Default constructor — empty cell.
45 FormattedCell() = default;
46
47 // Implicit from wstring — plain text cell (no formatting).
48 FormattedCell(std::wstring text) : fmt(std::move(text))
49 {
50 }
51
52 // Implicit from wstring_view.
53 FormattedCell(std::wstring_view text) : fmt(text)
54 {
55 }
56
57 // Implicit from literal.
58 FormattedCell(const wchar_t* text) : fmt(text)
59 {
60 }
61
62 // Formatted cell: format string with Sequence placeholders.
63 FormattedCell(std::wstring format, std::initializer_list<const Sequence*> seqs) : fmt(std::move(format)), sequences(seqs)
64 {
65 }
66
67 // Single-sequence cell: wraps text with the sequence and a trailing reset.
68 FormattedCell(std::wstring_view text, const Sequence& seq);
69
70 // Block temporaries: the cell only stores a pointer to seq, so binding a Sequence rvalue (including
71 // derived types such as the ConstructedSequence returned by Sgr()) would dangle once the full
72 // expression ends. Only long-lived Sequence instances may be used here.
73 FormattedCell(std::wstring_view text, const Sequence&& seq) = delete;
74
75 // Visible width: count characters that are not part of {} placeholders.
76 size_t VisibleWidth() const;
77
78 // Renders the cell with or without sequences.
79 // When vtEnabled is false, all {} placeholders are skipped (no VT output).
80 // When vtEnabled is true but colorEnabled is false, only non-color sequences are emitted.
81 // When both are true, all sequences are emitted.
82 std::wstring Render(bool vtEnabled, bool colorEnabled) const;
83
84 // Renders with visible text truncated to maxWidth characters, appending ellipsis.
85 // Sequences after the truncation point are still emitted (for resets).
86 std::wstring RenderTruncated(size_t maxWidth, bool vtEnabled, bool colorEnabled) const;
87 };
88
89 // Controls how a column handles content that exceeds its available width.
90 enum class ColumnOverflow
91 {
92 // Truncates content with an ellipsis at MaxWidth; column width is fixed and does not
93 // participate in the shrink loop.
94 Truncate,
95
96 // Participates in the shrink loop: reduced largest-first down to MinWidth, then truncated.
97 // PreferredShrink=true marks this as a higher-priority shrink target.
98 Shrink,
99
100 // Wraps long values across multiple physical rows; width is remaining space after other columns.
101 Wrap,
102 };
103
104 struct ColumnWidthConfig
105 {
106 static constexpr size_t NoLimit = 0;
107
108 size_t MinWidth = NoLimit; // Minimum visible width (NoLimit = header width).
109 size_t MaxWidth = NoLimit; // Maximum visible width cap (NoLimit = unlimited).
110 ColumnOverflow Overflow = ColumnOverflow::Truncate;
111 bool PreferredShrink = true; // Prioritizes this column in the shrink loop.
112 };
113
114 struct ColumnDefinition
115 {
116 std::wstring Name;
117 ColumnWidthConfig Config;
118 };
119
120 namespace details {
121
122 // Splits visible text into word-boundary chunks of at most maxWidth chars.
123 std::vector<std::wstring> WrapText(const std::wstring& text, size_t maxWidth);
124
125 } // namespace details
126
127 template <size_t FieldCount>
128 struct TableOutput
129 {
130 static_assert(FieldCount > 0, "TableOutput requires at least one column");
131
132 using header_t = std::array<std::wstring, FieldCount>;
133 using line_t = std::array<FormattedCell, FieldCount>;
134 using column_config_t = std::array<ColumnWidthConfig, FieldCount>;
135 using column_def_t = std::array<ColumnDefinition, FieldCount>;
136
137 static constexpr size_t DefaultColumnPadding = 3; // Docker-like spacing between columns
138
139 // Generous fallback used when the destination is redirected (no real console width).
140 // The wrap pass is skipped in that case so the receiver controls its own width.
141 static constexpr size_t DefaultRedirectedConsoleWidth = 2000;
142
143 TableOutput(Terminal& terminal, header_t&& header, size_t sizingBuffer = 50, size_t columnPadding = DefaultColumnPadding, Terminal::Level level = Terminal::Level::Output) :
144 m_terminal(terminal),
145 m_outputLevel(level),
146 m_vtEnabled(terminal.IsVTEnabled(level)),
147 m_colorEnabled(terminal.IsColorEnabled(level)),
148 m_sizingBuffer(sizingBuffer),
149 m_columnPadding(columnPadding)
150 {
151 InitializeColumns(std::move(header));
152 }
153
154 TableOutput(
155 Terminal& terminal,
156 header_t&& header,
157 column_config_t&& config,
158 size_t sizingBuffer = 50,
159 size_t columnPadding = DefaultColumnPadding,
160 Terminal::Level level = Terminal::Level::Output) :
161 m_terminal(terminal),
162 m_outputLevel(level),
163 m_vtEnabled(terminal.IsVTEnabled(level)),
164 m_colorEnabled(terminal.IsColorEnabled(level)),
165 m_sizingBuffer(sizingBuffer),
166 m_columnPadding(columnPadding),
167 m_columnConfigs(std::move(config))
168 {
169 InitializeColumns(std::move(header));
170 }
171
172 TableOutput(
173 Terminal& terminal,
174 column_def_t&& columns,
175 size_t sizingBuffer = 50,
176 size_t columnPadding = DefaultColumnPadding,
177 Terminal::Level level = Terminal::Level::Output) :
178 m_terminal(terminal),
179 m_outputLevel(level),
180 m_vtEnabled(terminal.IsVTEnabled(level)),
181 m_colorEnabled(terminal.IsColorEnabled(level)),
182 m_sizingBuffer(sizingBuffer),
183 m_columnPadding(columnPadding)
184 {
185 header_t headers;
186 for (size_t i = 0; i < FieldCount; ++i)
187 {
188 headers[i] = std::move(columns[i].Name);
189 m_columnConfigs[i] = columns[i].Config;
190 }
191 InitializeColumns(std::move(headers));
192 }
193
194 // Updates config store and Column state; safe to call after construction.
195 void SetColumnConfig(size_t columnIndex, const ColumnWidthConfig& config)
196 {
197 if (columnIndex < FieldCount)
198 {
199 m_columnConfigs[columnIndex] = config;
200 SyncColumnFromConfig(columnIndex);
201 }
202 }
203
204 void SetAlwaysShowHeader(bool alwaysShow)
205 {
206 m_alwaysShowHeader = alwaysShow;
207 }
208 void SetShowHeader(bool showHeader)
209 {
210 m_showHeader = showHeader;
211 }
212 // Sets spaces prepended to every row. Does not affect column width calculations.
213 void SetRowIndent(size_t spaces)
214 {
215 m_rowIndent = spaces;
216 }
217
218 // Overrides console width for column shrinking; pass 0 to restore default (Terminal-derived).
219 // When set, the wrap pass also runs as if a real console were attached.
220 void SetConsoleWidthOverride(size_t width)
221 {
222 m_consoleWidthOverride = width;
223 }
224
225 void WriteRow(line_t&& line)
226 {
227 m_empty = false;
228
229 // Buffer rows to size columns before flush. When every column is unbounded (no MaxWidth cap
230 // and no Wrap/Shrink overflow), buffer all rows so column widths grow to fit the widest value
231 // regardless of row order. With an overflow policy in play, cap the buffer at m_sizingBuffer
232 // and stream the remainder to bound memory for large result sets.
233 if (m_dataRowCount < m_sizingBuffer || AllColumnsUnbounded())
234 {
235 m_buffer.emplace_back(std::move(line));
236 ++m_dataRowCount;
237 }
238 else
239 {
240 EvaluateAndFlushBuffer();
241 OutputLineToStream(line);
242 }
243 }
244
245 // Emits a standalone text line that does not participate in column sizing.
246 // Use for section headers or blank separators between data rows.
247 void WriteLine(FormattedCell cell = {})
248 {
249 m_empty = false;
250
251 if (!m_bufferEvaluated)
252 {
253 m_buffer.emplace_back(std::move(cell));
254 }
255 else
256 {
257 OutputCellLineToStream(cell);
258 }
259 }
260
261 void Complete()
262 {
263 if (!m_empty)
264 {
265 EvaluateAndFlushBuffer();
266 }
267 else if (m_alwaysShowHeader && m_showHeader)
268 {
269 OutputHeaderOnly();
270 }
271 }
272
273 bool IsEmpty()
274 {
275 return m_empty;
276 }
277
278 private:
279 // A break entry is a FormattedCell rendered as a standalone line (section header or blank).
280 using buffer_entry_t = std::variant<line_t, FormattedCell>;
281
282 struct Column
283 {
284 std::wstring Name;
285 size_t MinLength = 0;
286 size_t MaxLength = 0;
287 size_t ConfiguredMaxLength = 0; // Max length from configuration
288 bool SpaceAfter = true;
289 ColumnOverflow Overflow = ColumnOverflow::Truncate;
290 };
291
292 Terminal& m_terminal;
293 Terminal::Level m_outputLevel;
294 const bool m_vtEnabled;
295 const bool m_colorEnabled;
296 std::array<Column, FieldCount> m_columns;
297 column_config_t m_columnConfigs;
298 size_t m_sizingBuffer;
299 size_t m_columnPadding;
300 size_t m_rowIndent = 0;
301 std::vector<buffer_entry_t> m_buffer;
302 size_t m_dataRowCount = 0;
303 bool m_bufferEvaluated = false;
304 bool m_empty = true;
305 bool m_alwaysShowHeader = true;
306 bool m_showHeader = true;
307 bool m_dropEmptyColumns = false;
308 size_t m_consoleWidthOverride = 0;
309
310 // True when no column constrains its width (no MaxWidth cap and no Wrap/Shrink overflow).
311 // Such tables buffer every row so a late, wide value is never truncated or misaligned.
312 bool AllColumnsUnbounded() const
313 {
314 for (size_t i = 0; i < FieldCount; ++i)
315 {
316 if (m_columns[i].ConfiguredMaxLength != 0 || m_columns[i].Overflow != ColumnOverflow::Truncate)
317 {
318 return false;
319 }
320 }
321 return true;
322 }
323
324 // Syncs Column state from m_columnConfigs[i]; call whenever a config entry changes.
325 void SyncColumnFromConfig(size_t i)
326 {
327 auto& col = m_columns[i];
328 const auto& cfg = m_columnConfigs[i];
329
330 col.Overflow = cfg.Overflow;
331 col.ConfiguredMaxLength = (cfg.MaxWidth != ColumnWidthConfig::NoLimit) ? cfg.MaxWidth : 0;
332
333 if (cfg.MinWidth != ColumnWidthConfig::NoLimit)
334 {
335 col.MinLength = std::max(col.Name.size(), cfg.MinWidth);
336 }
337 else
338 {
339 col.MinLength = col.Name.size();
340 }
341 }
342
343 void InitializeColumns(header_t&& header)
344 {
345 for (size_t i = 0; i < FieldCount; ++i)
346 {
347 m_columns[i].Name = std::move(header[i]);
348 m_columns[i].MaxLength = 0;
349 SyncColumnFromConfig(i);
350 }
351 }
352
353 // Returns the effective console width (in columns) of the destination, or std::nullopt
354 // when the destination is redirected. SetConsoleWidthOverride() takes precedence and is
355 // treated as a real console (the wrap pass uses has_value() to gate its behavior).
356 std::optional<size_t> GetEffectiveConsoleWidth() const
357 {
358 if (m_consoleWidthOverride > 0)
359 {
360 return m_consoleWidthOverride;
361 }
362
363 if (const auto width = m_terminal.GetConsoleWidth(m_outputLevel); width.has_value())
364 {
365 return static_cast<size_t>(*width);
366 }
367
368 return std::nullopt;
369 }
370
371 // Wraps a cell's visible text into chunks, preserving formatting on each chunk.
372 std::vector<FormattedCell> BuildWrappedCells(const FormattedCell& cell, const Column& col) const
373 {
374 if (col.Overflow != ColumnOverflow::Wrap || col.MaxLength == 0)
375 {
376 return {cell};
377 }
378
379 // Extract the visible text for wrapping.
380 const size_t visWidth = cell.VisibleWidth();
381 if (visWidth <= col.MaxLength)
382 {
383 return {cell};
384 }
385
386 // For plain cells, wrap the text directly.
387 if (cell.sequences.empty())
388 {
389 auto chunks = wsl::windows::wslc::details::WrapText(cell.fmt, col.MaxLength);
390 std::vector<FormattedCell> result;
391 result.reserve(chunks.size());
392 for (auto& chunk : chunks)
393 {
394 result.emplace_back(std::move(chunk));
395 }
396 return result;
397 }
398
399 // Wrapping only supports single-style cells (open + reset). Complex cells with
400 // multiple sequences (e.g., hyperlinks with distinct open/close pairs) cannot be
401 // reliably split across wrapped lines. Callers needing rich formatting in a wrapped
402 // column should use a single constructed Sequence that combines all escape codes.
403 THROW_HR_IF(E_INVALIDARG, cell.sequences.size() > 2);
404
405 // For formatted cells, extract visible text, wrap it, then re-apply formatting.
406 std::wstring visibleText;
407 visibleText.reserve(visWidth);
408 for (size_t i = 0; i < cell.fmt.size(); ++i)
409 {
410 if (i + 1 < cell.fmt.size() && cell.fmt[i] == L'{' && cell.fmt[i + 1] == L'}')
411 {
412 ++i;
413 }
414 else
415 {
416 visibleText += cell.fmt[i];
417 }
418 }
419
420 auto chunks = wsl::windows::wslc::details::WrapText(visibleText, col.MaxLength);
421 std::vector<FormattedCell> result;
422 result.reserve(chunks.size());
423 for (auto& chunk : chunks)
424 {
425 result.emplace_back(FormattedCell(std::wstring_view{chunk}, *cell.sequences.front()));
426 }
427 return result;
428 }
429
430 void OutputHeaderOnly()
431 {
432 for (size_t i = 0; i < FieldCount; ++i)
433 {
434 m_columns[i].MaxLength = m_columns[i].MinLength;
435 }
436
437 m_columns[FieldCount - 1].SpaceAfter = false;
438
439 line_t headerLine;
440 for (size_t i = 0; i < FieldCount; ++i)
441 {
442 headerLine[i] = FormattedCell(m_columns[i].Name);
443 }
444
445 OutputLineToStream(headerLine);
446 m_bufferEvaluated = true;
447 }
448
449 void EvaluateAndFlushBuffer()
450 {
451 if (m_bufferEvaluated)
452 {
453 return;
454 }
455
456 // Determine the maximum visible width for each column across all buffered data rows.
457 for (const auto& entry : m_buffer)
458 {
459 const auto* line = std::get_if<line_t>(&entry);
460 if (!line)
461 {
462 continue;
463 }
464
465 for (size_t i = 0; i < FieldCount; ++i)
466 {
467 size_t w = (*line)[i].VisibleWidth();
468
469 if (m_columns[i].ConfiguredMaxLength != ColumnWidthConfig::NoLimit)
470 {
471 w = std::min(w, m_columns[i].ConfiguredMaxLength);
472 }
473
474 m_columns[i].MaxLength = std::max(m_columns[i].MaxLength, w);
475 }
476 }
477
478 // Apply MinLength so empty columns still render at least as wide as their header.
479 for (size_t i = 0; i < FieldCount; ++i)
480 {
481 if (m_columns[i].MaxLength || !m_dropEmptyColumns)
482 {
483 m_columns[i].MaxLength = std::max(m_columns[i].MaxLength, m_columns[i].MinLength);
484 }
485 }
486
487 // Last column never needs trailing padding.
488 m_columns[FieldCount - 1].SpaceAfter = false;
489
490 // Disable SpaceAfter on columns that are followed only by empty columns.
491 for (size_t i = FieldCount - 1; i > 0; --i)
492 {
493 if (m_columns[i].MaxLength)
494 {
495 break;
496 }
497 m_columns[i - 1].SpaceAfter = false;
498 }
499
500 // Compute total visible width required to not truncate any columns.
501 size_t totalRequired = 0;
502 for (size_t i = 0; i < FieldCount; ++i)
503 {
504 totalRequired += m_columns[i].MaxLength + (m_columns[i].SpaceAfter ? m_columnPadding : 0);
505 }
506
507 const auto consoleWidthOpt = GetEffectiveConsoleWidth();
508 const size_t consoleWidth = consoleWidthOpt.value_or(DefaultRedirectedConsoleWidth);
509 const size_t availableWidth = (consoleWidth > m_rowIndent) ? consoleWidth - m_rowIndent : 0;
510
511 // Shrink pass: reduce Shrink columns until the total fits within the available width.
512 if (totalRequired > availableWidth)
513 {
514 size_t extra = totalRequired - availableWidth;
515
516 while (extra > 0)
517 {
518 size_t targetIndex = FieldCount;
519 size_t targetVal = 0;
520
521 for (size_t j = 0; j < FieldCount; ++j)
522 {
523 if (m_columns[j].Overflow != ColumnOverflow::Shrink)
524 {
525 continue;
526 }
527 if (m_columns[j].MaxLength <= m_columns[j].MinLength)
528 {
529 continue;
530 }
531
532 const bool isPreferred = m_columnConfigs[j].PreferredShrink;
533 const bool currentPreferred = (targetIndex < FieldCount) ? m_columnConfigs[targetIndex].PreferredShrink : false;
534
535 if (targetIndex == FieldCount || (isPreferred && !currentPreferred) ||
536 (isPreferred == currentPreferred && m_columns[j].MaxLength > targetVal))
537 {
538 targetIndex = j;
539 targetVal = m_columns[j].MaxLength;
540 }
541 }
542
543 if (targetIndex == FieldCount)
544 {
545 break;
546 }
547
548 m_columns[targetIndex].MaxLength -= 1;
549 extra -= 1;
550 }
551 }
552
553 // Wrap pass: clamp each Wrap column to remaining space after all other columns.
554 // Skipped when the destination is redirected so the receiver controls its own width.
555 if (consoleWidthOpt.has_value())
556 {
557 for (size_t i = 0; i < FieldCount; ++i)
558 {
559 if (m_columns[i].Overflow != ColumnOverflow::Wrap || !m_columns[i].MaxLength)
560 {
561 continue;
562 }
563
564 size_t otherWidth = 0;
565 for (size_t j = 0; j < FieldCount; ++j)
566 {
567 if (j != i)
568 {
569 otherWidth += m_columns[j].MaxLength + (m_columns[j].SpaceAfter ? m_columnPadding : 0);
570 }
571 }
572 if (m_columns[i].SpaceAfter)
573 {
574 otherWidth += m_columnPadding;
575 }
576
577 const size_t wrapBudget = (availableWidth > otherWidth) ? availableWidth - otherWidth : 1;
578
579 if (m_columns[i].MaxLength > wrapBudget)
580 {
581 m_columns[i].MaxLength = std::max(wrapBudget, m_columns[i].MinLength);
582 }
583 }
584 }
585
586 if (m_showHeader)
587 {
588 line_t headerLine;
589 for (size_t i = 0; i < FieldCount; ++i)
590 {
591 headerLine[i] = FormattedCell(m_columns[i].Name);
592 }
593 OutputLineToStream(headerLine);
594 }
595
596 for (const auto& entry : m_buffer)
597 {
598 if (const auto* line = std::get_if<line_t>(&entry))
599 {
600 OutputLineToStream(*line);
601 }
602 else if (const auto* cell = std::get_if<FormattedCell>(&entry))
603 {
604 OutputCellLineToStream(*cell);
605 }
606 }
607
608 m_bufferEvaluated = true;
609 }
610
611 void OutputCellLineToStream(const FormattedCell& cell)
612 {
613 m_terminal.Write(m_outputLevel, L"{}\n", cell.Render(m_vtEnabled, m_colorEnabled));
614 }
615
616 // Renders a logical row, emitting multiple physical rows for word-wrapping columns.
617 void OutputLineToStream(const line_t& line)
618 {
619 size_t physicalRows = 1;
620 std::array<std::vector<FormattedCell>, FieldCount> wrappedCells;
621 for (size_t i = 0; i < FieldCount; ++i)
622 {
623 wrappedCells[i] = BuildWrappedCells(line[i], m_columns[i]);
624 physicalRows = std::max(physicalRows, wrappedCells[i].size());
625 }
626
627 for (size_t row = 0; row < physicalRows; ++row)
628 {
629 std::wstring rowStr;
630
631 if (m_rowIndent > 0)
632 {
633 rowStr.append(m_rowIndent, L' ');
634 }
635
636 for (size_t i = 0; i < FieldCount; ++i)
637 {
638 const auto& col = m_columns[i];
639 if (!col.MaxLength)
640 {
641 continue;
642 }
643
644 // On continuation rows, exhausted columns render as blank.
645 static const FormattedCell emptyCell{L""};
646 const FormattedCell& cell = (row < wrappedCells[i].size()) ? wrappedCells[i][row] : emptyCell;
647 const size_t valueLength = cell.VisibleWidth();
648
649 if (col.Overflow != ColumnOverflow::Wrap && valueLength > col.MaxLength)
650 {
651 // Truncate and append ellipsis.
652 rowStr.append(cell.RenderTruncated(col.MaxLength, m_vtEnabled, m_colorEnabled));
653
654 if (col.SpaceAfter)
655 {
656 rowStr.append(m_columnPadding, L' ');
657 }
658 }
659 else
660 {
661 rowStr.append(cell.Render(m_vtEnabled, m_colorEnabled));
662
663 if (col.SpaceAfter)
664 {
665 rowStr.append(col.MaxLength - valueLength + m_columnPadding, L' ');
666 }
667 }
668 }
669
670 m_terminal.Write(m_outputLevel, L"{}\n", rowStr);
671 }
672 }
673 };
674
675 } // namespace wsl::windows::wslc