diff --git a/.gitignore b/.gitignore index 8476c4cdb..c010663f1 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,8 @@ perf.data* # lane looks next -- confusingly, since it is named like the file it is not. # Anchored to the root: this pattern is only ever created there. /.clang-format-neutered.* + +# The design and plan documents the agent workflow (superpowers brainstorming / +# writing-plans) writes are local working files and are never tracked. Anchored +# to the repository root's docs/ directory, like the rules above. +/docs/superpowers/ diff --git a/docs/tools/pacectl.md b/docs/tools/pacectl.md index 563b6b32c..1c93a19c8 100644 --- a/docs/tools/pacectl.md +++ b/docs/tools/pacectl.md @@ -863,18 +863,27 @@ A terminal smaller than a panel's minimum gets one line, `needs x | Key | What it does | |---|---| -| `q`, `Q`, `Esc`, `Ctrl-C` | quit, restoring the terminal | +| `q`, `Q`, `Esc`, `Ctrl-C` | quit, restoring the terminal; `Esc` closes a machine's detail instead while one is open | | `Tab`, `→` / `←` | the fleet's next / previous section | | `1` … `9` | the fleet's sections by position | | `m` `w` `l` `c` `r` `!` `t` | the fleet's sections by name: machines, workers, leases, members (`c`, for cluster: `m` is taken), revoked keys, conditions (`!`: `c` is taken) and tiers | | `PgDn` / `PgUp`, `Home` | scroll the fleet's table a page, or back to its top | | `/` | filter the fleet's table: type, `Enter` keeps the filter, `Esc` clears it, `Backspace` edits it | +| `↑` / `↓` | highlight the fleet chart's lanes, then the table's rows, and the machine they name everywhere it appears | +| `Enter` | open the highlighted machine's detail: its rows in every section that names machines; `Esc` goes back | A section starts at its top with no filter. The filter keeps the rows with a cell containing the typed text, ignoring ASCII case, and the table says how many rows of how many it kept. While a filter is being typed, every key is text except `Ctrl-C`, so a `q` in a machine's name does not end the session. +A machine's detail replaces the tiles, the chart, the strip and the table until `Esc` +closes it, and the highlight is where it was. A machine the latest sample no longer +lists says `not in the latest sample` rather than drawing empty sections. `Enter` +on a row of a section that names no machine, such as revoked keys, opens nothing. +The detail's tables do not scroll: a long one shows its first rows and `+N more`, and +on a short terminal whole sections give way from the bottom. + ### How it draws: Sixel, Unicode, ASCII What the terminal can draw is asked once, when the session starts, and decides diff --git a/mkdocs.yml b/mkdocs.yml index 520c9c34d..f949817eb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -35,6 +35,14 @@ validation: not_in_nav: | snippets/* +# The design and plan files the agent workflow writes under `docs/superpowers/` +# are local working documents: untracked (.gitignore) and never published. They +# still sit in a developer's docs/ tree, so `exclude_docs` keeps a local +# `mkdocs build --strict` from refusing them as pages missing from `nav:`; +# `not_in_nav` would only hide them from the menu and publish every one of them. +exclude_docs: | + /superpowers/ + site_name: Pace site_description: A memcached- and Redis-compatible cache daemon, and a portable compile cache site_author: Pace contributors diff --git a/src/apps/pacectl/CMakeLists.txt b/src/apps/pacectl/CMakeLists.txt index d4260e24c..a86a67fb0 100644 --- a/src/apps/pacectl/CMakeLists.txt +++ b/src/apps/pacectl/CMakeLists.txt @@ -26,6 +26,7 @@ set(_pace_cli_sources DashboardLoop.cpp DashboardSampler.cpp FleetChartModel.cpp + FleetFocus.cpp FleetDocument.cpp HistoryChart.cpp LiveEventSource.cpp @@ -111,6 +112,7 @@ if(PACE_BUILD_TESTS) DashboardLoop_test.cpp DashboardSampler_test.cpp FleetChartModel_test.cpp + FleetFocus_test.cpp FleetDocument_test.cpp HistoryChart_test.cpp LiveEventSource_test.cpp diff --git a/src/apps/pacectl/DashboardGlyphs.hpp b/src/apps/pacectl/DashboardGlyphs.hpp index bae9ea000..33352d6cd 100644 --- a/src/apps/pacectl/DashboardGlyphs.hpp +++ b/src/apps/pacectl/DashboardGlyphs.hpp @@ -79,6 +79,9 @@ struct RungGlyphs /// with `Δ` (`DeltaMark`) and drawn through this column, one cell either way, so the layout is the same on /// every rung and no UTF-8 byte reaches an ASCII terminal. std::string_view delta; + + /// The Up and Down keys as a hint names them: `↑↓`, and `Up/Dn` where the terminal is ASCII. + std::string_view upDown; }; /// The mark a note is written with for a change over an interval, and drawn as its rung's `RungGlyphs::delta`. @@ -106,7 +109,8 @@ inline constexpr EnumTable RungGlyphTable { { .gaugeHeld = "▒", .gaugeEmpty = "░", .gaugeClose = "", - .delta = DeltaMark }, + .delta = DeltaMark, + .upDown = "↑↓" }, { .rung = RenderRung::Unicode, .sparkLevels = BlockLevels, .chartLevels = BlockLevels, @@ -122,7 +126,8 @@ inline constexpr EnumTable RungGlyphTable { { .gaugeHeld = "▒", .gaugeEmpty = "░", .gaugeClose = "", - .delta = DeltaMark }, + .delta = DeltaMark, + .upDown = "↑↓" }, { .rung = RenderRung::Ascii, .sparkLevels = {}, .chartLevels = AsciiChartLevels, @@ -138,7 +143,8 @@ inline constexpr EnumTable RungGlyphTable { { .gaugeHeld = "=", .gaugeEmpty = ".", .gaugeClose = "]", - .delta = "d" }, + .delta = "d", + .upDown = "Up/Dn" }, { .rung = RenderRung::Piped, .sparkLevels = {}, .chartLevels = {}, @@ -154,7 +160,8 @@ inline constexpr EnumTable RungGlyphTable { { .gaugeHeld = "=", .gaugeEmpty = ".", .gaugeClose = "]", - .delta = "d" }, + .delta = "d", + .upDown = "Up/Dn" }, } }; static_assert(RowsInEnumeratorOrder(RungGlyphTable, &RungGlyphs::rung), diff --git a/src/apps/pacectl/DashboardLoop.cpp b/src/apps/pacectl/DashboardLoop.cpp index 74e2f6505..44bf8dc2c 100644 --- a/src/apps/pacectl/DashboardLoop.cpp +++ b/src/apps/pacectl/DashboardLoop.cpp @@ -18,14 +18,16 @@ namespace /// `Ctrl+C`: the quit key that stays one while a view `CapturesText`, so a session is always leavable. constexpr std::string_view InterruptKey = "\x03"; - /// The keystrokes that mean *leave*. + /// The keystrokes that mean *leave*, unless the view takes them first. /// /// A table rather than an `if` ladder, so a fourth spelling is a row. `ESC` is here /// because an operator reaches for it and a dashboard that ignores it feels stuck; /// it is safe only because this view sends no query whose reply begins with `ESC` /// -- the DA1 exchange is over before the loop starts, which is stage 2's one-shot - /// probe, not a thing that can arrive mid-run. - constexpr auto QuitKeys = std::to_array({ "q", "Q", InterruptKey, "\x1b" }); + /// probe, not a thing that can arrive mid-run. A key here still reaches the view, rather + /// than ending the run, while the view `CapturesText` (every key but `Ctrl+C`) and, for + /// `ESC` alone, while it `ClaimsEscape` -- a machine's detail is open, and `ESC` goes back. + constexpr auto QuitKeys = std::to_array({ "q", "Q", InterruptKey, EscapeKey }); /// Whether a reading continues the run the reading before it belongs to. /// @@ -294,7 +296,9 @@ core::async::Task RunDashboard( break; case DashboardEventKind::Key: - if (!IsQuitKey(event.keys) || (view->CapturesText() && event.keys != InterruptKey)) + // A quit key quits unless the view is typing it, or it is Esc and the view has something open to close. + if (!IsQuitKey(event.keys) || (view->CapturesText() && event.keys != InterruptKey) + || (event.keys == EscapeKey && view->ClaimsEscape())) { // Owed now, not at the next `Tick`: the view changed, and nothing was read. if (view->Key(event.keys)) diff --git a/src/apps/pacectl/DashboardLoop.hpp b/src/apps/pacectl/DashboardLoop.hpp index d8961d833..adb8c49c9 100644 --- a/src/apps/pacectl/DashboardLoop.hpp +++ b/src/apps/pacectl/DashboardLoop.hpp @@ -542,7 +542,7 @@ class IDashboardView } /// Act on a keystroke that is not a quit key -- or on any keystroke but `Ctrl+C` while the view - /// `CapturesText`. + /// `CapturesText`, and on `EscapeKey` while it `ClaimsEscape`. /// /// **What a view is showing is the view's state, not the model's**: the model is what was READ, /// and a key reads nothing. So a key the view acts on changes the view, and the loop draws the @@ -567,6 +567,14 @@ class IDashboardView { return false; } + + /// Whether Esc closes something this view has open, rather than quitting the session. + /// + /// **Pure, unlike `CapturesText`**: a view that opens something Esc should close and answered through a + /// default would quit the session on the key meant to go back, and nothing would say so. Asked only for + /// `EscapeKey`; every other quit key quits whatever the view has open. + /// @return True while Esc is the view's to act on. + [[nodiscard]] virtual bool ClaimsEscape() const noexcept = 0; }; /// What bounds the run. @@ -597,6 +605,9 @@ struct DashboardLimits [[nodiscard]] core::async::Task RunDashboard( IDashboardEventSource* events, SampleReader reader, IDashboardView* view, IFrameSink* sink, DashboardLimits limits); +/// `Esc`, as a keystroke delivers it: a quit key unless the view `ClaimsEscape`, and what closes what it has open. +inline constexpr std::string_view EscapeKey = "\x1b"; + /// Whether @p keys asks the dashboard to quit. /// /// Its own function so the loop states no key literal and a test can assert the set diff --git a/src/apps/pacectl/DashboardLoop_test.cpp b/src/apps/pacectl/DashboardLoop_test.cpp index 0a2bcc428..dcc406232 100644 --- a/src/apps/pacectl/DashboardLoop_test.cpp +++ b/src/apps/pacectl/DashboardLoop_test.cpp @@ -41,6 +41,12 @@ namespace class RecordingView final: public IDashboardView { public: + /// @return False: this view opens nothing Esc closes. + [[nodiscard]] bool ClaimsEscape() const noexcept override + { + return false; + } + [[nodiscard]] DashboardFrame PlacedFrame(DashboardModel const& model) override { ++calls; @@ -1186,6 +1192,12 @@ namespace class DocumentWatchingView final: public IDashboardView { public: + /// @return False: this view opens nothing Esc closes. + [[nodiscard]] bool ClaimsEscape() const noexcept override + { + return false; + } + [[nodiscard]] DashboardFrame PlacedFrame(DashboardModel const& model) override { seen.emplace_back(model.latestDocument); @@ -1233,6 +1245,12 @@ namespace class PlacingView final: public IDashboardView { public: + /// @return False: this view opens nothing Esc closes. + [[nodiscard]] bool ClaimsEscape() const noexcept override + { + return false; + } + [[nodiscard]] DashboardFrame PlacedFrame(DashboardModel const& model) override { cellPixelsSeen.push_back(model.cellPixels); @@ -1315,6 +1333,12 @@ namespace class OneKeyView final: public IDashboardView { public: + /// @return False: this view opens nothing Esc closes. + [[nodiscard]] bool ClaimsEscape() const noexcept override + { + return false; + } + [[nodiscard]] DashboardFrame PlacedFrame(DashboardModel const& model) override { (void) model; @@ -1374,6 +1398,12 @@ namespace class TypingView final: public IDashboardView { public: + /// @return False: this view opens nothing Esc closes. + [[nodiscard]] bool ClaimsEscape() const noexcept override + { + return false; + } + [[nodiscard]] DashboardFrame PlacedFrame(DashboardModel const& model) override { (void) model; @@ -1433,3 +1463,71 @@ TEST_CASE("a view typing text is offered the quit keys as text, and Ctrl+C still CHECK(doneExit.stop == DashboardStop::Quit); CHECK(done.offered == std::vector { "\r" }); } + +namespace +{ + +/// A view that claims Esc or does not, and writes down every key it is offered. +/// +/// Every member public, as this file's other recording fakes keep what a case reads. +class EscapeClaimingView final: public IDashboardView +{ + public: + /// @param claimsEscape What `ClaimsEscape` answers. + explicit EscapeClaimingView(bool claimsEscape): + claims { claimsEscape } + { + } + + [[nodiscard]] DashboardFrame PlacedFrame(DashboardModel const& model) override + { + (void) model; + return DashboardFrame { .text = std::format("offered {}", offered.size()), .placements = {} }; + } + + [[nodiscard]] bool Key(std::string_view keys) override + { + offered.emplace_back(keys); + return true; + } + + [[nodiscard]] bool ClaimsEscape() const noexcept override + { + return claims; + } + + bool claims; ///< What `ClaimsEscape` answers. + std::vector offered {}; ///< Every key offered, in order. +}; + +/// Drive @p view through Esc, then `x`, then `q`, then `y`. +/// @param view The view. +/// @return How the run ended. +[[nodiscard]] DashboardExit EscThenQuit(EscapeClaimingView& view) +{ + auto sink = CollectingSink {}; + return Drive({ DashboardEvent { .kind = DashboardEventKind::Key, .keys = std::string { EscapeKey } }, + DashboardEvent { .kind = DashboardEventKind::Key, .keys = "x" }, + DashboardEvent { .kind = DashboardEventKind::Key, .keys = "q" }, + DashboardEvent { .kind = DashboardEventKind::Key, .keys = "y" } }, + DashboardLimits {}, + view, + sink); +} + +} // namespace + +TEST_CASE("esc quits a view that claims nothing and is handed to a view that claims it", "[cli][dashboard][loop]") +{ + // WHAT DISTINGUISHES: who is offered Esc. A view that claims it is handed Esc and the run goes on to `x`, + // ending at `q`; a view that claims nothing is offered nothing, since Esc ends the run as it always did. + auto claiming = EscapeClaimingView { true }; + auto const claimed = EscThenQuit(claiming); + CHECK(claimed.stop == DashboardStop::Quit); + CHECK(claiming.offered == std::vector { std::string { EscapeKey }, "x" }); + + auto plain = EscapeClaimingView { false }; + auto const quit = EscThenQuit(plain); + CHECK(quit.stop == DashboardStop::Quit); + CHECK(plain.offered.empty()); +} diff --git a/src/apps/pacectl/DashboardPanel.cpp b/src/apps/pacectl/DashboardPanel.cpp index da83120d5..5f57f41ad 100644 --- a/src/apps/pacectl/DashboardPanel.cpp +++ b/src/apps/pacectl/DashboardPanel.cpp @@ -572,6 +572,7 @@ namespace std::size_t rowsKept { 0 }; ///< The rows a table keeps when it shrinks at `shrinkAt`. std::size_t imageFirst { 0 }; ///< For an image item, the first of its lines the image covers. std::size_t imageRows { 0 }; ///< For an image item, how many of its lines the image covers. + std::size_t heading { 1 }; ///< How many of a table's first lines stand above its rows. Priority priority { Priority::Normal }; ///< When it goes. std::optional shrinkAt {}; ///< The level a table gives up rows at, when that is not `priority`. bool table { false }; ///< Whether it shrinks to its overflow line before it goes. @@ -607,8 +608,8 @@ namespace { if (!item.table) return item.lines.size(); - auto const rows = item.lines.size() - 1; - return 1 + (rows - item.hidden) + (HasOverflowLine(item) ? 1 : 0); + auto const rows = item.lines.size() - item.heading; + return item.heading + (rows - item.hidden) + (HasOverflowLine(item) ? 1 : 0); } /// How many rows @p item hides after one more cut that leaves at least @p floor shown, if a cut can. @@ -621,7 +622,7 @@ namespace /// @return The rows hidden after the cut, or nullopt when no cut makes it shorter. [[nodiscard]] std::optional AfterCut(Item const& item, std::size_t floor) noexcept { - auto const shown = (item.lines.size() - 1) - item.hidden; + auto const shown = (item.lines.size() - item.heading) - item.hidden; if (!HasOverflowLine(item)) return shown >= floor + 2 ? std::optional { 2 } : std::nullopt; return shown > floor ? std::optional { item.hidden + 1 } : std::nullopt; @@ -722,7 +723,8 @@ namespace for (auto& item: items) { auto const first = fitted.lines.size(); - auto const kept = item.table ? 1 + ((item.lines.size() - 1) - item.hidden) : item.lines.size(); + auto const kept = + item.table ? item.heading + ((item.lines.size() - item.heading) - item.hidden) : item.lines.size(); for (auto const& span: item.spans) if (span.line < kept) fitted.spans.push_back( @@ -733,7 +735,7 @@ namespace fitted.imageRows = item.imageRows; } if (item.table && !item.noun.empty()) - fitted.tableShown = kept - 1; + fitted.tableShown = kept - item.heading; std::ranges::move(item.lines | std::views::take(kept), std::back_inserter(fitted.lines)); if (item.table && HasOverflowLine(item)) { @@ -2120,28 +2122,52 @@ namespace letters += (letters.empty() ? "" : " ") + std::string { hotkey }; } if (!letters.empty()) + { pieces.push_back(Piece { .text = std::format("{}keys {}", ColumnGap, letters), .priority = spec.keysHintPriority, .slot = HintSlot, .tone = FrameTone::Label }); + // The keys that select and open a machine, after the letters: the rightmost piece, so it yields first. + pieces.push_back(Piece { .text = std::format("{}{} select Enter detail", ColumnGap, in.glyphs->upDown), + .priority = spec.keysHintPriority, + .slot = HintSlot, + .tone = FrameTone::Label }); + } auto kept = FitPieces(std::move(pieces), budget, 0, in.cellWidth); if (!kept.has_value()) return {}; - // The hint right-aligned: the spare cells go in front of it once the pieces that fit are known. + // The hint right-aligned: the spare cells go in front of its first piece once the pieces that fit are known. auto const spare = budget - std::min(budget, TextWidth(*kept, in.cellWidth)); for (auto& piece: *kept) if (piece.slot == HintSlot) + { piece.text.insert(0, spare, ' '); + break; + } return { LineOf(*kept, {}, spec.stripPriority) }; } - /// What the operator has done to a document panel's table: how far it is scrolled and what filters it. + /// The machine a row must name to be kept, and the column that names it. + struct RowMachine + { + std::string_view machine; ///< The machine's key, as the document names it. + std::string_view column; ///< The column naming a row's machine. + }; + + /// What the operator has done to a document panel's table: how far it is scrolled, what filters it, + /// which row and machine the highlight names, and which machine's rows alone it draws. struct TableState { - std::string_view filter {}; ///< The text a row must contain; empty for none. - std::size_t scroll { 0 }; ///< The matching rows skipped at the top. - bool typing { false }; ///< Whether the filter is being typed. + std::string_view filter {}; ///< The text a row must contain; empty for none. + std::size_t scroll { 0 }; ///< The matching rows skipped at the top. + std::optional focusedRow {}; ///< The focused row, among the matching rows; none for none. + std::string_view machine {}; ///< The machine the highlight names; empty for none. + std::string_view machineColumn {}; ///< The column naming a row's machine; empty for none. + /// The machine every drawn row must name; none to draw every machine's. Not a highlight: the rows it keeps + /// are all that machine's, so none of them is dressed for it. + std::optional only {}; + bool typing { false }; ///< Whether the filter is being typed. }; /// A section's table as a frame draws it, with what the filter and the scroll made of it. @@ -2204,6 +2230,82 @@ namespace return FrameToneOf(tone); } + /// A section's rows the filter kept, each cell written for a person. + struct WrittenRows + { + std::vector matched {}; ///< Each kept row's index into the table's rows. + std::vector> cells {}; ///< Each kept row's cells, written. + }; + + /// Whether @p row of @p table names @p only's machine in @p only's column. + /// + /// An absent cell names no machine, and a table without the column names none in any row. + /// @param table The section's table. + /// @param row The row. + /// @param only The machine and its column. + /// @return True when the row is that machine's. + [[nodiscard]] bool RowNamesMachine(Value const& table, std::vector const& row, RowMachine const& only) + { + auto const at = ColumnNamed(table, only.column); + if (!at.has_value()) + return false; + auto const& cell = row[*at]; + return cell.kind != CellKind::Absent && cell.lexical == only.machine; + } + + /// The rows of @p table, in @p section, that @p filter keeps, every cell written for a person. + /// + /// **The one place a row is matched**, so the frame that draws the table and the highlight that walks + /// its rows count the same rows -- and a machine's detail keeps its rows here too. Each cell is written by + /// the scale its column has in the leader's own tables, so the filter matches what the operator reads. + /// @param in The frame's inputs. + /// @param section The section. + /// @param table The section's table. + /// @param filter The text a row must contain; empty for none. + /// @param only The machine a row must name, or nullopt to keep every machine's rows. + /// @return The kept rows. + [[nodiscard]] WrittenRows WriteMatchingRows(FrameInputs const& in, + FleetSection section, + Value const& table, + std::string_view filter, + std::optional const& only = std::nullopt) + { + auto const formats = ColumnFormats(section, table.columns); + auto rows = WrittenRows {}; + for (auto const rowIndex: std::views::iota(std::size_t { 0 }, table.rows.size())) + { + auto const& row = table.rows[rowIndex]; + if (only.has_value() && !RowNamesMachine(table, row, *only)) + continue; + auto written = std::vector {}; + written.reserve(row.size()); + for (auto const index: std::views::iota(std::size_t { 0 }, row.size())) + written.push_back(HumanCellText(in, row[index], formats[index])); + if (!std::ranges::any_of(written, + [filter](std::string const& cell) { return ContainsIgnoringAsciiCase(cell, filter); })) + continue; + rows.matched.push_back(rowIndex); + rows.cells.push_back(std::move(written)); + } + return rows; + } + + /// The indices of the rows of @p section's table that @p filter keeps, in drawing order. + /// @param in The frame's inputs. + /// @param section The section. + /// @param filter The text a row must contain; empty for none. + /// @return The indices; empty where no document, or no such section, was read. + [[nodiscard]] std::vector MatchedRowsOf(FrameInputs const& in, + FleetSection section, + std::string_view filter) + { + auto const* document = in.model->latestDocument.get(); + auto const* table = document == nullptr ? nullptr : document->Section(section); + if (table == nullptr || table->columns.empty()) + return {}; + return WriteMatchingRows(in, section, *table, filter).matched; + } + /// The active section's table, walked from the header line the leader sent. /// /// Every column's priority is its rank in the leader's tables (`FleetColumnKeep`), through @@ -2211,19 +2313,15 @@ namespace /// header with no `Identity` column -- a section vocabulary this build does not know -- keeps its first column instead, /// so a line still says which row it is. /// @param in The frame's inputs. - /// @param context The session facts: the section. + /// @param section The section the table draws. /// @param spec The document block. /// @param state The filter and the scroll. /// @param budget The content width. /// @return The table -- or the absent marker where no document, or no such section, was read -- /// or the cells its essential columns needed when those do not fit. - [[nodiscard]] std::expected SectionItems(FrameInputs const& in, - PanelContext const& context, - DocumentSpec const& spec, - TableState const& state, - std::size_t budget) + [[nodiscard]] std::expected SectionItems( + FrameInputs const& in, FleetSection section, DocumentSpec const& spec, TableState const& state, std::size_t budget) { - auto const section = context.section; auto const* document = in.model->latestDocument.get(); auto const* table = document == nullptr ? nullptr : document->Section(section); if (table == nullptr || table->columns.empty()) @@ -2236,25 +2334,17 @@ namespace return SectionTable { .items = { Item { .lines = { std::move(line) }, .priority = spec.tablePriority } } }; } - // Every cell written for a person FIRST, by the scale its column has in the leader's own tables, - // so the widths below are the widths drawn, the drop for width sees the real figures, and the - // filter matches what the operator reads. - auto const formats = ColumnFormats(section, table->columns); - auto matched = std::vector {}; - auto cells = std::vector> {}; - for (auto const rowIndex: std::views::iota(std::size_t { 0 }, table->rows.size())) - { - auto const& row = table->rows[rowIndex]; - auto written = std::vector {}; - written.reserve(row.size()); - for (auto const index: std::views::iota(std::size_t { 0 }, row.size())) - written.push_back(HumanCellText(in, row[index], formats[index])); - if (!std::ranges::any_of( - written, [&state](std::string const& cell) { return ContainsIgnoringAsciiCase(cell, state.filter); })) - continue; - matched.push_back(rowIndex); - cells.push_back(std::move(written)); - } + // Every cell written for a person FIRST, so the widths below are the widths drawn and the drop for + // width sees the real figures. + auto const written = WriteMatchingRows(in, section, *table, state.filter, state.only); + auto const& matched = written.matched; + // A table restricted to one machine that holds none of its rows is the marker, never a heading of columns + // over nothing -- and a heading too wide for the terminal is no reason to refuse a frame that draws none. + if (state.only.has_value() && matched.empty()) + return SectionTable { .items = { Item { .lines = { std::string { Indent } + std::string { in.absent } }, + .priority = spec.tablePriority } }, + .rows = table->rows.size() }; + auto const& cells = written.cells; auto const scroll = matched.empty() ? 0 : std::min(state.scroll, matched.size() - 1); // Each column as wide as its widest matching cell, heading included, so no cell is ever cut. @@ -2303,21 +2393,37 @@ namespace .priority = spec.tablePriority, .shrinkAt = spec.tableShrinkPriority, .table = true }; + // The highlight: the focused row, and every row of the machine it names, dressed at its first cell. + auto const machineAt = + state.machineColumn.empty() || state.machine.empty() + ? table->columns.size() + : static_cast(std::ranges::find(table->columns, state.machineColumn) - table->columns.begin()); + auto const isSelected = [&](std::size_t shown) { + if (state.focusedRow == shown) + return true; + if (machineAt >= table->columns.size()) + return false; + auto const& cell = table->rows[matched[shown]][machineAt]; + return cell.kind != CellKind::Absent && cell.lexical == state.machine; + }; for (auto const shown: std::views::iota(scroll, cells.size())) { auto line = std::string {}; + auto const selected = isSelected(shown); for (auto const& piece: *kept) { line += prefixOf(piece.slot); auto const aligned = alignedOf(piece.slot, cells[shown][piece.slot]); auto const& raw = table->rows[matched[shown]][piece.slot]; - if (auto const tone = CellFrameTone(section, table->columns[piece.slot], raw); tone.has_value()) - { - auto const text = cells[shown][piece.slot]; - auto const start = piece.slot == 0 ? 0 : aligned.size() - text.size(); + auto const& text = cells[shown][piece.slot]; + auto const start = piece.slot == 0 ? 0 : aligned.size() - text.size(); + // The selection outranks the cell's own tone, rather than two runs over the same bytes. + auto const tone = selected && &piece == &kept->front() + ? std::optional { FrameTone::Selected } + : CellFrameTone(section, table->columns[piece.slot], raw); + if (tone.has_value() && !text.empty()) item.spans.push_back(LineSpan { .line = item.lines.size(), .byte = line.size() + start, .length = text.size(), .tone = *tone }); - } line += aligned; } item.lines.push_back(std::move(line)); @@ -2392,14 +2498,23 @@ namespace } /// The machines the fleet chart could band: those with a reading of its figure in the span it counts them over -- - /// the image's fixed step, or as much history as text could draw. + /// the image's fixed step, or as much history as text could draw -- in key order. + /// @param in The frame's inputs. + /// @param context The session facts. + /// @return The bands. + [[nodiscard]] std::vector FleetChartLanes(FrameInputs const& in, PanelContext const& context) + { + auto const window = ChartIsPixels(in, context) ? ChartWindowFor(in.model->history.size()) : HistoryCapacity; + return FleetChartBands(in.model->history, FleetChartMetrics.front(), window); + } + + /// How many machines the fleet chart could band (`FleetChartLanes`). /// @param in The frame's inputs. /// @param context The session facts. /// @return How many machines. [[nodiscard]] std::size_t FleetChartMachines(FrameInputs const& in, PanelContext const& context) { - auto const window = ChartIsPixels(in, context) ? ChartWindowFor(in.model->history.size()) : HistoryCapacity; - return FleetChartBands(in.model->history, FleetChartMetrics.front(), window).size(); + return FleetChartLanes(in, context).size(); } /// A span of @p samples readings, written for the title and the axis. @@ -2475,6 +2590,8 @@ namespace std::size_t available { 0 }; ///< Every band the chart could draw; the shape draws the first of them. /// The first @p count bands over the newest @p window samples, their latest figures and tops written. std::function(std::size_t count, std::size_t window)> tracks {}; + /// The label of the band the highlight names, dressed as selected; empty for none. + std::string_view selected {}; }; /// @p source laid out in @p shape: a title, a row per band, a time axis and a legend, as one item that goes whole. @@ -2578,8 +2695,10 @@ namespace // Every band after the first starts after its spacer rows. if (item.lines.size() > item.imageFirst) item.lines.resize(item.lines.size() + block.shape.spacerCells); + auto const selected = !source.selected.empty() && track.label == source.selected; auto row = std::vector { - Piece { .text = std::string { Indent } + FitRight(track.label, nameCells, in.cellWidth) }, + Piece { .text = std::string { Indent } + FitRight(track.label, nameCells, in.cellWidth), + .tone = selected ? std::optional { FrameTone::Selected } : std::nullopt }, Piece { .text = std::string { ColumnGap } + AlignRight(track.latest, figureCells, in.cellWidth), .tone = FrameTone::Figure }, }; @@ -2751,9 +2870,14 @@ namespace /// @param context The session facts: the rung, the encoder and the interval. /// @param budget The content width. /// @param shape How many bands, and rows of cells each. + /// @param machine The machine the highlight names, whose band's label is dressed as selected; empty for none. /// @return The chart, or nullopt where it is not drawn: a rung with no chart marks, or too narrow. - [[nodiscard]] std::optional FleetChartOf( - FrameInputs const& in, DocumentSpec const& spec, PanelContext const& context, std::size_t budget, ChartShape shape) + [[nodiscard]] std::optional FleetChartOf(FrameInputs const& in, + DocumentSpec const& spec, + PanelContext const& context, + std::size_t budget, + ChartShape shape, + std::string_view machine) { auto const pixels = ChartIsPixels(in, context); if (!pixels && in.glyphs->chartLevels.size() < 2) @@ -2799,6 +2923,7 @@ namespace tracks[index].latest = ChartFigureText(in, *chartMetric, bands[index].latest); return tracks; }, + .selected = machine, }; return LayoutChart(in, source, context, budget, shape, Priority::Low, spec.chartGrowth.minimumCells, pixels); } @@ -2873,7 +2998,7 @@ namespace auto block = DocumentBlock {}; if (!spec.document.has_value()) return block; - auto table = SectionItems(in, context, *spec.document, state, budget); + auto table = SectionItems(in, context.section, *spec.document, state, budget); if (!table.has_value()) return std::unexpected(table.error()); block.tiles = TileItems(in, *spec.document, budget); @@ -2885,6 +3010,136 @@ namespace return block; } + /// The machines section's column that names a machine for a person. + constexpr std::string_view MachineNameColumn = "name"; + + /// What a machine's detail heading says when the latest document lists no such machine. + constexpr std::string_view NotInSampleNote = "not in the latest sample"; + + /// What a machine's detail tells the operator to press to go back to the fleet. + constexpr std::string_view DetailBackHint = "Esc back"; + + /// The heading of @p machine's detail: `machine `, then its name from the latest machines section -- + /// or, where that section was read and lists no such machine, `not in the latest sample`, and where no machines + /// section was read at all, the absent marker. + /// + /// **A machine that left says so**: a detail whose every section is empty would otherwise read as a machine + /// that is there and doing nothing. **And a sample that failed does not**: a refused or dropped sample read no + /// machines section, which says nothing about this machine, so saying it left would be a confident wrong signal. + /// @param in The frame's inputs. + /// @param spec The document block. + /// @param machine The machine's key. + /// @param budget The content width. + /// @return The heading, or the cells it needed when it does not fit. + [[nodiscard]] std::expected DetailHeading(FrameInputs const& in, + DocumentSpec const& spec, + std::string_view machine, + std::size_t budget) + { + auto const* document = in.model->latestDocument.get(); + auto const* machines = document == nullptr ? nullptr : document->Section(FleetSection::Machines); + auto const column = MachineColumnOf(FleetSection::Machines); + auto const* row = + machines == nullptr || !column.has_value() + ? nullptr + : core::findIfOrNull(machines->rows, [&](std::vector const& cells) { + return RowNamesMachine(*machines, cells, RowMachine { .machine = machine, .column = *column }); + }); + auto pieces = std::vector { + Piece { .text = std::format("{}machine", Indent), .priority = Priority::Essential, .tone = FrameTone::Label }, + Piece { .text = std::format(" {}", machine), .priority = Priority::Essential }, + }; + if (machines == nullptr) + pieces.push_back(Piece { .text = std::format("{}{}", ColumnGap, in.absent), .priority = Priority::Essential }); + else if (row == nullptr) + pieces.push_back(Piece { .text = std::format("{}{}", ColumnGap, NotInSampleNote), + .priority = Priority::Essential, + .tone = FrameTone::Label }); + else if (auto const nameAt = ColumnNamed(*machines, MachineNameColumn); + nameAt.has_value() && (*row)[*nameAt].kind != CellKind::Absent) + pieces.push_back( + Piece { .text = std::format("{}{}", ColumnGap, (*row)[*nameAt].lexical), .priority = Priority::High }); + auto kept = FitPieces(std::move(pieces), budget, 0, in.cellWidth); + if (!kept.has_value()) + return std::unexpected(kept.error()); + return LineOf(*kept, {}, spec.tablePriority); + } + + /// When a machine's detail gives up a section for height: after its blanks, before its heading and its back key. + /// + /// Below `Essential`, unlike the fleet view's one table: six essential sections need about twenty lines, and a + /// terminal shorter than that would get the minimum-size line where the fleet view fits. + constexpr auto DetailSectionPriority = Priority::Normal; + + /// One labelled line: @p text, indented and dressed as a label. + /// @param text The words. + /// @param priority When the line goes. + /// @return The item. + [[nodiscard]] Item LabelLine(std::string_view text, Priority priority) + { + auto const piece = + Piece { .text = std::format("{}{}", Indent, text), .priority = priority, .tone = FrameTone::Label }; + return LineOf(std::span { &piece, 1 }, {}, priority); + } + + /// @p machine's detail, drawn in place of the fleet block: its heading, then every tabular section whose rows + /// name a machine, in `FleetSectionTable` order, under its name and restricted to that machine's rows, then the + /// key that goes back. + /// + /// A section with none of its rows writes the absent marker under its name, never an empty table, which would + /// claim it was read and holds nothing for the machine where the section may not have been sent at all. + /// + /// Each section is ONE item, its name a heading line above its columns, so a short terminal shrinks the tables + /// to `tableRowsKept` rows and then drops whole sections from the bottom -- never a name without its table, nor + /// a table without its name. + /// @param in The frame's inputs. + /// @param spec The document block. + /// @param machine The machine's key, as the document names it. + /// @param budget The content width. + /// @return The items, or the cells a heading or an essential column needed when it does not fit. + [[nodiscard]] std::expected, std::size_t> DetailItems(FrameInputs const& in, + DocumentSpec const& spec, + std::string_view machine, + std::size_t budget) + { + auto heading = DetailHeading(in, spec, machine, budget); + if (!heading.has_value()) + return std::unexpected(heading.error()); + auto items = std::vector { std::move(*heading) }; + for (auto const& row: Distributed::FleetSectionTable) + { + auto const column = MachineColumnOf(row.section); + if (!row.tabular || !column.has_value()) + continue; + auto table = SectionItems( + in, row.section, spec, TableState { .only = RowMachine { .machine = machine, .column = *column } }, budget); + if (!table.has_value()) + return std::unexpected(table.error()); + items.push_back(Blank()); + for (auto& part: table->items) + { + // The section's name above what it drew: the marker, or the columns and their rows. + auto section = LabelLine(row.key, DetailSectionPriority); + for (auto span: part.spans) + { + span.line += 1; + section.spans.push_back(span); + } + std::ranges::move(part.lines, std::back_inserter(section.lines)); + section.table = true; + section.heading = part.table ? 2 : section.lines.size(); + section.above = part.above; + section.rowsKept = part.rowsKept; + section.shrinkAt = part.table ? part.shrinkAt : std::nullopt; + // No overflow line names a key to scroll by, so no noun: a detail's tables do not scroll. + items.push_back(std::move(section)); + } + } + items.push_back(Blank()); + items.push_back(LabelLine(DetailBackHint, Priority::High)); + return items; + } + /// How much of where a source line says the source answered. enum class SourceWhere : std::uint8_t { @@ -3293,7 +3548,7 @@ namespace /// Every editing key; any other printable text is typed into the filter. constexpr auto FilterEditKeys = std::to_array({ { .keys = "\r", .edit = FilterEdit::Keep }, - { .keys = "\x1b", .edit = FilterEdit::Clear }, + { .keys = EscapeKey, .edit = FilterEdit::Clear }, { .keys = "\x7f", .edit = FilterEdit::Backspace }, { .keys = "\b", .edit = FilterEdit::Backspace }, }); @@ -3318,8 +3573,147 @@ namespace if (!text.empty()) text.pop_back(); } + + /// A keystroke that moves the fleet view's highlight, as the bytes `KeyBytes` delivers it. + struct FocusStepKey + { + std::string_view keys; ///< The keystroke's bytes. + FocusStep step; ///< Which way it moves. + }; + + /// Every key that moves the highlight. + constexpr auto FocusStepKeys = std::to_array({ + { .keys = "\x1b[A", .step = FocusStep::Up }, + { .keys = "\x1b[B", .step = FocusStep::Down }, + }); + + /// What a key does to a machine's detail. + /// + /// TRANSMITTED/PERSISTED: no. Private; enumerators may be inserted. + enum class DetailAction : std::uint8_t + { + Open, ///< Open the detail of the machine the highlight names. + Close, ///< Close the detail, back to the fleet view. + }; + + /// A keystroke that opens or closes a machine's detail, as the bytes `KeyBytes` delivers it. + struct DetailKey + { + std::string_view keys; ///< The keystroke's bytes. + DetailAction action; ///< What it does. + }; + + /// Every key that opens or closes a machine's detail. + constexpr auto DetailKeys = std::to_array({ + { .keys = "\r", .action = DetailAction::Open }, + { .keys = EscapeKey, .action = DetailAction::Close }, + }); + + /// The scroll that shows @p focus's row, from @p scroll with @p page rows shown. + /// + /// Moves only as far as it must: a row above the top becomes the top, one below the bottom the bottom. + /// @param focus The focus; a lane focus, or none, leaves the scroll alone. + /// @param scroll The matching rows skipped at the top now. + /// @param page How many rows the last frame showed; at least one. + /// @return The scroll. + [[nodiscard]] std::size_t ScrollShowing(FleetFocus const& focus, std::size_t scroll, std::size_t page) noexcept + { + if (!focus.active || focus.zone != FocusZone::Rows) + return scroll; + if (focus.index < scroll) + return focus.index; + if (focus.index >= scroll + page) + return focus.index + 1 - page; + return scroll; + } + + /// @p focus as a new table starts it: a row focus back on the first row, a lane focus kept. + /// + /// A section switch or a filter edit makes a different list of rows, so the index a row focus held + /// names some other row in it. + /// @param focus The focus. + /// @return The focus. + [[nodiscard]] FleetFocus RowFocusRestarted(FleetFocus focus) noexcept + { + if (focus.zone == FocusZone::Rows) + focus.index = 0; + return focus; + } + /// What the fleet view's highlight walks, as the last frame left it. + struct FocusWalk + { + FleetFocus focus {}; ///< Where the highlight is. + std::size_t scroll { 0 }; ///< The table's rows skipped at the top. + std::size_t page { 1 }; ///< How many of the table's rows the last frame showed. + std::size_t lanes { 0 }; ///< How many chart lanes the last frame drew. + std::size_t laneWindow { 0 }; ///< The samples the last frame's chart took its bands over. + }; + + /// The highlight as one frame draws it: clamped to what the frame has, and the machine it names. + struct FramedFocus + { + FleetFocus focus {}; ///< The focus, clamped to the lanes and rows this frame has. + std::size_t scroll { 0 }; ///< The scroll, moved to show a row focus the clamp moved. + std::size_t rows { 0 }; ///< How many table rows match the filter in this frame. + std::string machine {}; ///< The machine the focus names; empty for none. + }; + + /// @p walk's focus clamped to what this frame has -- rows a sample or a filter took away, lanes gone -- so the + /// machine it names, and every row and band dressed for it, is one this frame draws. + /// + /// The keys walked the lanes the last frame drew, so they are read over the span it drew them over: a machine + /// that fell silent before that span is in the whole history and in no band. A band the chart had no room for + /// is no lane either. + /// @param in The frame's inputs. + /// @param section The section the table draws. + /// @param filter The text a row must contain; empty for none. + /// @param walk The highlight as the last frame left it. + /// @return The highlight for this frame. + [[nodiscard]] FramedFocus FocusForFrame(FrameInputs const& in, + FleetSection section, + std::string_view filter, + FocusWalk const& walk) + { + auto lanes = walk.laneWindow > 0 ? FleetChartBands(in.model->history, FleetChartMetrics.front(), walk.laneWindow) + : std::vector {}; + lanes.resize(std::min(lanes.size(), walk.lanes)); + auto const matched = MatchedRowsOf(in, section, filter); + auto framed = FramedFocus { .focus = ClampFocus(walk.focus, lanes.size(), matched.size()), + .scroll = walk.scroll, + .rows = matched.size(), + .machine = {} }; + if (framed.focus != walk.focus) + framed.scroll = ScrollShowing(framed.focus, walk.scroll, walk.page); + framed.machine = + FocusedMachine(framed.focus, lanes, in.model->latestDocument.get(), section, matched).value_or(std::string {}); + return framed; + } } // namespace +std::optional FocusedMachine(FleetFocus const& focus, + std::span lanes, + FleetDocument const* document, + FleetSection section, + std::span matchedRows) +{ + if (!focus.active) + return std::nullopt; + if (focus.zone == FocusZone::Lanes) + return focus.index < lanes.size() ? std::optional { lanes[focus.index].subject } : std::nullopt; + auto const column = MachineColumnOf(section); + auto const* table = document == nullptr ? nullptr : document->Section(section); + if (!column.has_value() || table == nullptr || focus.index >= matchedRows.size()) + return std::nullopt; + auto const at = std::ranges::find(table->columns, *column); + auto const row = matchedRows[focus.index]; + if (at == table->columns.end() || row >= table->rows.size()) + return std::nullopt; + auto const& cell = table->rows[row][static_cast(at - table->columns.begin())]; + if (cell.kind == CellKind::Absent) + return std::nullopt; + return cell.lexical; +} + std::optional SectionForKey(FleetSection active, std::string_view keys) { auto tabs = std::vector {}; @@ -3378,12 +3772,14 @@ bool PanelView::Key(std::string_view keys) _typingFilter = false; _filter.clear(); _scroll = 0; + _focus = RowFocusRestarted(_focus); return true; case FilterEdit::Backspace: if (_filter.empty()) return false; PopCharacter(_filter); _scroll = 0; + _focus = RowFocusRestarted(_focus); return true; } } @@ -3391,6 +3787,26 @@ bool PanelView::Key(std::string_view keys) return false; _filter += keys; _scroll = 0; + _focus = RowFocusRestarted(_focus); + return true; + } + + // While a detail is open it is the whole view: Esc closes it, and no other key acts on what it hides. + auto const* detail = core::findIfOrNull(DetailKeys, [keys](DetailKey const& row) { return row.keys == keys; }); + if (!_detailMachine.empty()) + { + if (detail == nullptr || detail->action != DetailAction::Close) + return false; + _detailMachine.clear(); + return true; + } + if (detail != nullptr && detail->action == DetailAction::Open) + { + // The machine the last frame's highlight named: none, or a row of a section that names no machine, opens + // nothing. + if (_focusedMachine.empty()) + return false; + _detailMachine = _focusedMachine; return true; } @@ -3419,6 +3835,16 @@ bool PanelView::Key(std::string_view keys) return _scroll != before; } + if (auto const* focusKey = + core::findIfOrNull(FocusStepKeys, [keys](FocusStepKey const& row) { return row.keys == keys; })) + { + // Over what the last frame drew: the lanes and the rows the operator is looking at. + auto const before = _focus; + _focus = StepFocus(_focus, focusKey->step, _lanes, _rows); + _scroll = ScrollShowing(_focus, _scroll, _page); + return _focus != before; + } + auto const section = SectionForKey(_context.section, keys); if (!section.has_value() || *section == _context.section) return false; @@ -3426,6 +3852,7 @@ bool PanelView::Key(std::string_view keys) _context.section = *section; _scroll = 0; _filter.clear(); + _focus = RowFocusRestarted(_focus); return true; } @@ -3434,6 +3861,11 @@ bool PanelView::CapturesText() const noexcept return _typingFilter; } +bool PanelView::ClaimsEscape() const noexcept +{ + return !_detailMachine.empty(); +} + DashboardFrame PanelView::PlacedFrame(DashboardModel const& model) { auto const in = @@ -3467,8 +3899,39 @@ DashboardFrame PanelView::PlacedFrame(DashboardModel const& model) auto rates = RateItems(in, _spec->rates, budget, _besideReserve); auto levels = LevelItems(in, _spec->levels, budget); auto tiers = TierItems(in, *_spec, budget); - auto document = DocumentItems( - in, *_spec, _context, TableState { .filter = _filter, .scroll = _scroll, .typing = _typingFilter }, budget); + // The highlight first, clamped to what this frame has (`FocusForFrame`). A machine's detail draws in place of + // the fleet block and leaves the highlight as it was, so closing it comes back to the lanes and rows the + // operator left: nothing below reads or moves the focus while it is open. A panel without a document block is + // never focused, so its focus, rows and machine stay as constructed. + auto const detail = _spec->document.has_value() && !_detailMachine.empty(); + if (_spec->document.has_value() && !detail) + { + auto framed = FocusForFrame( + in, + _context.section, + _filter, + FocusWalk { .focus = _focus, .scroll = _scroll, .page = _page, .lanes = _lanes, .laneWindow = _laneWindow }); + _focus = framed.focus; + _scroll = framed.scroll; + _rows = framed.rows; + _focusedMachine = std::move(framed.machine); + } + auto const focusedRow = + _focus.active && _focus.zone == FocusZone::Rows ? std::optional { _focus.index } : std::nullopt; + auto document = + detail ? DetailItems(in, *_spec->document, _detailMachine, budget).transform([this](std::vector items) { + return DocumentBlock { .tiles = {}, .body = std::move(items), .scroll = _scroll }; + }) + : DocumentItems(in, + *_spec, + _context, + TableState { .filter = _filter, + .scroll = _scroll, + .focusedRow = focusedRow, + .machine = _focusedMachine, + .machineColumn = MachineColumnOf(_context.section).value_or(""), + .typing = _typingFilter }, + budget); auto below = FactGroups(in, *_spec, FactPlace::BelowRates, budget); for (auto const* block: { &rates, &levels, &tiers }) if (!block->has_value()) @@ -3520,7 +3983,8 @@ DashboardFrame PanelView::PlacedFrame(DashboardModel const& model) if (!fitted.has_value()) return tooSmall(0); auto chart = std::optional {}; - if (available.has_value() && _spec->document.has_value()) + // A detail draws no chart: the history is the fleet's, and the detail is one machine's rows. + if (available.has_value() && _spec->document.has_value() && !detail) { // Before any machine reported the figure, one band's worth of rows holds the line saying so. chart = GrowChart( @@ -3528,12 +3992,12 @@ DashboardFrame PanelView::PlacedFrame(DashboardModel const& model) _spec->document->chartGrowth, std::max(1, FleetChartMachines(in, _context)), !ChartIsPixels(in, _context), - [&](ChartShape shape) { return FleetChartOf(in, *_spec->document, _context, budget, shape); }, + [&](ChartShape shape) { return FleetChartOf(in, *_spec->document, _context, budget, shape, _focusedMachine); }, compose, items, *fitted); } - else if (available.has_value() && !_spec->charts.empty()) + else if (available.has_value() && !_spec->document.has_value() && !_spec->charts.empty()) chart = GrowChart( *available, _spec->chartGrowth, @@ -3551,8 +4015,15 @@ DashboardFrame PanelView::PlacedFrame(DashboardModel const& model) : fitted->lines.size(); fitted->lines.insert(fitted->lines.begin() + static_cast(at), pad, std::string {}); } - if (fitted->tableShown.has_value()) - _page = std::max(1, *fitted->tableShown); + // The page and the lanes the fleet view's keys walk, kept as the fleet view last drew them while a detail is open. + if (!detail) + { + if (fitted->tableShown.has_value()) + _page = std::max(1, *fitted->tableShown); + // The lanes the next Up or Down walks: the bands this frame drew, none where it drew no chart. + _lanes = chart.has_value() ? chart->tracks.size() : 0; + _laneWindow = chart.has_value() ? chart->window : 0; + } auto const title = TitleFor(in, *_spec, _context, columns); auto frame = diff --git a/src/apps/pacectl/DashboardPanel.hpp b/src/apps/pacectl/DashboardPanel.hpp index 61d11d533..cd8f50256 100644 --- a/src/apps/pacectl/DashboardPanel.hpp +++ b/src/apps/pacectl/DashboardPanel.hpp @@ -5,6 +5,8 @@ #include "DashboardGlyphs.hpp" #include "DashboardLoop.hpp" #include "DashboardRung.hpp" +#include "FleetChartModel.hpp" +#include "FleetFocus.hpp" #include "HistoryChart.hpp" #include "SixelEncoder.hpp" #include "StatsSource.hpp" @@ -625,6 +627,24 @@ struct PanelContext [[nodiscard]] std::optional SectionForKey(Distributed::FleetSection active, std::string_view keys); +/// The machine @p focus names: the lane's subject, or the focused matched row's cell in +/// @p section's machine column; nullopt when it names none. +/// +/// None for an inactive focus, a focus past the lanes or rows it is given, a section whose rows are not +/// per machine (`MachineColumnOf`), a document without that section or column, and an absent cell -- +/// a row nobody named a machine for names none, rather than one called `-`. +/// @param focus The focus. +/// @param lanes The lanes drawn, top to bottom. +/// @param document The document the table is drawn from, or null before one was read. +/// @param section The section the table draws. +/// @param matchedRows The indices, into @p section's rows, of the rows the filter kept, in drawing order. +/// @return The machine's key, as the document names it. +[[nodiscard]] std::optional FocusedMachine(FleetFocus const& focus, + std::span lanes, + FleetDocument const* document, + Distributed::FleetSection section, + std::span matchedRows); + /// Draws any `PanelSpec` on the rung it was given, laid out for the model's current size. /// /// **Responsive to `columns x rows`** (#134 decision 5), which arrive as `Resize` events: no line is @@ -648,7 +668,12 @@ class PanelView final: public IDashboardView /// filter. `PgDn` and `PgUp` scroll it by the rows the last frame showed, `Home` goes back to the top. /// `/` starts typing a filter: while typed, printable text is appended, `Backspace` takes the last /// character, `Enter` keeps the filter and `Esc` clears it. A row is drawn when any of its written - /// cells contains the filter, ignoring ASCII case. A panel without a document block acts on no key. + /// cells contains the filter, ignoring ASCII case. `Up` and `Down` move the highlight through the + /// chart's lanes and then the table's rows (`StepFocus`), over the counts the last frame drew, and + /// scroll the table to keep a focused row shown; a section switch or a filter edit puts a row focus + /// back on the first row. `Enter` on a highlight that names a machine opens that machine's detail, drawn + /// in place of the fleet block, and `Esc` closes it; while it is open no other key acts. A panel without a + /// document block acts on no key. /// @param keys The bytes the keystroke delivered. /// @return Whether the next frame differs. [[nodiscard]] bool Key(std::string_view keys) override; @@ -656,6 +681,9 @@ class PanelView final: public IDashboardView /// @return True while a filter is being typed. [[nodiscard]] bool CapturesText() const noexcept override; + /// @return True while a machine's detail is open, which Esc closes. + [[nodiscard]] bool ClaimsEscape() const noexcept override; + private: PanelSpec const* _spec; RungGlyphs const* _glyphs; @@ -680,6 +708,24 @@ class PanelView final: public IDashboardView /// The content width `_besideReserve` was measured at. std::size_t _reserveColumns { 0 }; + /// How many chart lanes the last frame drew: what `Up` and `Down` walk before the rows. + std::size_t _lanes { 0 }; + + /// The samples the last frame's chart took its bands over: the span the lanes are read in. + std::size_t _laneWindow { 0 }; + + /// How many table rows matched the filter in the last frame: what `Up` and `Down` walk after the lanes. + std::size_t _rows { 0 }; + + /// Where the highlight is; inactive until the first `Up` or `Down`. + FleetFocus _focus {}; + + /// The machine the highlight names in the last frame drawn; empty for none. + std::string _focusedMachine {}; + + /// The machine whose detail is drawn instead of the fleet block; empty for none. + std::string _detailMachine {}; + /// Whether the filter is being typed; last, so it pads nothing between 8-aligned members. bool _typingFilter { false }; }; diff --git a/src/apps/pacectl/DashboardPanel_test.cpp b/src/apps/pacectl/DashboardPanel_test.cpp index 8a91dc176..dc2e5f248 100644 --- a/src/apps/pacectl/DashboardPanel_test.cpp +++ b/src/apps/pacectl/DashboardPanel_test.cpp @@ -1344,13 +1344,12 @@ constexpr auto FleetMachines = std::size_t { 12 }; return std::nullopt; } -/// A leader's `/fleet.txt` for @p machines machines, rendered by the leader's own renderer. +/// A leader's snapshot of @p machines machines, as `LeaderFleetText` renders it. /// -/// Rendered rather than written, so every column a real leader sends is there under its real name, with -/// widths a real fleet has: a build string, a heartbeat past its threshold on the second machine. +/// A build string, a heartbeat past its threshold on the second machine, and no cluster. /// @param machines How many machines. -/// @return The document. -[[nodiscard]] std::string LeaderFleetText(std::size_t machines) +/// @return The snapshot. +[[nodiscard]] Distributed::FleetSnapshot LeaderSnapshot(std::size_t machines) { auto snapshot = Distributed::FleetSnapshot {}; snapshot.role = Distributed::SchedulerRole::Leader; @@ -1370,7 +1369,18 @@ constexpr auto FleetMachines = std::size_t { 12 }; snapshot.nodes.push_back(std::move(machine)); } snapshot.liveLeases = 47; - return RenderFleetText(snapshot, Distributed::FleetHistoryView {}, std::nullopt); + return snapshot; +} + +/// A leader's `/fleet.txt` for @p machines machines, rendered by the leader's own renderer. +/// +/// Rendered rather than written, so every column a real leader sends is there under its real name, with +/// widths a real fleet has: a build string, a heartbeat past its threshold on the second machine. +/// @param machines How many machines. +/// @return The document. +[[nodiscard]] std::string LeaderFleetText(std::size_t machines) +{ + return RenderFleetText(LeaderSnapshot(machines), Distributed::FleetHistoryView {}, std::nullopt); } /// Whether @p heading names the column @p name as a whole word. @@ -1855,6 +1865,12 @@ std::size_t fleetParses = 0; class EveryFleetDrawer final: public IDashboardView { public: + /// @return False: this view opens nothing Esc closes. + [[nodiscard]] bool ClaimsEscape() const noexcept override + { + return false; + } + [[nodiscard]] DashboardFrame PlacedFrame(DashboardModel const& model) override { auto frame = panel.PlacedFrame(model); @@ -2317,6 +2333,468 @@ TEST_CASE("the active section's tab is dressed as selected, and a letter from th namespace { +/// The Up key, as the bytes `KeyBytes` delivers it. +constexpr std::string_view UpKey = "\x1b[A"; + +/// The Down key, as the bytes `KeyBytes` delivers it. +constexpr std::string_view DownKey = "\x1b[B"; + +/// One frame's Selected spans, split by where they sit against the section strip. +struct SelectedRuns +{ + std::vector lanes {}; ///< Above the strip: the chart's band labels. + std::vector strip {}; ///< On the strip: the active tab. + std::vector rows {}; ///< Below the strip: the table's cells. +}; + +/// The Selected spans of @p run's frame @p index, split by the strip's row. +/// +/// The strip is the row of the Selected span that is a bracketed tab; every frame of a fleet panel with a +/// document draws one, so a frame without it fails here rather than reading as nothing selected. +/// @param run What was drawn. +/// @param index Which frame. +/// @return The spans' texts, top to bottom within each part. +[[nodiscard]] SelectedRuns SelectedIn(FleetRun const& run, std::size_t index) +{ + auto selected = std::vector> {}; + for (auto const& span: run.spans.at(index)) + if (span.tone == FrameTone::Selected) + selected.emplace_back(span.row, SpanText(run.frames.at(index), span)); + auto const* tab = core::findIfOrNull(selected, [](std::pair const& entry) { + return entry.second.starts_with("[") && entry.second.ends_with("]"); + }); + REQUIRE(tab != nullptr); + auto const stripRow = tab->first; + auto runs = SelectedRuns {}; + for (auto const& [row, text]: selected) + { + if (row < stripRow) + runs.lanes.push_back(text); + else if (row == stripRow) + runs.strip.push_back(text); + else + runs.rows.push_back(text); + } + return runs; +} + +/// A fleet of @p machines machines whose cluster has revoked one key, under the id `old-07`. +/// @param machines How many machines. +/// @return The document, rendered by the leader's own renderer. +[[nodiscard]] std::string LeaderFleetTextRevokingOne(std::size_t machines) +{ + auto snapshot = LeaderSnapshot(machines); + snapshot.cluster = Cluster::ClusterState {}; + snapshot.cluster->revokedKeys.push_back(Cluster::RevokedKey { .id = "old-07", .publicKey = {} }); + return RenderFleetText(snapshot, Distributed::FleetHistoryView {}, std::nullopt); +} + +/// A three-machine fleet's first frame, then @p downs Down keys. +/// @param document The leader's document. +/// @param downs How many Downs. +/// @return The script. +[[nodiscard]] std::vector DownsAfterFirstFrame(std::string document, int downs) +{ + auto script = std::vector { FleetSampleOf(1, std::move(document)), Tick }; + for ([[maybe_unused]] auto const step: std::views::iota(0, downs)) + script.push_back(KeyOf(DownKey)); + return script; +} + +} // namespace + +TEST_CASE("down highlights the first lane and marks that machine's rows below", "[cli][dashboard][panel][fleet][focus]") +{ + // WHAT DISTINGUISHES: before a key nothing is dressed but the tab; after Down the FIRST band by key + // order is, and the table row of that same machine with it -- and no other machine anywhere. + auto const run = RunFleet(DownsAfterFirstFrame(LeaderFleetText(3), 1), 132, 40, UnicodeContext()); + REQUIRE(run.frames.size() == 2); + INFO(run.frames[1]); + + auto const before = SelectedIn(run, 0); + CHECK(before.lanes.empty()); + CHECK(before.rows.empty()); + + auto const after = SelectedIn(run, 1); + CHECK(after.lanes == std::vector { "build-01:7070" }); + CHECK(after.strip == std::vector { "[machines]" }); + CHECK(after.rows == std::vector { "build-01:7070" }); +} + +TEST_CASE("down past the last lane focuses the first row and the lanes follow the row's machine", + "[cli][dashboard][panel][fleet][focus]") +{ + // Three lanes, so the fourth Down is the first row. WHAT DISTINGUISHES: the lane dressed is the one the + // ROW names, which on the last lane was the third. + auto const run = RunFleet(DownsAfterFirstFrame(LeaderFleetText(3), 4), 132, 40, UnicodeContext()); + REQUIRE(run.frames.size() == 5); + INFO(run.frames.back()); + + auto const onLastLane = SelectedIn(run, 3); + CHECK(onLastLane.lanes == std::vector { "build-03:7070" }); + CHECK(onLastLane.rows == std::vector { "build-03:7070" }); + + auto const onFirstRow = SelectedIn(run, 4); + CHECK(onFirstRow.rows == std::vector { "build-01:7070" }); + CHECK(onFirstRow.lanes == std::vector { "build-01:7070" }); + + // And Up from the first row is the last lane again. + auto script = DownsAfterFirstFrame(LeaderFleetText(3), 4); + script.push_back(KeyOf(UpKey)); + auto const back = RunFleet(std::move(script), 132, 40, UnicodeContext()); + REQUIRE(back.frames.size() == 6); + CHECK(SelectedIn(back, 5).lanes == std::vector { "build-03:7070" }); +} + +TEST_CASE("the section strip keeps exactly one selected tab while a machine is focused", + "[cli][dashboard][panel][fleet][focus]") +{ + // A focused machine adds Selected spans to the lanes and the rows; the strip's one tab must stay the only + // one ON THE STRIP, and follow the section. + auto script = DownsAfterFirstFrame(LeaderFleetText(3), 1); + script.push_back(KeyOf("w")); + auto const run = RunFleet(std::move(script), 132, 40, UnicodeContext()); + REQUIRE(run.frames.size() == 3); + INFO(run.frames[2]); + auto const selected = SelectedIn(run, 2); + CHECK(selected.strip == std::vector { "[workers]" }); + // The lane focus survives the switch. + CHECK(selected.lanes == std::vector { "build-01:7070" }); +} + +TEST_CASE("a filter that drops the focused row clamps the focus", "[cli][dashboard][panel][fleet][focus]") +{ + // Focus the third row (three lanes, then three rows), then filter to one machine. WHAT DISTINGUISHES: the + // frame still draws, and the one row left is the focused one. + auto script = DownsAfterFirstFrame(LeaderFleetText(3), 6); + script.push_back(KeyOf("/")); + script.push_back(KeyOf("build-01")); + script.push_back(KeyOf("\r")); + auto const run = RunFleet(std::move(script), 132, 40, UnicodeContext()); + REQUIRE(run.frames.size() == 10); + CHECK(SelectedIn(run, 6).rows == std::vector { "build-03:7070" }); + + auto const& last = run.frames.back(); + INFO(last); + CHECK(last.contains("filter /build-01")); + auto const selected = SelectedIn(run, run.frames.size() - 1); + CHECK(selected.rows == std::vector { "build-01:7070" }); + CHECK(selected.lanes == std::vector { "build-01:7070" }); +} + +TEST_CASE("rows that vanish between samples clamp the focus", "[cli][dashboard][panel][fleet][focus]") +{ + // The last of three rows focused, then a sample with one machine. WHAT DISTINGUISHES: the focus lands on + // the one row left rather than on nothing, or on a row index the table no longer has. + auto script = DownsAfterFirstFrame(LeaderFleetText(3), 6); + script.push_back(FleetSampleOf(2, LeaderFleetText(1))); + script.push_back(Tick); + auto const run = RunFleet(std::move(script), 132, 40, UnicodeContext()); + REQUIRE(run.frames.size() == 8); + CHECK(SelectedIn(run, 6).rows == std::vector { "build-03:7070" }); + + INFO(run.frames.back()); + CHECK(SelectedIn(run, 7).rows == std::vector { "build-01:7070" }); +} + +TEST_CASE("the lanes the keys walk are the bands the chart drew over its own span", "[cli][dashboard][panel][fleet][focus]") +{ + // `build-01` reports only in the first sample, then falls silent for more samples than the text chart is + // wide -- a dead node, which is what an operator investigates -- while staying inside the whole history. The + // chart draws build-02..04. WHAT DISTINGUISHES: the lanes are taken over the span the chart DREW; over the + // whole history build-01 would be lane 0, the first Down would dress no band, and build-04 would be out of + // reach. + constexpr auto SilentSamples = 150; + static_assert(SilentSamples < static_cast(HistoryCapacity), "build-01 must stay inside the history"); + auto withoutFirst = LeaderSnapshot(4); + withoutFirst.nodes.erase(withoutFirst.nodes.begin()); + auto const later = RenderFleetText(withoutFirst, Distributed::FleetHistoryView {}, std::nullopt); + auto script = std::vector { FleetSampleOf(1, LeaderFleetText(4)) }; + for (auto const second: std::views::iota(2, SilentSamples + 2)) + script.push_back(FleetSampleOf(second, later)); + script.push_back(Tick); + for ([[maybe_unused]] auto const step: std::views::iota(0, 3)) + script.push_back(KeyOf(DownKey)); + auto const run = RunFleet(std::move(script), 132, 40, UnicodeContext()); + REQUIRE(run.frames.size() == 4); + INFO(run.frames[0]); + REQUIRE(LineIndexHolding(run.frames[0], "build-01") == Lines(run.frames[0]).size()); + + CHECK(SelectedIn(run, 1).lanes == std::vector { "build-02:7070" }); + CHECK(SelectedIn(run, 3).lanes == std::vector { "build-04:7070" }); +} + +TEST_CASE("a section with no machine column focuses rows but names no machine", "[cli][dashboard][panel][fleet][focus]") +{ + // `revoked` names a key, not a machine. WHAT DISTINGUISHES: the focused row is dressed, and no lane is -- + // a band dressed here would claim the revoked key belongs to a machine on the chart. + auto script = std::vector { FleetSampleOf(1, LeaderFleetTextRevokingOne(3)), Tick, KeyOf("r") }; + for ([[maybe_unused]] auto const step: std::views::iota(0, 4)) + script.push_back(KeyOf(DownKey)); + auto const run = RunFleet(std::move(script), 132, 40, UnicodeContext()); + REQUIRE(run.frames.size() == 6); + INFO(run.frames.back()); + + // Three Downs still walk the lanes: the focus is there, and it names a machine. + CHECK(SelectedIn(run, 4).lanes == std::vector { "build-03:7070" }); + auto const selected = SelectedIn(run, 5); + CHECK(selected.strip == std::vector { "[revoked]" }); + CHECK(selected.rows == std::vector { "old-07" }); + CHECK(selected.lanes.empty()); +} + +namespace +{ + +/// The Enter key, as the bytes `KeyBytes` delivers it. +constexpr std::string_view EnterKey = "\r"; + +/// A fleet of @p machines machines, each serving two toolchains and holding one outstanding lease. +/// +/// A worker's id and a lease's key name their machine's number (`w-01-gcc`, `obj-01.o`), so a frame that +/// holds another machine's rows says so by a string no other cell spells. +/// @param machines How many machines. +/// @return The document, rendered by the leader's own renderer. +[[nodiscard]] std::string LeaderFleetTextWithWork(std::size_t machines) +{ + auto snapshot = LeaderSnapshot(machines); + for (auto const index: std::views::iota(std::size_t { 1 }, machines + 1)) + { + auto const endpoint = std::format("build-{:02}:7070", index); + for (auto const toolchain: { std::string_view { "gcc" }, std::string_view { "clang" } }) + { + auto worker = Distributed::WorkerReport {}; + worker.info.id = std::format("w-{:02}-{}", index, toolchain); + worker.info.fingerprint = std::format("{}-fingerprint", toolchain); + worker.info.endpoint = endpoint; + worker.info.slots = 8; + snapshot.workers.push_back(std::move(worker)); + } + snapshot.outstandingLeases.push_back(Distributed::LeaseHolding { .key = std::format("obj-{:02}.o", index), + .workerId = std::format("w-{:02}-gcc", index), + .workerEndpoint = endpoint, + .age = std::chrono::milliseconds { 1200 } }); + } + return RenderFleetText(snapshot, Distributed::FleetHistoryView {}, std::nullopt); +} + +} // namespace + +TEST_CASE("enter on a focused machine draws its detail: its row in every section that names machines", + "[cli][dashboard][panel][fleet][detail]") +{ + // Down focuses the first lane, `build-01`. WHAT DISTINGUISHES: the frame Enter draws names that machine and + // its own name, holds ITS workers and lease, holds no other machine anywhere, and the fleet's strip is gone -- + // a detail drawn under the fleet view, or one that did not restrict its tables, keeps one of those. + auto script = DownsAfterFirstFrame(LeaderFleetTextWithWork(3), 1); + script.push_back(KeyOf(EnterKey)); + auto const run = RunFleet(std::move(script), 132, 40, UnicodeContext()); + REQUIRE(run.frames.size() == 3); + auto const& detail = run.frames[2]; + INFO(detail); + + CHECK(detail.contains("machine build-01:7070")); + CHECK(detail.contains("ci-01")); + CHECK(detail.contains("w-01-gcc")); + CHECK(detail.contains("w-01-clang")); + CHECK(detail.contains("obj-01.o")); + for (auto const* other: { "build-02", "build-03", "ci-02", "w-02-gcc", "obj-03.o" }) + { + INFO("other " << other); + CHECK_FALSE(detail.contains(other)); + } + CHECK_FALSE(detail.contains("[machines]")); + CHECK(detail.contains("Esc back")); +} + +TEST_CASE("esc closes the detail and the fleet view comes back with the same machine focused", + "[cli][dashboard][panel][fleet][detail]") +{ + // WHAT DISTINGUISHES: Esc is the view's, not the loop's -- the run goes on and draws a fourth frame -- and + // that frame is the fleet view again with the lane the detail was opened from still dressed. + auto script = DownsAfterFirstFrame(LeaderFleetTextWithWork(3), 1); + script.push_back(KeyOf(EnterKey)); + script.push_back(KeyOf("\x1b")); + auto const run = RunFleet(std::move(script), 132, 40, UnicodeContext()); + REQUIRE(run.frames.size() == 4); + CHECK(run.stop == DashboardStop::SourceDetached); + INFO(run.frames[3]); + CHECK(run.frames[3].contains("[machines]")); + CHECK(SelectedIn(run, 3).lanes == std::vector { "build-01:7070" }); +} + +TEST_CASE("enter with nothing focused or on a section with no machine column opens nothing", + "[cli][dashboard][panel][fleet][detail]") +{ + // WHAT DISTINGUISHES: the frame count. An Enter that opened a detail draws a frame; one that has no machine + // to open draws none, whether nothing is focused or the focused row names a key rather than a machine. + auto const fresh = + RunFleet({ FleetSampleOf(1, LeaderFleetTextWithWork(3)), Tick, KeyOf(EnterKey) }, 132, 40, UnicodeContext()); + CHECK(fresh.frames.size() == 1); + + auto script = std::vector { FleetSampleOf(1, LeaderFleetTextRevokingOne(3)), Tick, KeyOf("r") }; + for ([[maybe_unused]] auto const step: std::views::iota(0, 4)) + script.push_back(KeyOf(DownKey)); + script.push_back(KeyOf(EnterKey)); + auto const revoked = RunFleet(std::move(script), 132, 40, UnicodeContext()); + REQUIRE(revoked.frames.size() == 6); + CHECK(SelectedIn(revoked, 5).rows == std::vector { "old-07" }); +} + +TEST_CASE("a detail whose machine left the sample says so rather than going blank", "[cli][dashboard][panel][fleet][detail]") +{ + // The detail opened on the third lane, `build-03`, and the next sample has two machines. WHAT DISTINGUISHES: + // the heading says the machine is not in the latest sample, and the sections under it hold no other machine's + // rows -- a detail that dropped its restriction when the machine left would draw the whole fleet. + auto script = DownsAfterFirstFrame(LeaderFleetTextWithWork(3), 3); + script.push_back(KeyOf(EnterKey)); + script.push_back(FleetSampleOf(2, LeaderFleetTextWithWork(2))); + script.push_back(Tick); + auto const run = RunFleet(std::move(script), 132, 40, UnicodeContext()); + REQUIRE(run.frames.size() == 6); + CHECK(run.frames[4].contains("w-03-gcc")); + auto const& gone = run.frames[5]; + INFO(gone); + CHECK(gone.contains("machine build-03:7070")); + CHECK(gone.contains("not in the latest sample")); + for (auto const* other: { "build-01", "build-02", "w-01-gcc", "w-02-gcc" }) + { + INFO("other " << other); + CHECK_FALSE(gone.contains(other)); + } +} + +TEST_CASE("esc while typing a filter clears the filter and keeps the session", "[cli][dashboard][panel][fleet][detail]") +{ + // WHAT DISTINGUISHES: the run does not quit on Esc, and the frame Esc draws has no filter line left. + auto const run = RunFleet({ FleetSampleOf(1, LeaderFleetTextWithWork(3)), Tick, KeyOf("/"), KeyOf("x"), KeyOf("\x1b") }, + 132, + 40, + UnicodeContext()); + REQUIRE(run.frames.size() == 4); + CHECK(run.stop == DashboardStop::SourceDetached); + CHECK(run.frames[2].contains("filter /x_")); + CHECK_FALSE(run.frames[3].contains("filter")); +} + +TEST_CASE("a failed sample does not tell a detail its machine left", "[cli][dashboard][panel][fleet][detail]") +{ + // A refused sample is a gap in what was read, not a fleet without the machine. WHAT DISTINGUISHES: the heading + // after the failure carries the absent marker -- nothing was read -- and never `not in the latest sample`, + // which the case where a sample really lacks the machine still says. + auto script = DownsAfterFirstFrame(LeaderFleetTextWithWork(3), 1); + script.push_back(KeyOf(EnterKey)); + script.push_back(DashboardEvent { .kind = DashboardEventKind::SampleFailed, + .at = core::platform::SteadyTimePoint { 2s }, + .outcome = Outcome::Refused, + .note = "not the leader" }); + script.push_back(Tick); + auto const run = RunFleet(std::move(script), 132, 40, UnicodeContext()); + REQUIRE(run.frames.size() == 4); + auto const& failed = run.frames[3]; + INFO(failed); + CHECK_FALSE(failed.contains("not in the latest sample")); + auto const heading = LineIndexHolding(failed, "machine build-01:7070"); + REQUIRE(heading < Lines(failed).size()); + CHECK(Lines(failed)[heading].contains(Absent)); +} + +TEST_CASE("a detail on a short terminal drops whole sections rather than refusing the frame", + "[cli][dashboard][panel][fleet][detail]") +{ + // WHAT DISTINGUISHES: a detail whose every section were essential needs about twenty lines and draws the + // minimum-size line on a terminal the fleet view fits; one whose sections give way keeps its heading, its + // first section and the key that goes back. + for (auto const rows: { 20, 16 }) + { + INFO("rows " << rows); + auto script = DownsAfterFirstFrame(LeaderFleetTextWithWork(3), 1); + script.push_back(KeyOf(EnterKey)); + auto const run = RunFleet(std::move(script), 132, rows, UnicodeContext()); + REQUIRE(run.frames.size() == 3); + auto const& detail = run.frames[2]; + INFO(detail); + CHECK_FALSE(detail.contains("needs")); + CHECK(detail.contains("machine build-01:7070")); + CHECK(LineStarting(detail, "machines").has_value()); + CHECK(detail.contains("Esc back")); + CHECK(Lines(detail).size() == static_cast(rows)); + } +} + +TEST_CASE("a detail's tables dress no row as selected", "[cli][dashboard][panel][fleet][detail]") +{ + // Every row of a detail is the machine's, so dressing the ones that name it dresses all of them and says + // nothing. WHAT DISTINGUISHES: no Selected span anywhere in the detail frame. + auto script = DownsAfterFirstFrame(LeaderFleetTextWithWork(3), 1); + script.push_back(KeyOf(EnterKey)); + auto const run = RunFleet(std::move(script), 132, 40, UnicodeContext()); + REQUIRE(run.frames.size() == 3); + INFO(run.frames[2]); + CHECK(run.frames[2].contains("w-01-gcc")); + auto selected = std::vector {}; + for (auto const& span: run.spans[2]) + if (span.tone == FrameTone::Selected) + selected.push_back(SpanText(run.frames[2], span)); + CHECK(selected.empty()); +} + +TEST_CASE("while a detail is open no other key draws and a second esc after closing it quits", + "[cli][dashboard][panel][fleet][detail]") +{ + // WHAT DISTINGUISHES: the frame count and how the run ends. Up, Down, a section key and PgDn act on what the + // detail hides, so none draws; the first Esc closes the detail and draws the fleet view; the second Esc is + // the loop's again and quits, so the closing Tick draws nothing. + auto script = DownsAfterFirstFrame(LeaderFleetTextWithWork(3), 1); + script.push_back(KeyOf(EnterKey)); + for (auto const keys: { UpKey, DownKey, std::string_view { "w" }, std::string_view { "\x1b[6~" } }) + script.push_back(KeyOf(keys)); + script.push_back(KeyOf("\x1b")); + script.push_back(KeyOf("\x1b")); + script.push_back(Tick); + auto const run = RunFleet(std::move(script), 132, 40, UnicodeContext()); + REQUIRE(run.frames.size() == 4); + CHECK(run.stop == DashboardStop::Quit); + INFO(run.frames[3]); + CHECK(run.frames[3].contains("[machines]")); +} + +TEST_CASE("a focused row past the table's page is scrolled into view", "[cli][dashboard][panel][fleet][focus]") +{ + // Thirty machines on a short terminal: the table shows a few rows, and forty Downs walk past the lanes to the + // last row, `build-30`. WHAT DISTINGUISHES: that row is drawn and dressed -- a focus that did not scroll the + // table would name a row below the page, and the frame would dress no row at all. + constexpr auto Machines = std::size_t { 30 }; + auto const run = RunFleet(DownsAfterFirstFrame(LeaderFleetText(Machines), 40), 132, 24, UnicodeContext()); + // A Down that moves nothing draws nothing, so the frames stop once the last row is focused. + REQUIRE(run.frames.size() > 1); + INFO(run.frames.back()); + REQUIRE(LineIndexHolding(run.frames[0], "build-30:7070") == Lines(run.frames[0]).size()); + CHECK(SelectedIn(run, run.frames.size() - 1).rows == std::vector { "build-30:7070" }); +} + +TEST_CASE("the strip's hint names the keys that select and open a machine where the width allows", + "[cli][dashboard][panel][fleet][detail]") +{ + // At 100 columns the section letters fit and the selection hint does not: it yields first, being the strip's + // rightmost piece. Wider, both are drawn. + auto const wide = RunFleet({ FleetSampleOf(1, LeaderFleetText(3)), Tick }, 160, 40, UnicodeContext()); + REQUIRE(wide.frames.size() == 1); + auto const strip = LineIndexHolding(wide.frames[0], "[machines]"); + REQUIRE(strip < Lines(wide.frames[0]).size()); + INFO(Lines(wide.frames[0])[strip]); + CHECK(Lines(wide.frames[0])[strip].ends_with( + "keys m w l c r ! t \xe2\x86\x91\xe2\x86\x93 select Enter detail \xe2\x94\x82")); + + auto const narrower = RunFleet({ FleetSampleOf(1, LeaderFleetText(3)), Tick }, 100, 40, UnicodeContext()); + REQUIRE(narrower.frames.size() == 1); + CHECK_FALSE(narrower.frames[0].contains("Enter detail")); + CHECK(narrower.frames[0].contains("keys m w l c r ! t")); +} + +namespace +{ + /// The endpoint the chrome fixtures' cache sessions subscribe at, and their streams answered from. constexpr std::string_view ChromeEndpoint = "127.0.0.1:6379"; diff --git a/src/apps/pacectl/FleetFocus.cpp b/src/apps/pacectl/FleetFocus.cpp new file mode 100644 index 000000000..f6dbbaa3d --- /dev/null +++ b/src/apps/pacectl/FleetFocus.cpp @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "FleetFocus.hpp" + +#include +#include +#include +#include + +namespace Pace::Cli +{ + +namespace +{ + /// Where @p focus sits in the one list the two zones make: the lanes, then the rows. + /// @param focus An active focus inside its zone. + /// @param lanes How many lanes there are. + /// @return Its position in that list. + [[nodiscard]] std::size_t PositionOf(FleetFocus const& focus, std::size_t lanes) noexcept + { + return focus.zone == FocusZone::Lanes ? focus.index : lanes + focus.index; + } + + /// The active focus at @p position of the list the lanes and the rows make. + /// @param position A position inside that list. + /// @param lanes How many lanes there are. + /// @return That focus. + [[nodiscard]] FleetFocus FocusAt(std::size_t position, std::size_t lanes) noexcept + { + if (position < lanes) + return FleetFocus { .zone = FocusZone::Lanes, .index = position, .active = true }; + return FleetFocus { .zone = FocusZone::Rows, .index = position - lanes, .active = true }; + } +} // namespace + +std::optional MachineColumnOf(Distributed::FleetSection section) noexcept +{ + return MachineColumnTable[static_cast(section)].column; +} + +FleetFocus StepFocus(FleetFocus focus, FocusStep step, std::size_t lanes, std::size_t rows) noexcept +{ + auto const total = lanes + rows; + if (total == 0) + return FleetFocus {}; + // The first key only lights the highlight, on the first entry there is, whichever key it was. + if (!focus.active) + return FocusAt(0, lanes); + auto const position = PositionOf(ClampFocus(focus, lanes, rows), lanes); + // No wrap at either end: the first lane and the last row are where the keys stop. + auto const next = + step == FocusStep::Down ? std::min(position + 1, total - 1) : std::max(position, std::size_t { 1 }) - 1; + return FocusAt(next, lanes); +} + +FleetFocus ClampFocus(FleetFocus focus, std::size_t lanes, std::size_t rows) noexcept +{ + if (!focus.active) + return focus; + if (lanes + rows == 0) + return FleetFocus {}; + auto const ownCount = focus.zone == FocusZone::Lanes ? lanes : rows; + if (ownCount > 0) + return FleetFocus { .zone = focus.zone, .index = std::min(focus.index, ownCount - 1), .active = true }; + // The zone emptied under the focus: the nearest entry of the other zone takes it -- the last + // lane, which sits just above the rows, or the first row, which sits just below the lanes. + return focus.zone == FocusZone::Rows ? FocusAt(lanes - 1, lanes) : FocusAt(lanes, lanes); +} + +} // namespace Pace::Cli diff --git a/src/apps/pacectl/FleetFocus.hpp b/src/apps/pacectl/FleetFocus.hpp new file mode 100644 index 000000000..92d113641 --- /dev/null +++ b/src/apps/pacectl/FleetFocus.hpp @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#include +#include +#include +#include + +namespace Pace::Cli +{ + +/// @file FleetFocus.hpp +/// The fleet view's keyboard focus as data: which zone is highlighted, which entry in it, and what +/// an arrow key does to that. +/// +/// Pure: counts in, a focus out, so every rule below is a case over literals and the screen code +/// only renders what this decides. The two zones are walked as one list -- the machine lanes, then +/// the table rows that matched the filter -- with no wrap at either end. + +/// Which part of the fleet view holds the highlight. +/// +/// TRANSMITTED/PERSISTED: no. Private; enumerators may be inserted. +enum class FocusZone : std::uint8_t +{ + Lanes, ///< The per-machine chart lanes. + Rows, ///< The rows of the table the view shows, after filtering. +}; + +/// One arrow key's direction. +/// +/// TRANSMITTED/PERSISTED: no. Private; enumerators may be inserted. +enum class FocusStep : std::uint8_t +{ + Up, ///< Towards the first lane. + Down, ///< Towards the last row. +}; + +/// Where the fleet view's highlight is. +/// +/// Inactive until the first Up or Down, so a view nobody has navigated highlights nothing rather +/// than claiming a selection the operator never made. +struct FleetFocus +{ + FocusZone zone { FocusZone::Lanes }; ///< The zone holding the highlight. + std::size_t index { 0 }; ///< The lane index, or the index into the MATCHED rows. + bool active { false }; ///< False until the first Up/Down: nothing highlighted. + + /// Member-wise equality. + /// @param other The focus to compare with. + /// @return Whether every member is equal. + [[nodiscard]] bool operator==(FleetFocus const& other) const = default; +}; + +/// Which column of a section's table names the machine one of its rows describes. +struct MachineColumnRow +{ + Distributed::FleetSection section; ///< The section this row describes. + std::optional column {}; ///< That column, as the leader's renderer names it; none for none. +}; + +/// The machine column of every section, one row per enumerator, in enumerator order. +/// +/// **Named here and written there**: a test requires each column to be one the leader's renderer +/// writes (`FleetColumnNames`), so a column renamed in `FleetView.cpp` fails that case rather than +/// leaving a section whose rows never resolve to a machine. `Members` names a machine by where its +/// `0xFC` port answers, the other machine-grained sections by `endpoint`; `Kpi` and `Series` are not +/// per-machine, and a revoked key's row describes a key that may no longer belong to any machine. +inline constexpr EnumTable MachineColumnTable { { + { .section = Distributed::FleetSection::Kpi, .column = std::nullopt }, + { .section = Distributed::FleetSection::Machines, .column = "endpoint" }, + { .section = Distributed::FleetSection::Workers, .column = "endpoint" }, + { .section = Distributed::FleetSection::Leases, .column = "endpoint" }, + { .section = Distributed::FleetSection::Members, .column = "scheduler-endpoint" }, + { .section = Distributed::FleetSection::Revoked, .column = std::nullopt }, + { .section = Distributed::FleetSection::Conditions, .column = "endpoint" }, + { .section = Distributed::FleetSection::Tiers, .column = "endpoint" }, + { .section = Distributed::FleetSection::Series, .column = std::nullopt }, +} }; + +static_assert(RowsInEnumeratorOrder(MachineColumnTable, &MachineColumnRow::section), + "MachineColumnTable must hold one row per FleetSection, in enumerator order"); + +/// The column of @p section naming the machine a row describes, or nullopt for none. +/// @param section The section. +/// @return Its machine column, or nullopt when its rows are not per machine. +[[nodiscard]] std::optional MachineColumnOf(Distributed::FleetSection section) noexcept; + +/// @p focus after @p step, given how many lanes and matched rows there are now. +/// +/// An inactive focus becomes active on the first lane (the first row when there are no lanes) and +/// stays inactive when there is nothing at all. Down walks the lanes, then the rows, and stops on +/// the last row; Up walks back and stops on the first lane. Neither direction wraps. +/// @param focus The focus before the key. +/// @param step The key. +/// @param lanes How many lanes the view draws. +/// @param rows How many table rows matched the filter. +/// @return The focus after the key. +[[nodiscard]] FleetFocus StepFocus(FleetFocus focus, FocusStep step, std::size_t lanes, std::size_t rows) noexcept; + +/// @p focus clamped to @p lanes / @p rows (the counts changed under it). +/// +/// A focus past the end of its zone moves to that zone's last entry; a zone that emptied hands the +/// focus to the nearest entry of the other one -- the last lane for the rows, the first row for the +/// lanes -- and a view with neither drops the highlight. An inactive focus stays inactive. +/// @param focus The focus to clamp. +/// @param lanes How many lanes the view draws now. +/// @param rows How many table rows match the filter now. +/// @return The clamped focus. +[[nodiscard]] FleetFocus ClampFocus(FleetFocus focus, std::size_t lanes, std::size_t rows) noexcept; + +} // namespace Pace::Cli diff --git a/src/apps/pacectl/FleetFocus_test.cpp b/src/apps/pacectl/FleetFocus_test.cpp new file mode 100644 index 000000000..4ff573546 --- /dev/null +++ b/src/apps/pacectl/FleetFocus_test.cpp @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "FleetFocus.hpp" + +#include +#include + +#include + +#include +#include +#include +#include + +#include + +using namespace Pace; +using namespace Pace::Cli; +using Distributed::FleetSection; +using Pace::Testing::Unwrap; + +TEST_CASE("the first arrow key highlights the first lane", "[cli][dashboard][fleet][focus]") +{ + auto const first = FleetFocus { .zone = FocusZone::Lanes, .index = 0, .active = true }; + CHECK(StepFocus({}, FocusStep::Down, 3, 5) == first); + CHECK(StepFocus({}, FocusStep::Up, 3, 5) == first); +} + +TEST_CASE("down walks the lanes and then into the rows and stops at the last row", "[cli][dashboard][fleet][focus]") +{ + auto focus = FleetFocus { .zone = FocusZone::Lanes, .index = 2, .active = true }; + focus = StepFocus(focus, FocusStep::Down, 3, 2); + CHECK(focus == FleetFocus { .zone = FocusZone::Rows, .index = 0, .active = true }); + focus = StepFocus(focus, FocusStep::Down, 3, 2); + CHECK(focus == FleetFocus { .zone = FocusZone::Rows, .index = 1, .active = true }); + focus = StepFocus(focus, FocusStep::Down, 3, 2); + CHECK(focus == FleetFocus { .zone = FocusZone::Rows, .index = 1, .active = true }); + + // Inside the lanes, Down advances one lane at a time. + CHECK(StepFocus({ .zone = FocusZone::Lanes, .index = 0, .active = true }, FocusStep::Down, 3, 2) + == FleetFocus { .zone = FocusZone::Lanes, .index = 1, .active = true }); + // From the last lane with no rows below it there is nowhere to go. + CHECK(StepFocus({ .zone = FocusZone::Lanes, .index = 2, .active = true }, FocusStep::Down, 3, 0) + == FleetFocus { .zone = FocusZone::Lanes, .index = 2, .active = true }); +} + +TEST_CASE("up from the first row returns to the last lane", "[cli][dashboard][fleet][focus]") +{ + CHECK(StepFocus({ .zone = FocusZone::Rows, .index = 0, .active = true }, FocusStep::Up, 3, 4) + == FleetFocus { .zone = FocusZone::Lanes, .index = 2, .active = true }); + // Up inside the rows walks back one row, and Up on the first lane stays there. + CHECK(StepFocus({ .zone = FocusZone::Rows, .index = 3, .active = true }, FocusStep::Up, 3, 4) + == FleetFocus { .zone = FocusZone::Rows, .index = 2, .active = true }); + CHECK(StepFocus({ .zone = FocusZone::Lanes, .index = 0, .active = true }, FocusStep::Up, 3, 4) + == FleetFocus { .zone = FocusZone::Lanes, .index = 0, .active = true }); +} + +TEST_CASE("with no lanes the rows are the only zone", "[cli][dashboard][fleet][focus]") +{ + CHECK(StepFocus({}, FocusStep::Down, 0, 2) == FleetFocus { .zone = FocusZone::Rows, .index = 0, .active = true }); + CHECK(StepFocus({ .zone = FocusZone::Rows, .index = 0, .active = true }, FocusStep::Up, 0, 2) + == FleetFocus { .zone = FocusZone::Rows, .index = 0, .active = true }); +} + +TEST_CASE("nothing to focus leaves the focus inactive", "[cli][dashboard][fleet][focus]") +{ + CHECK_FALSE(StepFocus({}, FocusStep::Down, 0, 0).active); + CHECK_FALSE(StepFocus({}, FocusStep::Up, 0, 0).active); +} + +TEST_CASE("a focus past the end is clamped when rows or lanes go away", "[cli][dashboard][fleet][focus]") +{ + CHECK(ClampFocus({ .zone = FocusZone::Rows, .index = 7, .active = true }, 3, 4) + == FleetFocus { .zone = FocusZone::Rows, .index = 3, .active = true }); + CHECK(ClampFocus({ .zone = FocusZone::Rows, .index = 0, .active = true }, 3, 0) + == FleetFocus { .zone = FocusZone::Lanes, .index = 2, .active = true }); + CHECK(ClampFocus({ .zone = FocusZone::Lanes, .index = 5, .active = true }, 2, 0) + == FleetFocus { .zone = FocusZone::Lanes, .index = 1, .active = true }); + CHECK_FALSE(ClampFocus({ .zone = FocusZone::Lanes, .index = 0, .active = true }, 0, 0).active); + + // The lanes went away under a lane focus: the rows take it. + CHECK(ClampFocus({ .zone = FocusZone::Lanes, .index = 1, .active = true }, 0, 3) + == FleetFocus { .zone = FocusZone::Rows, .index = 0, .active = true }); + // An inactive focus stays inactive: clamping highlights nothing. + CHECK(ClampFocus({}, 3, 4) == FleetFocus {}); + // A focus inside the bounds is left alone. + CHECK(ClampFocus({ .zone = FocusZone::Rows, .index = 2, .active = true }, 3, 4) + == FleetFocus { .zone = FocusZone::Rows, .index = 2, .active = true }); +} + +TEST_CASE("a step from a focus whose counts shrank starts from the clamped focus", "[cli][dashboard][fleet][focus]") +{ + // WHAT DISTINGUISHES: StepFocus clamps BEFORE it steps. Stepping the stale index would land Up on + // row 4 (past the two rows left), and Down from a row of an emptied zone would claim a row nobody has. + CHECK(StepFocus({ .zone = FocusZone::Rows, .index = 5, .active = true }, FocusStep::Up, 3, 2) + == FleetFocus { .zone = FocusZone::Rows, .index = 0, .active = true }); + CHECK(StepFocus({ .zone = FocusZone::Rows, .index = 0, .active = true }, FocusStep::Down, 3, 0) + == FleetFocus { .zone = FocusZone::Lanes, .index = 2, .active = true }); +} + +TEST_CASE("every section says which column names its machine", "[cli][dashboard][fleet][focus]") +{ + CHECK(MachineColumnOf(FleetSection::Machines) == std::string_view { "endpoint" }); + CHECK(MachineColumnOf(FleetSection::Workers) == std::string_view { "endpoint" }); + CHECK(MachineColumnOf(FleetSection::Leases) == std::string_view { "endpoint" }); + CHECK(MachineColumnOf(FleetSection::Conditions) == std::string_view { "endpoint" }); + CHECK(MachineColumnOf(FleetSection::Tiers) == std::string_view { "endpoint" }); + CHECK(MachineColumnOf(FleetSection::Members) == std::string_view { "scheduler-endpoint" }); + CHECK_FALSE(MachineColumnOf(FleetSection::Revoked)); + CHECK_FALSE(MachineColumnOf(FleetSection::Kpi)); + CHECK_FALSE(MachineColumnOf(FleetSection::Series)); +} + +TEST_CASE("every machine column is one the leader's renderer writes", "[cli][dashboard][fleet][focus]") +{ + // The column is named here, the table is written there: a column renamed in the leader's + // renderer must fail HERE rather than leave a section whose rows never resolve to a machine. + auto const snapshot = Distributed::FleetSnapshot {}; + for (auto const section: Enumerators()) + { + auto const column = MachineColumnOf(section); + if (!column.has_value()) + continue; + auto const names = Distributed::FleetColumnNames(section, snapshot); + INFO(std::string { Unwrap(column) }); + CHECK(std::ranges::find(names, std::string { Unwrap(column) }) != names.end()); + } +} diff --git a/src/apps/pacectl/LivePipedView.hpp b/src/apps/pacectl/LivePipedView.hpp index 3ddcd7fd1..ab9e29d70 100644 --- a/src/apps/pacectl/LivePipedView.hpp +++ b/src/apps/pacectl/LivePipedView.hpp @@ -91,6 +91,12 @@ using FigureProjection = Value (*)(DashboardModel const& model); class PipedRecordView final: public IDashboardView { public: + /// @return False: this view opens nothing Esc closes. + [[nodiscard]] bool ClaimsEscape() const noexcept override + { + return false; + } + /// @param format How each record is written. /// @param absentOverride From `--absent`: what an absent cell renders as; ignored by `json`. /// @param project What a row reports. diff --git a/src/apps/pacectl/LiveSession_test.cpp b/src/apps/pacectl/LiveSession_test.cpp index c8ba78477..7e71853a3 100644 --- a/src/apps/pacectl/LiveSession_test.cpp +++ b/src/apps/pacectl/LiveSession_test.cpp @@ -240,6 +240,12 @@ class ScriptedInstaller final: public IStopSignalInstaller class GeometryView final: public IDashboardView { public: + /// @return False: this view opens nothing Esc closes. + [[nodiscard]] bool ClaimsEscape() const noexcept override + { + return false; + } + /// @param geometry Where each frame's columns and rows go; outlives the view. explicit GeometryView(std::vector>* geometry) noexcept: _geometry { geometry } @@ -260,6 +266,12 @@ class GeometryView final: public IDashboardView class ThrowingView final: public IDashboardView { public: + /// @return False: this view opens nothing Esc closes. + [[nodiscard]] bool ClaimsEscape() const noexcept override + { + return false; + } + [[nodiscard]] DashboardFrame PlacedFrame(DashboardModel const& /*model*/) override { throw std::runtime_error { "the view could not draw" }; @@ -1020,6 +1032,12 @@ namespace class StatusView final: public IDashboardView { public: + /// @return False: this view opens nothing Esc closes. + [[nodiscard]] bool ClaimsEscape() const noexcept override + { + return false; + } + /// @param versions Where each frame's version goes; outlives the view. explicit StatusView(std::vector* versions) noexcept: _versions { versions } diff --git a/src/apps/pacectl/LiveSourceRig.hpp b/src/apps/pacectl/LiveSourceRig.hpp index d597c7f21..6b079b863 100644 --- a/src/apps/pacectl/LiveSourceRig.hpp +++ b/src/apps/pacectl/LiveSourceRig.hpp @@ -399,6 +399,12 @@ class SpokenTerminal final: public IDashboardEventSource class CountView final: public IDashboardView { public: + /// @return False: this view opens nothing Esc closes. + [[nodiscard]] bool ClaimsEscape() const noexcept override + { + return false; + } + [[nodiscard]] DashboardFrame PlacedFrame(DashboardModel const& model) override { return DashboardFrame { .text = std::to_string(model.samples), .placements = {} }; diff --git a/src/apps/pacectl/TerminalEventStream.cpp b/src/apps/pacectl/TerminalEventStream.cpp index 1da4f8a48..9615fc302 100644 --- a/src/apps/pacectl/TerminalEventStream.cpp +++ b/src/apps/pacectl/TerminalEventStream.cpp @@ -40,8 +40,9 @@ namespace /// The named keys that have a byte spelling, as a table so another key is a row. /// /// The spellings are the ordinary xterm ones. They only have to be RIGHT for the keys the - /// dashboard acts on, and `ESC` is the one of those here; the rest are spelled so that a - /// consumer comparing bytes sees what an operator pressed rather than nothing. + /// dashboard acts on -- `ESC`, `Enter`, `Tab`, `Backspace`, the four arrows, `Home`, `PageUp` and + /// `PageDown` -- and the rest are spelled so that a consumer comparing bytes sees what an operator + /// pressed rather than nothing. constexpr auto NamedKeys = std::to_array({ { .key = core::tui::KeyCode::Escape, .bytes = "\x1b" }, { .key = core::tui::KeyCode::Enter, .bytes = "\r" },