From cf3e6fc8a02d7a964ba6f2c382e76f46957e137b Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Tue, 20 Oct 2026 16:41:43 +0200 Subject: [PATCH 1/7] feat(pacectl): a focus model for the fleet view's lanes and rows Signed-off-by: Christian Parpart --- src/apps/pacectl/CMakeLists.txt | 2 + src/apps/pacectl/FleetFocus.cpp | 69 ++++++++++++++++ src/apps/pacectl/FleetFocus.hpp | 114 ++++++++++++++++++++++++++ src/apps/pacectl/FleetFocus_test.cpp | 117 +++++++++++++++++++++++++++ 4 files changed, 302 insertions(+) create mode 100644 src/apps/pacectl/FleetFocus.cpp create mode 100644 src/apps/pacectl/FleetFocus.hpp create mode 100644 src/apps/pacectl/FleetFocus_test.cpp 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/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..ef8c4abe8 --- /dev/null +++ b/src/apps/pacectl/FleetFocus_test.cpp @@ -0,0 +1,117 @@ +// 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("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()); + } +} From 03b1362a714206135611349a2ee7bcb69570e08a Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Tue, 20 Oct 2026 16:51:45 +0200 Subject: [PATCH 2/7] feat(pacectl): up and down move a highlight through the fleet's lanes and rows Up and Down walk the fleet view's chart lanes, then the active section's matched rows (StepFocus), over the counts the last frame drew. The machine the highlight names is dressed as Selected wherever it appears: its band's label and the first cell of every table row whose machine column names it, plus the focused row itself. Each frame clamps the focus to the lanes and rows it has before drawing, so a sample or filter that removes the focused row moves the highlight rather than losing it; a section switch or filter edit restarts a row focus on the first row, and a focused row is scrolled into view. Row matching is lifted into WriteMatchingRows, so the drawn table and the highlight count the same rows. FocusedMachine is public for the detail view that follows. Signed-off-by: Christian Parpart --- src/apps/pacectl/DashboardPanel.cpp | 264 +++++++++++++++++++---- src/apps/pacectl/DashboardPanel.hpp | 37 +++- src/apps/pacectl/DashboardPanel_test.cpp | 208 +++++++++++++++++- src/apps/pacectl/FleetFocus_test.cpp | 10 + 4 files changed, 475 insertions(+), 44 deletions(-) diff --git a/src/apps/pacectl/DashboardPanel.cpp b/src/apps/pacectl/DashboardPanel.cpp index da83120d5..9123a42cb 100644 --- a/src/apps/pacectl/DashboardPanel.cpp +++ b/src/apps/pacectl/DashboardPanel.cpp @@ -2136,12 +2136,16 @@ namespace 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. + /// What the operator has done to a document panel's table: how far it is scrolled, what filters it and + /// which row and machine the highlight names. 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. + 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 +2208,62 @@ 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. + }; + + /// 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. 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. + /// @return The kept rows. + [[nodiscard]] WrittenRows WriteMatchingRows(FrameInputs const& in, + FleetSection section, + Value const& table, + std::string_view filter) + { + 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]; + 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 @@ -2236,25 +2296,11 @@ 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); + auto const& matched = written.matched; + 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 +2349,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 +2454,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 +2546,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 +2651,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 +2826,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 +2879,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); } @@ -3318,8 +3399,76 @@ 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 }, + }); + + /// 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; + } } // 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 +3527,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 +3542,7 @@ bool PanelView::Key(std::string_view keys) return false; _filter += keys; _scroll = 0; + _focus = RowFocusRestarted(_focus); return true; } @@ -3419,6 +3571,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 +3588,7 @@ bool PanelView::Key(std::string_view keys) _context.section = *section; _scroll = 0; _filter.clear(); + _focus = RowFocusRestarted(_focus); return true; } @@ -3467,8 +3630,33 @@ 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 -- 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. + auto lanes = _spec->document.has_value() ? FleetChartLanes(in, _context) : std::vector {}; + // The keys walked the lanes the last frame drew; a band the chart had no room for is no lane. + lanes.resize(std::min(lanes.size(), _lanes)); + auto const matched = + _spec->document.has_value() ? MatchedRowsOf(in, _context.section, _filter) : std::vector {}; + if (auto const clamped = ClampFocus(_focus, lanes.size(), matched.size()); clamped != _focus) + { + _focus = clamped; + _scroll = ScrollShowing(_focus, _scroll, _page); + } + _rows = matched.size(); + _focusedMachine = + FocusedMachine(_focus, lanes, in.model->latestDocument.get(), _context.section, matched).value_or(std::string {}); + auto const focusedRow = + _focus.active && _focus.zone == FocusZone::Rows ? std::optional { _focus.index } : std::nullopt; + auto document = 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()) @@ -3528,7 +3716,7 @@ 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); @@ -3553,6 +3741,8 @@ DashboardFrame PanelView::PlacedFrame(DashboardModel const& model) } 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; 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..1244fcb47 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,10 @@ 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. 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; @@ -680,6 +703,18 @@ 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 }; + + /// 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 {}; + /// 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..b2710278d 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. @@ -2317,6 +2327,192 @@ 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("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 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_test.cpp b/src/apps/pacectl/FleetFocus_test.cpp index ef8c4abe8..4ff573546 100644 --- a/src/apps/pacectl/FleetFocus_test.cpp +++ b/src/apps/pacectl/FleetFocus_test.cpp @@ -87,6 +87,16 @@ TEST_CASE("a focus past the end is clamped when rows or lanes go away", "[cli][d == 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" }); From 6b198c7a2864d523d379872d6c5da0a72f6f566a Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Tue, 20 Oct 2026 16:56:27 +0200 Subject: [PATCH 3/7] fix(pacectl): read the fleet view's lanes over the span the chart drew The lanes Up and Down walk were read over the whole history while a text chart bands only the samples its cells hold. A machine that fell silent between the two spans was lane 0 in the list and in no band: the first Down dressed nothing and the last drawn band could not be reached. The view now records the chart's window with its band count and reads the lanes over it. Signed-off-by: Christian Parpart --- src/apps/pacectl/DashboardPanel.cpp | 9 ++++++-- src/apps/pacectl/DashboardPanel.hpp | 3 +++ src/apps/pacectl/DashboardPanel_test.cpp | 27 ++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/apps/pacectl/DashboardPanel.cpp b/src/apps/pacectl/DashboardPanel.cpp index 9123a42cb..028fcab78 100644 --- a/src/apps/pacectl/DashboardPanel.cpp +++ b/src/apps/pacectl/DashboardPanel.cpp @@ -3632,8 +3632,12 @@ DashboardFrame PanelView::PlacedFrame(DashboardModel const& model) auto tiers = TierItems(in, *_spec, budget); // The highlight first, 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. - auto lanes = _spec->document.has_value() ? FleetChartLanes(in, _context) : std::vector {}; - // The keys walked the lanes the last frame drew; a band the chart had no room for is no lane. + // 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. + auto lanes = _spec->document.has_value() && _laneWindow > 0 + ? FleetChartBands(model.history, FleetChartMetrics.front(), _laneWindow) + : std::vector {}; lanes.resize(std::min(lanes.size(), _lanes)); auto const matched = _spec->document.has_value() ? MatchedRowsOf(in, _context.section, _filter) : std::vector {}; @@ -3743,6 +3747,7 @@ DashboardFrame PanelView::PlacedFrame(DashboardModel const& model) _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 1244fcb47..4704d420e 100644 --- a/src/apps/pacectl/DashboardPanel.hpp +++ b/src/apps/pacectl/DashboardPanel.hpp @@ -706,6 +706,9 @@ class PanelView final: public IDashboardView /// 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 }; diff --git a/src/apps/pacectl/DashboardPanel_test.cpp b/src/apps/pacectl/DashboardPanel_test.cpp index b2710278d..d52cb1643 100644 --- a/src/apps/pacectl/DashboardPanel_test.cpp +++ b/src/apps/pacectl/DashboardPanel_test.cpp @@ -2491,6 +2491,33 @@ TEST_CASE("rows that vanish between samples clamp the focus", "[cli][dashboard][ 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 -- From da866549943fc8f417e30e1181d5f48bf230526f Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Tue, 20 Oct 2026 17:06:43 +0200 Subject: [PATCH 4/7] feat(pacectl): enter opens a machine's detail in the fleet view, esc returns Enter on a highlight that names a machine draws that machine's detail in place of the tiles, chart, strip and table: a `machine ` heading with its name, then every tabular section that names machines, restricted to that machine's rows through WriteMatchingRows (TableState gains restrictToMachine). A section with none of its rows writes the absent marker under its name, and a machine the latest sample no longer lists says `not in the latest sample`. Esc closes the detail and the focus is where it was; while it is open no other key acts. IDashboardView gains a pure ClaimsEscape, implemented by every view: the loop hands Esc (now the named EscapeKey) to a view that claims it rather than quitting. The strip's hint names the keys that select and open a machine, yielding first for width, and the rung glyphs spell the arrows as ASCII where the terminal is. Signed-off-by: Christian Parpart --- docs/tools/pacectl.md | 9 +- src/apps/pacectl/DashboardGlyphs.hpp | 15 +- src/apps/pacectl/DashboardLoop.cpp | 6 +- src/apps/pacectl/DashboardLoop.hpp | 13 +- src/apps/pacectl/DashboardLoop_test.cpp | 98 ++++++++ src/apps/pacectl/DashboardPanel.cpp | 302 +++++++++++++++++++---- src/apps/pacectl/DashboardPanel.hpp | 10 +- src/apps/pacectl/DashboardPanel_test.cpp | 159 ++++++++++++ src/apps/pacectl/LivePipedView.hpp | 6 + src/apps/pacectl/LiveSession_test.cpp | 18 ++ src/apps/pacectl/LiveSourceRig.hpp | 6 + 11 files changed, 590 insertions(+), 52 deletions(-) diff --git a/docs/tools/pacectl.md b/docs/tools/pacectl.md index 563b6b32c..8660ae2f6 100644 --- a/docs/tools/pacectl.md +++ b/docs/tools/pacectl.md @@ -863,18 +863,25 @@ 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. + ### How it draws: Sixel, Unicode, ASCII What the terminal can draw is asked once, when the session starts, and decides 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..45f39c469 100644 --- a/src/apps/pacectl/DashboardLoop.cpp +++ b/src/apps/pacectl/DashboardLoop.cpp @@ -25,7 +25,7 @@ namespace /// 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" }); + constexpr auto QuitKeys = std::to_array({ "q", "Q", InterruptKey, EscapeKey }); /// Whether a reading continues the run the reading before it belongs to. /// @@ -294,7 +294,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..b043301d9 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. +class EscapeClaimingView final: public IDashboardView +{ + public: + /// @param claims What `ClaimsEscape` answers. + explicit EscapeClaimingView(bool claims): + _claims { claims } + { + } + + [[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; + } + + std::vector offered {}; + + private: + bool _claims; +}; + +/// 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 028fcab78..ee8cce4b2 100644 --- a/src/apps/pacectl/DashboardPanel.cpp +++ b/src/apps/pacectl/DashboardPanel.cpp @@ -2120,19 +2120,29 @@ 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) }; } @@ -2146,6 +2156,7 @@ namespace 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. bool typing { false }; ///< Whether the filter is being typed. + bool restrictToMachine { false }; ///< Whether only the rows `machine` names are drawn. }; /// A section's table as a frame draws it, with what the filter and the scroll made of it. @@ -2215,26 +2226,53 @@ namespace std::vector> cells {}; ///< Each kept row's cells, written. }; + /// 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. + }; + + /// 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. Each cell is written by the scale its column has in the leader's own - /// tables, so the filter matches what the operator reads. + /// 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::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())) @@ -2271,19 +2309,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()) @@ -2298,7 +2332,14 @@ namespace // 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); + auto const written = WriteMatchingRows( + in, + section, + *table, + state.filter, + state.restrictToMachine + ? std::optional { RowMachine { .machine = state.machine, .column = state.machineColumn } } + : std::nullopt); auto const& matched = written.matched; auto const& cells = written.cells; auto const scroll = matched.empty() ? 0 : std::min(state.scroll, matched.size() - 1); @@ -2954,7 +2995,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); @@ -2966,6 +3007,120 @@ 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 lists no such machine, `not in the latest sample`. + /// + /// **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. + /// @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 (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); + } + + /// 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. + /// @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 { .machine = machine, .machineColumn = *column, .restrictToMachine = true }, + budget); + if (!table.has_value()) + return std::unexpected(table.error()); + items.push_back(Blank()); + items.push_back(LabelLine(row.key, spec.tablePriority)); + if (table->matched == 0) + { + items.push_back( + Item { .lines = { std::format("{}{}", Indent, in.absent) }, .priority = spec.tablePriority }); + continue; + } + for (auto& item: table->items) + { + // No overflow line names a key to scroll by: a detail's tables do not scroll. + item.noun = {}; + items.push_back(std::move(item)); + } + } + 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 { @@ -3413,6 +3568,28 @@ namespace { .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. @@ -3546,6 +3723,25 @@ bool PanelView::Key(std::string_view keys) 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; + } + if (keys == FilterStartKey) { _typingFilter = true; @@ -3597,6 +3793,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 = @@ -3635,32 +3836,42 @@ DashboardFrame PanelView::PlacedFrame(DashboardModel const& model) // 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. - auto lanes = _spec->document.has_value() && _laneWindow > 0 - ? FleetChartBands(model.history, FleetChartMetrics.front(), _laneWindow) - : std::vector {}; - lanes.resize(std::min(lanes.size(), _lanes)); - auto const matched = - _spec->document.has_value() ? MatchedRowsOf(in, _context.section, _filter) : std::vector {}; - if (auto const clamped = ClampFocus(_focus, lanes.size(), matched.size()); clamped != _focus) - { - _focus = clamped; - _scroll = ScrollShowing(_focus, _scroll, _page); + // 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. + auto const detail = _spec->document.has_value() && !_detailMachine.empty(); + if (!detail) + { + auto lanes = _spec->document.has_value() && _laneWindow > 0 + ? FleetChartBands(model.history, FleetChartMetrics.front(), _laneWindow) + : std::vector {}; + lanes.resize(std::min(lanes.size(), _lanes)); + auto const matched = + _spec->document.has_value() ? MatchedRowsOf(in, _context.section, _filter) : std::vector {}; + if (auto const clamped = ClampFocus(_focus, lanes.size(), matched.size()); clamped != _focus) + { + _focus = clamped; + _scroll = ScrollShowing(_focus, _scroll, _page); + } + _rows = matched.size(); + _focusedMachine = FocusedMachine(_focus, lanes, in.model->latestDocument.get(), _context.section, matched) + .value_or(std::string {}); } - _rows = matched.size(); - _focusedMachine = - FocusedMachine(_focus, lanes, in.model->latestDocument.get(), _context.section, matched).value_or(std::string {}); auto const focusedRow = _focus.active && _focus.zone == FocusZone::Rows ? std::optional { _focus.index } : std::nullopt; - auto document = DocumentItems(in, - *_spec, - _context, - TableState { .filter = _filter, - .scroll = _scroll, - .focusedRow = focusedRow, - .machine = _focusedMachine, - .machineColumn = MachineColumnOf(_context.section).value_or(""), - .typing = _typingFilter }, - budget); + 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()) @@ -3712,7 +3923,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( @@ -3725,7 +3937,7 @@ DashboardFrame PanelView::PlacedFrame(DashboardModel const& model) 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, @@ -3743,11 +3955,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 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; + // 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 4704d420e..cd8f50256 100644 --- a/src/apps/pacectl/DashboardPanel.hpp +++ b/src/apps/pacectl/DashboardPanel.hpp @@ -671,7 +671,9 @@ class PanelView final: public IDashboardView /// 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. A panel without a document block acts on no key. + /// 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; @@ -679,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; @@ -718,6 +723,9 @@ class PanelView final: public IDashboardView /// 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 d52cb1643..4986f263e 100644 --- a/src/apps/pacectl/DashboardPanel_test.cpp +++ b/src/apps/pacectl/DashboardPanel_test.cpp @@ -1865,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); @@ -2540,6 +2546,159 @@ TEST_CASE("a section with no machine column focuses rows but names no machine", 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("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/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 = {} }; From 9e7a1f2c5875a7de926e2c72e33c6ff1590a249a Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Tue, 20 Oct 2026 17:16:47 +0200 Subject: [PATCH 5/7] fix(pacectl): a machine's detail fits short terminals and reads failed samples right The final review of the fleet navigation branch found six things: - A failed sample nulls the latest document, and the detail heading read that as `not in the latest sample`. The note is now said only when a machines section was read and lists no such machine; with none read the heading carries the absent marker. - Every detail section was Essential, so a terminal of about 21 rows or fewer drew the minimum-size line. Each section is now one table item, its name a heading line above its columns (Item::heading), at a priority below Essential: tables shrink, then whole sections drop from the bottom, keeping the heading. A restricted table with no rows is the absent marker without the column-width check. - The detail dressed every row Selected. The restriction now travels as TableState::only (a RowMachine), never as the highlight's machine. - Docs say the detail's tables do not scroll; the QuitKeys and NamedKeys comments no longer contradict what Esc and the arrows now do; the filter's Esc row spells EscapeKey. - Tests pin that no key but Esc acts while a detail is open, that a second Esc quits, and that a focused row past the page is scrolled in. Signed-off-by: Christian Parpart --- docs/tools/pacectl.md | 2 + src/apps/pacectl/DashboardLoop.cpp | 6 +- src/apps/pacectl/DashboardPanel.cpp | 107 +++++++++++++---------- src/apps/pacectl/DashboardPanel_test.cpp | 96 ++++++++++++++++++++ src/apps/pacectl/TerminalEventStream.cpp | 5 +- 5 files changed, 168 insertions(+), 48 deletions(-) diff --git a/docs/tools/pacectl.md b/docs/tools/pacectl.md index 8660ae2f6..1c93a19c8 100644 --- a/docs/tools/pacectl.md +++ b/docs/tools/pacectl.md @@ -881,6 +881,8 @@ A machine's detail replaces the tiles, the chart, the strip and the table until 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 diff --git a/src/apps/pacectl/DashboardLoop.cpp b/src/apps/pacectl/DashboardLoop.cpp index 45f39c469..44bf8dc2c 100644 --- a/src/apps/pacectl/DashboardLoop.cpp +++ b/src/apps/pacectl/DashboardLoop.cpp @@ -18,13 +18,15 @@ 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. + /// 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. diff --git a/src/apps/pacectl/DashboardPanel.cpp b/src/apps/pacectl/DashboardPanel.cpp index ee8cce4b2..e8f15a471 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)) { @@ -2146,8 +2148,15 @@ namespace return { LineOf(*kept, {}, spec.stripPriority) }; } - /// What the operator has done to a document panel's table: how far it is scrolled, what filters it and - /// which row and machine the highlight names. + /// 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. @@ -2155,8 +2164,10 @@ namespace 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. - bool typing { false }; ///< Whether the filter is being typed. - bool restrictToMachine { false }; ///< Whether only the rows `machine` names are drawn. + /// 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. @@ -2226,13 +2237,6 @@ namespace std::vector> cells {}; ///< Each kept row's cells, written. }; - /// 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. - }; - /// 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. @@ -2332,15 +2336,14 @@ namespace // 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.restrictToMachine - ? std::optional { RowMachine { .machine = state.machine, .column = state.machineColumn } } - : std::nullopt); + 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); @@ -3017,10 +3020,12 @@ namespace 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 lists no such machine, `not in the latest sample`. + /// 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. + /// 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. @@ -3044,7 +3049,9 @@ namespace Piece { .text = std::format("{}machine", Indent), .priority = Priority::Essential, .tone = FrameTone::Label }, Piece { .text = std::format(" {}", machine), .priority = Priority::Essential }, }; - if (row == nullptr) + 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 }); @@ -3058,6 +3065,12 @@ namespace 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. @@ -3075,6 +3088,10 @@ namespace /// /// 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. @@ -3094,26 +3111,28 @@ namespace auto const column = MachineColumnOf(row.section); if (!row.tabular || !column.has_value()) continue; - auto table = SectionItems(in, - row.section, - spec, - TableState { .machine = machine, .machineColumn = *column, .restrictToMachine = true }, - budget); + 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()); - items.push_back(LabelLine(row.key, spec.tablePriority)); - if (table->matched == 0) + for (auto& part: table->items) { - items.push_back( - Item { .lines = { std::format("{}{}", Indent, in.absent) }, .priority = spec.tablePriority }); - continue; - } - for (auto& item: table->items) - { - // No overflow line names a key to scroll by: a detail's tables do not scroll. - item.noun = {}; - items.push_back(std::move(item)); + // 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()); @@ -3529,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 }, }); diff --git a/src/apps/pacectl/DashboardPanel_test.cpp b/src/apps/pacectl/DashboardPanel_test.cpp index 4986f263e..dc2e5f248 100644 --- a/src/apps/pacectl/DashboardPanel_test.cpp +++ b/src/apps/pacectl/DashboardPanel_test.cpp @@ -2677,6 +2677,102 @@ TEST_CASE("esc while typing a filter clears the filter and keeps the session", " 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]") { 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" }, From 1d17957ffa36126057b3a809f42a69a7e61c2fb1 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Tue, 20 Oct 2026 17:27:39 +0200 Subject: [PATCH 6/7] fix(pacectl): lift the fleet view's focus clamp out of PlacedFrame clang-tidy 22.1.8 refused two things on this branch: PlacedFrame's cognitive complexity (62, threshold 60) and EscapeClaimingView's public `offered` beside a private member. The per-frame clamp of the highlight and the machine it names is now FocusForFrame, a pure function over the frame's inputs and the walk state (FocusWalk -> FramedFocus); the fake keeps every member public, as the file's other recording fakes do. Signed-off-by: Christian Parpart --- src/apps/pacectl/DashboardLoop_test.cpp | 16 ++--- src/apps/pacectl/DashboardPanel.cpp | 87 ++++++++++++++++++------- 2 files changed, 72 insertions(+), 31 deletions(-) diff --git a/src/apps/pacectl/DashboardLoop_test.cpp b/src/apps/pacectl/DashboardLoop_test.cpp index b043301d9..dcc406232 100644 --- a/src/apps/pacectl/DashboardLoop_test.cpp +++ b/src/apps/pacectl/DashboardLoop_test.cpp @@ -1468,12 +1468,14 @@ 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 claims What `ClaimsEscape` answers. - explicit EscapeClaimingView(bool claims): - _claims { claims } + /// @param claimsEscape What `ClaimsEscape` answers. + explicit EscapeClaimingView(bool claimsEscape): + claims { claimsEscape } { } @@ -1491,13 +1493,11 @@ class EscapeClaimingView final: public IDashboardView [[nodiscard]] bool ClaimsEscape() const noexcept override { - return _claims; + return claims; } - std::vector offered {}; - - private: - bool _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`. diff --git a/src/apps/pacectl/DashboardPanel.cpp b/src/apps/pacectl/DashboardPanel.cpp index e8f15a471..5f57f41ad 100644 --- a/src/apps/pacectl/DashboardPanel.cpp +++ b/src/apps/pacectl/DashboardPanel.cpp @@ -3639,6 +3639,55 @@ namespace 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, @@ -3850,30 +3899,22 @@ 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); - // The highlight first, 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. - // 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. + // 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 (!detail) - { - auto lanes = _spec->document.has_value() && _laneWindow > 0 - ? FleetChartBands(model.history, FleetChartMetrics.front(), _laneWindow) - : std::vector {}; - lanes.resize(std::min(lanes.size(), _lanes)); - auto const matched = - _spec->document.has_value() ? MatchedRowsOf(in, _context.section, _filter) : std::vector {}; - if (auto const clamped = ClampFocus(_focus, lanes.size(), matched.size()); clamped != _focus) - { - _focus = clamped; - _scroll = ScrollShowing(_focus, _scroll, _page); - } - _rows = matched.size(); - _focusedMachine = FocusedMachine(_focus, lanes, in.model->latestDocument.get(), _context.section, matched) - .value_or(std::string {}); + 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; From c459f3f33172c7e2b478f246d3afd64d347ed993 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Tue, 20 Oct 2026 17:49:11 +0200 Subject: [PATCH 7/7] docs: keep the agent workflow's design and plan files out of git and off the site docs/superpowers/ holds the design and plan documents the agent workflow writes. They are local working files: ignored, never tracked, and excluded from the site so a local strict mkdocs build does not refuse them. Signed-off-by: Christian Parpart --- .gitignore | 5 +++++ mkdocs.yml | 8 ++++++++ 2 files changed, 13 insertions(+) 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/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