diff --git a/native/include/bm.h b/native/include/bm.h index a577f46..a6e5368 100644 --- a/native/include/bm.h +++ b/native/include/bm.h @@ -311,16 +311,28 @@ typedef struct BmScreen { /* 1 when there are no rows yet because a connection has not finished its first poll: draw a spinner rather than an empty page. */ int32_t loading; + /* 1 when any row, in view or not, has a mark before its name, and when any has one leading its + detail. Room for each is reserved on every row once any row has one; read from the rows in + view, the columns moved as a row with one scrolled in or out. */ + int32_t nameMarks; + int32_t detailMarks; /* Every distinct first column name across all rows, not only the visible slice, to size the - column from: nameCount build names, then groupNameCount group names, drawn behind an arrow. */ + column from: nameCount build names, then groupNameCount group names, drawn behind an arrow. + Then which of those are drawn wider, so each is sized as its row draws it rather than all as + the widest kind: memberNameCount of the names that are a member's, drawn indented under its + group, and markedGroupNameCount of the group names whose row carries a mark. */ const BmString* names; int32_t nameCount; int32_t groupNameCount; + int32_t memberNameCount; + int32_t markedGroupNameCount; /* Every distinct detail across all rows, to size that column from. The chips give way to the - width these want, up to a readable maximum, before the details are cut short. Text only: once - any row's spans carry an icon, add its width and gap to these. */ + width these want, up to a readable maximum, before the details are cut short. Text only: + after the detailCount of them come markedDetailCount of those that carry the branch's mark, + to which its width and gap are added. */ const BmString* details; int32_t detailCount; + int32_t markedDetailCount; /* Every distinct author across all failed builds, to size the author column from. With none the column is not drawn. */ const BmString* authors; @@ -496,7 +508,7 @@ typedef struct BmInput { * Bumped whenever the structs above change, or what a field means changes, so a stale native * library is detected rather than crashed. */ -#define BM_VERSION 19 +#define BM_VERSION 20 /* * The Swift implementation imports this header for the struct layouts, because Swift does not diff --git a/native/src/bm.cpp b/native/src/bm.cpp index ea142e6..4e4aaaa 100644 --- a/native/src/bm.cpp +++ b/native/src/bm.cpp @@ -757,32 +757,63 @@ void DrawBuilds(const BmScreen& screen, float bodyHeight) { } else if (screen.rowCount == 0) { ImGui::TextColored(dim, "%s", Str(screen, screen.empty).c_str()); } else if (ImGui::BeginTable("rows", 6, flags)) { + const ImGuiStyle& style = ImGui::GetStyle(); + // Short of the row's height rather than the sixteen a chip's icon is: the marks carry + // detail, a Jenkins butler's face and the play badge on the Actions mark, that a sixteen + // pixel square turned to a smudge. + const float iconSize = rowHeight - 6.0f; + // Reserved on every row once any row has one, so a group's row, which has no provider + // logo, keeps its name in line with the rows under it, and the names line up where a + // provider gave no repository URL to read a host mark from. Any row of the page rather + // than of those in view, or the columns move as a row with one scrolls in or out. + const bool anyIcon = screen.detailMarks != 0; + const bool anyMark = screen.nameMarks != 0; + const float markWidth = anyMark ? iconSize + style.ItemSpacing.x : 0.0f; + // The name cell starts with a status square a row height wide, then is as wide as the widest // name across every row, not only those on screen, so it does not shift while scrolling. - // A member is indented by the width of the arrow its group is drawn behind, and the room - // for it is reserved wherever the page has a group at all, open or closed, so the column - // does not shift under the rows as one is opened. - const float indent = screen.groupNameCount > 0 ? ImGui::CalcTextSize("v ").x : 0.0f; + // Each name as the kind of row that shows it: a build's behind the mark, a member's + // indented as well by the width of the arrow its group is drawn behind, and a group's + // behind its arrow, with the mark where its row has one. Every name taken as a member's + // gave the column an indent's width that no row drew in. + // The indent is the open arrow's width, as it is drawn, and a group's name is measured + // apart from it, as it is drawn: sized as the one text "v name", the column was measured + // with a letter the row does not draw, and the name moved as the arrow changed. + const float indent = screen.groupNameCount > 0 ? ImGui::CalcTextSize("▼ ").x : 0.0f; float nameText = 0.0f; - for (int32_t i = 0; i < screen.nameCount + screen.groupNameCount; i++) { - std::string name = Str(screen, screen.names[i]); - float wanted = indent; - if (i >= screen.nameCount) { - name = "v " + name; - wanted = 0.0f; + const BmString* sized = screen.names; + for (int32_t i = 0; i < screen.nameCount; i++, sized++) { + nameText = std::max(nameText, markWidth + ImGui::CalcTextSize(Str(screen, *sized).c_str()).x); + } + + for (int32_t i = 0; i < screen.groupNameCount; i++, sized++) { + nameText = std::max(nameText, indent + ImGui::CalcTextSize(Str(screen, *sized).c_str()).x); + } + + // A member's blank cell draws nothing, so it wants no room. + for (int32_t i = 0; i < screen.memberNameCount; i++, sized++) { + if (sized->length > 0) { + nameText = std::max(nameText, markWidth + indent + ImGui::CalcTextSize(Str(screen, *sized).c_str()).x); } + } - nameText = std::max(nameText, wanted + ImGui::CalcTextSize(name.c_str()).x); + for (int32_t i = 0; i < screen.markedGroupNameCount; i++, sized++) { + nameText = std::max(nameText, markWidth + indent + ImGui::CalcTextSize(Str(screen, *sized).c_str()).x); } // The detail cell likewise, up to forty characters: past that a long pipeline or branch is cut - // short rather than pushing every row's chips into the drop down. + // short rather than pushing every row's chips into the drop down. The details are text + // alone, so the branch's mark is added to those that carry one. + const float longestDetail = ImGui::CalcTextSize("0000000000000000000000000000000000000000").x; float detailText = 0.0f; for (int32_t i = 0; i < screen.detailCount; i++) { - detailText = std::max(detailText, ImGui::CalcTextSize(Str(screen, screen.details[i]).c_str()).x); + detailText = std::max(detailText, std::min(longestDetail, ImGui::CalcTextSize(Str(screen, screen.details[i]).c_str()).x)); } - detailText = std::min(detailText, ImGui::CalcTextSize("0000000000000000000000000000000000000000").x); + for (int32_t i = 0; i < screen.markedDetailCount; i++) { + const std::string detail = Str(screen, screen.details[screen.detailCount + i]); + detailText = std::max(detailText, SpanIconWidth() + std::min(longestDetail, ImGui::CalcTextSize(detail.c_str()).x)); + } // The author of a failed build, as wide as the widest name up to twenty characters, and no // width when no failed build names anyone. @@ -793,28 +824,6 @@ void DrawBuilds(const BmScreen& screen, float bodyHeight) { authorWidth = std::min(authorWidth, ImGui::CalcTextSize("00000000000000000000").x); - // Short of the row's height rather than the sixteen a chip's icon is: the marks carry - // detail, a Jenkins butler's face and the play badge on the Actions mark, that a sixteen - // pixel square turned to a smudge. - const float iconSize = rowHeight - 6.0f; - // Reserved on every row once any row has one, so a group's row, which has no provider - // logo, keeps its name in line with the rows under it, and the names line up where a - // provider gave no repository URL to read a host mark from. - bool anyIcon = false; - bool anyMark = false; - for (int32_t i = 0; i < screen.rowCount; i++) { - anyIcon = anyIcon || screen.rows[i].detailIcon.length > 0; - anyMark = anyMark || screen.rows[i].nameIcon.length > 0; - } - - // The details are text alone, so the branch's mark is added on top of them once any row - // draws one. - bool anySpanIcon = false; - for (int32_t i = 0; i < screen.spanCount; i++) { - anySpanIcon = anySpanIcon || screen.spans[i].icon.length > 0; - } - - const ImGuiStyle& style = ImGui::GetStyle(); // Measured rather than fixed, so each cell holds its widest text at whatever size the font // was loaded: a countdown past an hour, and the widest set of chips a row carries. const float barWidth = 104.0f; @@ -832,15 +841,19 @@ void DrawBuilds(const BmScreen& screen, float bodyHeight) { const float overflowWidth = ChipWidth(nullptr, overflowLabel); // Each boundary between the six columns carries cell padding on both sides of it. What is // left, the name, the detail, the bar and the chips share. - const float shared = tableWidth - timingWidth - authorWidth - 5.0f * 2.0f * style.CellPadding.x; - const float markWidth = anyMark ? iconSize + style.ItemSpacing.x : 0.0f; - const float nameWanted = rowHeight + style.ItemSpacing.x + markWidth + nameText + 2.0f * style.CellPadding.x; - const float detailWanted = (anyIcon ? iconSize + style.ItemSpacing.x : 0.0f) + (anySpanIcon ? SpanIconWidth() : 0.0f) + detailText; + // A column asked to be no width is still given the table's minimum, which is the frame + // padding. Left out of the sums, the bar's column, hidden, and the author's, with no one + // to name, took that much from the detail, the one column that stretches, and the widest + // detail was cut short by it: "feature/inline" lost its last letter. + const float minimumColumn = std::max(1.0f, style.FramePadding.x); + const float shared = tableWidth - timingWidth - std::max(authorWidth, minimumColumn) - 5.0f * 2.0f * style.CellPadding.x; + const float nameWanted = rowHeight + style.ItemSpacing.x + nameText + 2.0f * style.CellPadding.x; + const float detailWanted = (anyIcon ? iconSize + style.ItemSpacing.x : 0.0f) + detailText; // The bar gives way before anything else, since the timing beside it says the same: it shows // only while the names, the detail and every chip still fit. Hidden, its column is kept at no // width, so the columns after it keep their indexes. const bool showBar = shared - barWidth - nameWanted - detailWanted >= widestChips; - const float available = showBar ? shared - barWidth : shared; + const float available = showBar ? shared - barWidth : shared - minimumColumn; // Then the chips: a row without room for all of them puts the last behind an // overflow chip, rather than the names being cut short. Only once no chip but that one fits // do the names shrink. @@ -929,7 +942,9 @@ void DrawBuilds(const BmScreen& screen, float bodyHeight) { // arrow already stands where the mark would, and a prefix group, which names no one // repository, read as an indented heading. const float rowMarkWidth = (!arrow.empty() && row.nameIcon.length == 0) ? 0.0f : markWidth; - bool nameDrawn = nameLink || rowMarkWidth > 0.0f; + // Nor a group's, which starts the indent after the arrow rather than where whichever + // arrow is drawn happens to end. + bool nameDrawn = nameLink || rowMarkWidth > 0.0f || group; std::string selectableLabel = (nameDrawn ? arrow : arrow + name) + "##row"; // As tall as the cell, so a click anywhere on the row selects it. A click on a group // toggles it, so the second press of a double click is dropped, or it would close what @@ -948,7 +963,9 @@ void DrawBuilds(const BmScreen& screen, float bodyHeight) { } if (nameDrawn) { - float arrowWidth = arrow.empty() ? 0.0f : ImGui::CalcTextSize(arrow.c_str()).x; + // The indent rather than this arrow's own width, which is what the column was + // sized with, so the name does not move as the group opens and its arrow changes. + float arrowWidth = arrow.empty() ? 0.0f : indent; float markLeft = nameAt.x + arrowWidth; // The host's mark leads the name, and opens what the name does: it stands for the // same page, so a click on it is not a click on nothing. diff --git a/native/swift/Sources/Bm/BuildsRenderer.swift b/native/swift/Sources/Bm/BuildsRenderer.swift index 613089f..f52c3df 100644 --- a/native/swift/Sources/Bm/BuildsRenderer.swift +++ b/native/swift/Sources/Bm/BuildsRenderer.swift @@ -201,9 +201,10 @@ final class BuildsRenderer { let overflowWidth = chipWidth("", overflowLabel) // Reserved on every row once any row has one, so a group's row, which has no provider // logo, keeps its name in line with the rows under it, and the names line up where a - // provider gave no repository URL to read a host mark from. - let iconWidth: CGFloat = frame.rows.contains { !$0.detailIcon.isEmpty } ? logoSize + gap : 0 - let markWidth: CGFloat = frame.rows.contains { !$0.nameIcon.isEmpty } ? logoSize + gap : 0 + // provider gave no repository URL to read a host mark from. Any row of the page rather + // than of those in view, or the columns move as a row with one scrolls in or out. + let iconWidth: CGFloat = frame.detailMarks ? logoSize + gap : 0 + let markWidth: CGFloat = frame.nameMarks ? logoSize + gap : 0 // The author of a failed build, as wide as the widest name up to twenty characters, and gone // with its gap when no failed build names anyone. let authorWidth = min(frame.authors.map { measure($0).rounded(.up) }.max() ?? 0, measure(String(repeating: "0", count: 20))) @@ -214,18 +215,22 @@ final class BuildsRenderer { // As wide as the widest name across every row, not only those on screen, so it does not shift // while scrolling; the detail likewise, up to forty characters, past which a long pipeline or // branch is cut short rather than pushing every row's chips into the drop down. - // A member is indented by the width of the arrow its group is drawn behind, and the room - // for it is reserved wherever the page has a group at all, open or closed, so the column - // does not shift under the rows as one is opened. + // Each name as the kind of row that shows it, by the parts it is drawn from: a build's + // behind the mark, a member's indented as well by the width of the arrow its group is + // drawn behind, and a group's behind that arrow, with the mark where its row has one. A + // member's blank cell draws nothing, so it wants no room. Every name taken as a member's + // gave the column an indent's width that no row drew in. let indent: CGFloat = frame.groupNames.isEmpty ? 0 : measure("▼ ").rounded(.up) - let nameWanted = markWidth + max( - indent + (frame.names.map { measure($0).rounded(.up) }.max() ?? 0), - frame.groupNames.map { measure("▼ " + $0).rounded(.up) }.max() ?? 0) - // The details are text alone, so the branch's mark is added on top once any row draws one. - let spanIcons: CGFloat = frame.rows.contains { $0.spans.contains { !$0.icon.isEmpty } } ? spanIconWidth : 0 - let detailWanted = iconWidth + spanIcons + min( - frame.details.map { measure($0).rounded(.up) }.max() ?? 0, - measure(String(repeating: "0", count: 40))) + let nameWanted: CGFloat = max( + frame.names.map { markWidth + measure($0).rounded(.up) }.max() ?? 0, + frame.memberNames.filter { !$0.isEmpty }.map { markWidth + indent + measure($0).rounded(.up) }.max() ?? 0, + frame.groupNames.map { indent + measure($0).rounded(.up) }.max() ?? 0, + frame.markedGroupNames.map { markWidth + indent + measure($0).rounded(.up) }.max() ?? 0) + // The details are text alone, so the branch's mark is added to those that carry one. + let longestDetail = measure(String(repeating: "0", count: 40)) + let detailWanted: CGFloat = iconWidth + max( + frame.details.map { min(measure($0).rounded(.up), longestDetail) }.max() ?? 0, + frame.markedDetails.map { spanIconWidth + min(measure($0).rounded(.up), longestDetail) }.max() ?? 0) // The bar gives way before anything else, since the timing beside it says the same: it shows // only while the names, the detail and every chip still fit. let showBar = shared - barWidth - gap - nameWanted - detailWanted >= widestChips @@ -279,7 +284,9 @@ final class BuildsRenderer { let arrow = groupArrow(row) if !arrow.isEmpty { drawText(arrow, at: CGPoint(x: nameX, y: textY), font: font, colour: Palette.text, width: nameRoom) - let arrowWidth = min(measure(arrow).rounded(.up), nameRoom) + // The indent rather than this arrow's own width, which is what the column was + // sized with, so the name does not move as the group opens and its arrow changes. + let arrowWidth = min(indent, nameRoom) nameX += arrowWidth nameRoom -= arrowWidth } diff --git a/native/swift/Sources/Bm/Frame.swift b/native/swift/Sources/Bm/Frame.swift index 99ae2a6..950deb6 100644 --- a/native/swift/Sources/Bm/Frame.swift +++ b/native/swift/Sources/Bm/Frame.swift @@ -108,9 +108,17 @@ struct Frame { let totalRows: Int32 let selectedRow: Int32 let loading: Bool + /// Whether any row, in view or not, has a mark before its name, and one leading its detail. + let nameMarks: Bool + let detailMarks: Bool let names: [String] let groupNames: [String] + /// The names that are a member's, the group names whose row carries a mark, and the details + /// that carry the branch's mark: which of the texts above are drawn wider than the rest. + let memberNames: [String] + let markedGroupNames: [String] let details: [String] + let markedDetails: [String] let authors: [String] let search: String let searchTooltip: String @@ -210,8 +218,15 @@ struct Frame { separator: $0.flags & Int32(BM_TRAY_SEPARATOR.rawValue) != 0) } - let allNames = UnsafeBufferPointer(start: screen.names, count: Int(screen.nameCount + screen.groupNameCount)).map(text) - let details = UnsafeBufferPointer(start: screen.details, count: Int(screen.detailCount)).map(text) + // One run each: the names, the group names, the member names and the marked group names, + // and the details then the marked ones. + let nameCount = Int(screen.nameCount) + let groupNameCount = Int(screen.groupNameCount) + let memberNameCount = Int(screen.memberNameCount) + let markedGroupNameCount = Int(screen.markedGroupNameCount) + let allNames = UnsafeBufferPointer(start: screen.names, count: nameCount + groupNameCount + memberNameCount + markedGroupNameCount).map(text) + let detailCount = Int(screen.detailCount) + let allDetails = UnsafeBufferPointer(start: screen.details, count: detailCount + Int(screen.markedDetailCount)).map(text) let authors = UnsafeBufferPointer(start: screen.authors, count: Int(screen.authorCount)).map(text) return Frame( @@ -225,9 +240,14 @@ struct Frame { totalRows: screen.totalRows, selectedRow: screen.selectedRow, loading: screen.loading != 0, - names: Array(allNames.prefix(Int(screen.nameCount))), - groupNames: Array(allNames.dropFirst(Int(screen.nameCount))), - details: details, + nameMarks: screen.nameMarks != 0, + detailMarks: screen.detailMarks != 0, + names: Array(allNames.prefix(nameCount)), + groupNames: Array(allNames.dropFirst(nameCount).prefix(groupNameCount)), + memberNames: Array(allNames.dropFirst(nameCount + groupNameCount).prefix(memberNameCount)), + markedGroupNames: Array(allNames.dropFirst(nameCount + groupNameCount + memberNameCount)), + details: Array(allDetails.prefix(detailCount)), + markedDetails: Array(allDetails.dropFirst(detailCount)), authors: authors, search: text(screen.search), searchTooltip: text(screen.searchTooltip), diff --git a/src/BuildMonitor.Core/Model/BuildsPage.cs b/src/BuildMonitor.Core/Model/BuildsPage.cs index a474b3b..2840b8a 100644 --- a/src/BuildMonitor.Core/Model/BuildsPage.cs +++ b/src/BuildMonitor.Core/Model/BuildsPage.cs @@ -30,4 +30,19 @@ record BuildsPage( string Empty, // Every distinct author shown across all failed builds, to size the author column from. Null or // empty when no failed build names anyone, and then the column is not drawn. - IReadOnlyList? Authors = null); + IReadOnlyList? Authors = null, + // Which of the texts above are drawn wider than the rest, so a head that sizes a column from + // what each cell holds does not give every cell the room of the widest kind: a build's name + // was sized with a member's indent, and "2 passing" with a branch's mark. Each is a subset, of + // Names, GroupNames and Details in turn: the names that are a member's, drawn indented under + // its group, the groups whose row carries the host's mark, and the details that carry the + // branch's mark. Null where a head is given no rows. + IReadOnlyList? MemberNames = null, + IReadOnlyList? MarkedGroupNames = null, + IReadOnlyList? MarkedDetails = null, + // Whether any row, in view or not, has a mark before its name, a mark leading its second cell, + // or a provider to name. A head reserves the room for each on every row once any row has one, + // and read from the rows in view the columns moved as a row with one scrolled in or out. + bool NameMarks = false, + bool DetailMarks = false, + bool Providers = false); diff --git a/src/BuildMonitor.Core/Session/AsciiRenderer.cs b/src/BuildMonitor.Core/Session/AsciiRenderer.cs index 9217ec8..bae8991 100644 --- a/src/BuildMonitor.Core/Session/AsciiRenderer.cs +++ b/src/BuildMonitor.Core/Session/AsciiRenderer.cs @@ -89,18 +89,24 @@ static List BuildsLines(BuildsPage page, int inner) return [page.Loading ? $"{page.Empty}..." : page.Empty]; } - // "[-] " leads a group's name, and a member is indented by as much. The indent is reserved - // wherever the page has a group at all, open or closed, so the column does not shift under - // the rows as one is opened. - var names = page.Names.Select(_ => _.Length).DefaultIfEmpty().Max(); - var groups = page.GroupNames.Select(_ => _.Length + indent).DefaultIfEmpty().Max(); - var longestName = Math.Max(page.GroupNames.Count > 0 ? names + indent : names, groups); - // The details are text alone, so the mark's width is added once any row draws one, as the + // "[-] " leads a group's name, and a member is indented by as much. Each name as the kind + // of row that shows it: indenting every name wherever the page had a group made the column + // wider than anything written in it. A member's blank cell needs no room. + var longestName = page.Names + .Select(_ => _.Length) + .Concat((page.MemberNames ?? []).Where(_ => _.Length > 0).Select(_ => _.Length + indent)) + .Concat(page.GroupNames.Select(_ => _.Length + indent)) + .DefaultIfEmpty() + .Max(); + // The details are text alone, so the mark's width is added to those that carry one, as the // pixel heads add the width of its picture. - var marks = page.Rows.Any(_ => _.Detail.Any(_ => _.Icon.Length > 0)) ? DetailSpan.BranchIconText.Length : 0; - var longestDetail = page.Details.Select(_ => _.Length).DefaultIfEmpty().Max() + marks; + var longestDetail = page.Details + .Select(_ => _.Length) + .Concat((page.MarkedDetails ?? []).Select(_ => _.Length + DetailSpan.BranchIconText.Length)) + .DefaultIfEmpty() + .Max(); var author = Math.Min((page.Authors ?? []).Select(_ => _.Length).DefaultIfEmpty().Max(), maximumAuthor); - var layout = Layout(inner, page.Rows.Any(_ => _.Provider.Length > 0), longestName, longestDetail, author); + var layout = Layout(inner, page.Providers, longestName, longestDetail, author); return page.Rows.Select(_ => RowLine(_, layout)).ToList(); } diff --git a/src/BuildMonitor.Core/Session/PageColumns.cs b/src/BuildMonitor.Core/Session/PageColumns.cs index 7e13fc7..569d831 100644 --- a/src/BuildMonitor.Core/Session/PageColumns.cs +++ b/src/BuildMonitor.Core/Session/PageColumns.cs @@ -16,4 +16,8 @@ sealed record PageColumns( List Names, List GroupNames, List Details, - List AuthorNames); + List AuthorNames, + List MemberNames, + List MarkedGroupNames, + List MarkedDetails, + (bool Name, bool Detail, bool Provider) Marks); diff --git a/src/BuildMonitor.Core/Session/ScreenBuilder.cs b/src/BuildMonitor.Core/Session/ScreenBuilder.cs index 789579a..8a28191 100644 --- a/src/BuildMonitor.Core/Session/ScreenBuilder.cs +++ b/src/BuildMonitor.Core/Session/ScreenBuilder.cs @@ -78,7 +78,7 @@ static Screen BuildsScreen(SessionState state, DateTimeOffset now, ImmutableArra return new( Title, Page.Builds, - new(Header(state, counts), composed, top, rows.Length, selected, counts.Failing, counts.Running, columns.Names, columns.GroupNames, columns.Details, loading, state.Search, SearchTooltip, Empty(state, rows.Length, loading), columns.AuthorNames), + new(Header(state, counts), composed, top, rows.Length, selected, counts.Failing, counts.Running, columns.Names, columns.GroupNames, columns.Details, loading, state.Search, SearchTooltip, Empty(state, rows.Length, loading), columns.AuthorNames, columns.MemberNames, columns.MarkedGroupNames, columns.MarkedDetails, columns.Marks.Name, columns.Marks.Detail, columns.Marks.Provider), null, Buttons(state), status, @@ -116,14 +116,19 @@ static PageColumns Columns(SessionState state, ImmutableArray pi var authors = AuthorNames.Of(shown.Where(_ => _.Status == BuildStatus.Failed).Select(_ => _.Author)); var siblings = SiblingPipelines(shown); var sized = Sized(state, pipelines, projected.Rows); + var (details, markedDetails) = Details(sized, siblings); var columns = new PageColumns( projected, authors, siblings, Names(sized, RowKind.Build), Names(sized, RowKind.Group), - Details(sized, siblings), - authors.Values.Distinct().ToList()); + details, + authors.Values.Distinct().ToList(), + Names(sized, RowKind.Member), + Names(sized, RowKind.Group, marked: true), + markedDetails, + Marks(sized)); lastColumns = columns; return columns; } @@ -197,9 +202,10 @@ .. rows /// /// The distinct first cells a column of that kind is sized from. A member is measured with the /// builds rather than the groups: its cell is drawn like a build's, and under a prefix group it - /// holds a repository name the column has to have room for. + /// holds a repository name the column has to have room for. The members are also given on + /// their own, and the groups that carry a mark, for a head that sizes each cell as it is drawn. /// - static List Names(ImmutableArray rows, RowKind kind) + static List Names(ImmutableArray rows, RowKind kind, bool marked = false) { var names = new List(); var seen = new HashSet().GetAlternateLookup(); @@ -211,6 +217,12 @@ static List Names(ImmutableArray rows, RowKind kind) continue; } + if (marked && + GroupMarkOf(row).Length == 0) + { + continue; + } + // As NameOf says it, without copying a build's name out of its repository's. if (row is {Kind: RowKind.Build, Build: { } build}) { @@ -375,23 +387,72 @@ static string NameIconOf(Row row, ProviderDescriptor descriptor) static bool NamedAfterProject(Build build) => BuildExtensions.ShortRepoName(build.RepoName.AsSpan()).Equals(build.PipelineName, StringComparison.OrdinalIgnoreCase); - static List Details(ImmutableArray rows, IReadOnlyDictionary<(string ConnectionId, string RepoName), List> siblings) + /// + /// The mark before a group's name: the host of the repository its members share, and nothing + /// for a group whose members do not share one. One rule for the composed row and for the + /// groups the column is sized as carrying a mark. + /// + static string GroupMarkOf(Row row) => + RepoHosts.MarkOf(RowTooltips.Shared(row.Members)); + + /// + /// Whether any of the rows has a mark before its name, a mark leading its second cell, or a + /// provider to name, by the rules the composed row is given them. Of every row the columns are + /// sized from rather than of those in view, so the room a head reserves for each does not come + /// and go while scrolling. + /// + static (bool Name, bool Detail, bool Provider) Marks(ImmutableArray rows) + { + var name = false; + var detail = false; + var provider = false; + foreach (var row in rows) + { + if (name && + detail && + provider) + { + break; + } + + if (row.Build is null) + { + name = name || GroupMarkOf(row).Length > 0; + continue; + } + + var descriptor = ProviderDescriptors.Get(row.Connection!.Connection.ProviderId); + provider = true; + name = name || NameIconOf(row, descriptor).Length > 0; + detail = detail || DetailIconOf(row, descriptor).Icon.Length > 0; + } + + return (name, detail, provider); + } + + /// + /// The distinct second cells, and those of them that carry the branch's mark, in one pass over + /// the rows. + /// + static (List Details, List Marked) Details(ImmutableArray rows, IReadOnlyDictionary<(string ConnectionId, string RepoName), List> siblings) { var details = new List(); + var marked = new List(); var seen = new HashSet().GetAlternateLookup(); + var seenMarked = new HashSet(); foreach (var row in rows) { - AddDetail(seen, details, row, siblings); + AddDetail(seen, details, seenMarked, marked, row, siblings); } - return details; + return (details, marked); } /// /// The text gives the row, written on the stack rather than as runs that - /// are then joined. + /// are then joined. Marked where it names a branch, which is the run DetailOf gives the mark. /// - static void AddDetail(HashSet.AlternateLookup seen, List details, Row row, IReadOnlyDictionary<(string ConnectionId, string RepoName), List> siblings) + static void AddDetail(HashSet.AlternateLookup seen, List details, HashSet seenMarked, List marked, Row row, IReadOnlyDictionary<(string ConnectionId, string RepoName), List> siblings) { if (row.Build is null) { @@ -405,6 +466,13 @@ static void AddDetail(HashSet.AlternateLookup seen, List 0 && + seen.TryGetValue(text, out var added) && + seenMarked.Add(added)) + { + marked.Add(added); + } } /// @@ -697,7 +765,7 @@ static BuildRow ComposeGroup(SessionState state, Row row, GroupKey group, bool s // A member's own first cell is blank, so this row is the only place the repository is // named. Without the link a group would hide the repository of every row inside it. repo is null ? ChipKind.None : ChipKind.Repo, - RepoHosts.MarkOf(repo), + GroupMarkOf(row), ChipKind.None, DetailOf(row, siblings), "", diff --git a/src/BuildMonitor.Native/Native/Bm.cs b/src/BuildMonitor.Native/Native/Bm.cs index 8ccb7f0..de31a61 100644 --- a/src/BuildMonitor.Native/Native/Bm.cs +++ b/src/BuildMonitor.Native/Native/Bm.cs @@ -8,7 +8,7 @@ static partial class Bm /// /// Keep in sync with BM_VERSION in bm.h. /// - public const int ExpectedVersion = 19; + public const int ExpectedVersion = 20; /// /// BmInput.ChangedField for an edit of the filter box, which is not one of BmScreen.Fields. diff --git a/src/BuildMonitor.Native/Native/BmStructs.cs b/src/BuildMonitor.Native/Native/BmStructs.cs index 969963b..4d6efd3 100644 --- a/src/BuildMonitor.Native/Native/BmStructs.cs +++ b/src/BuildMonitor.Native/Native/BmStructs.cs @@ -112,11 +112,16 @@ struct BmScreen public int TotalRows; public int SelectedRow; public int Loading; + public int NameMarks; + public int DetailMarks; public BmString* Names; public int NameCount; public int GroupNameCount; + public int MemberNameCount; + public int MarkedGroupNameCount; public BmString* Details; public int DetailCount; + public int MarkedDetailCount; public BmString* Authors; public int AuthorCount; public BmChip* Chips; diff --git a/src/BuildMonitor.Native/Native/ScreenPayload.cs b/src/BuildMonitor.Native/Native/ScreenPayload.cs index 7da9f62..ad06127 100644 --- a/src/BuildMonitor.Native/Native/ScreenPayload.cs +++ b/src/BuildMonitor.Native/Native/ScreenPayload.cs @@ -82,14 +82,24 @@ public void Build(Screen source) screen.Search = Add(builds.Search); screen.SearchTooltip = Add(builds.SearchTooltip); screen.Empty = Add(builds.Empty); + screen.NameMarks = builds.NameMarks ? 1 : 0; + screen.DetailMarks = builds.DetailMarks ? 1 : 0; + var memberNames = builds.MemberNames ?? []; + var markedGroupNames = builds.MarkedGroupNames ?? []; + var markedDetails = builds.MarkedDetails ?? []; screen.NameCount = builds.Names.Count; screen.GroupNameCount = builds.GroupNames.Count; - foreach (var name in builds.Names.Concat(builds.GroupNames)) + screen.MemberNameCount = memberNames.Count; + screen.MarkedGroupNameCount = markedGroupNames.Count; + foreach (var name in builds.Names.Concat(builds.GroupNames).Concat(memberNames).Concat(markedGroupNames)) { names.Add(Add(name)); } - foreach (var detail in builds.Details) + // The marked ones after the rest, as the member and marked group names follow the names. + screen.DetailCount = builds.Details.Count; + screen.MarkedDetailCount = markedDetails.Count; + foreach (var detail in builds.Details.Concat(markedDetails)) { details.Add(Add(detail)); } @@ -284,7 +294,6 @@ int WithPinned(Call call) frame.RowCount = rows.Count; frame.Names = namePointer; frame.Details = detailPointer; - frame.DetailCount = details.Count; frame.Authors = authorPointer; frame.AuthorCount = authors.Count; frame.Chips = chipPointer; @@ -313,7 +322,7 @@ int WithPinned(Call call) public string Describe() { var builder = new StringBuilder(); - builder.AppendLine($"page: {screen.Page} rows: {rows.Count} details: {details.Count} chips: {chips.Count} spans: {spans.Count} fields: {fields.Count} options: {options.Count} buttons: {buttons.Count} menu: {menu.Count} tray: {trayItems.Count} strings: {strings.Count} bytes"); + builder.AppendLine($"page: {screen.Page} rows: {rows.Count} details: {screen.DetailCount} chips: {chips.Count} spans: {spans.Count} fields: {fields.Count} options: {options.Count} buttons: {buttons.Count} menu: {menu.Count} tray: {trayItems.Count} strings: {strings.Count} bytes"); var blob = strings.ToArray(); string Text(BmString value) => Encoding.UTF8.GetString(blob, value.Offset, value.Length); builder.AppendLine($"search='{Text(screen.Search)}' tip='{Text(screen.SearchTooltip)}' empty='{Text(screen.Empty)}'"); diff --git a/src/BuildMonitor.Native/runtimes/linux-arm64/native/libbuildmonitor_ui.so b/src/BuildMonitor.Native/runtimes/linux-arm64/native/libbuildmonitor_ui.so index 36053f9..e782643 100644 Binary files a/src/BuildMonitor.Native/runtimes/linux-arm64/native/libbuildmonitor_ui.so and b/src/BuildMonitor.Native/runtimes/linux-arm64/native/libbuildmonitor_ui.so differ diff --git a/src/BuildMonitor.Native/runtimes/linux-x64/native/libbuildmonitor_ui.so b/src/BuildMonitor.Native/runtimes/linux-x64/native/libbuildmonitor_ui.so index 7f06e02..5e7d792 100644 Binary files a/src/BuildMonitor.Native/runtimes/linux-x64/native/libbuildmonitor_ui.so and b/src/BuildMonitor.Native/runtimes/linux-x64/native/libbuildmonitor_ui.so differ diff --git a/src/BuildMonitor.Native/runtimes/osx-arm64/native/libbuildmonitor_ui.dylib b/src/BuildMonitor.Native/runtimes/osx-arm64/native/libbuildmonitor_ui.dylib index b4ac220..0d7f87a 100644 Binary files a/src/BuildMonitor.Native/runtimes/osx-arm64/native/libbuildmonitor_ui.dylib and b/src/BuildMonitor.Native/runtimes/osx-arm64/native/libbuildmonitor_ui.dylib differ diff --git a/src/BuildMonitor.Native/runtimes/osx-x64/native/libbuildmonitor_ui.dylib b/src/BuildMonitor.Native/runtimes/osx-x64/native/libbuildmonitor_ui.dylib index b4ac220..0d7f87a 100644 Binary files a/src/BuildMonitor.Native/runtimes/osx-x64/native/libbuildmonitor_ui.dylib and b/src/BuildMonitor.Native/runtimes/osx-x64/native/libbuildmonitor_ui.dylib differ diff --git a/src/BuildMonitor.Tests/Native/BmStructTests.Layout.verified.txt b/src/BuildMonitor.Tests/Native/BmStructTests.Layout.verified.txt index e504dc7..e8e448d 100644 --- a/src/BuildMonitor.Tests/Native/BmStructTests.Layout.verified.txt +++ b/src/BuildMonitor.Tests/Native/BmStructTests.Layout.verified.txt @@ -1,6 +1,6 @@ { PointerSize: 8, - Screen: 312, + Screen: 328, Input: 128, ScreenOffsets: [ Strings@0, @@ -16,39 +16,44 @@ TotalRows@64, SelectedRow@68, Loading@72, - Names@80, - NameCount@88, - GroupNameCount@92, - Details@96, - DetailCount@104, - Authors@112, - AuthorCount@120, - Chips@128, - ChipCount@136, - Spans@144, - SpanCount@152, - Tooltips@160, - TooltipCount@168, - Search@172, - SearchTooltip@180, - Empty@188, - FormTitle@196, - Fields@208, - FieldCount@216, - Options@224, - OptionCount@232, - Buttons@240, - ButtonCount@248, - Menu@256, - MenuCount@264, - MenuRow@268, - MenuOverflow@272, - TrayIcon@276, - TrayTooltip@280, - TrayItems@288, - TrayItemCount@296, - Theme@300, - Generation@304 + NameMarks@76, + DetailMarks@80, + Names@88, + NameCount@96, + GroupNameCount@100, + MemberNameCount@104, + MarkedGroupNameCount@108, + Details@112, + DetailCount@120, + MarkedDetailCount@124, + Authors@128, + AuthorCount@136, + Chips@144, + ChipCount@152, + Spans@160, + SpanCount@168, + Tooltips@176, + TooltipCount@184, + Search@188, + SearchTooltip@196, + Empty@204, + FormTitle@212, + Fields@224, + FieldCount@232, + Options@240, + OptionCount@248, + Buttons@256, + ButtonCount@264, + Menu@272, + MenuCount@280, + MenuRow@284, + MenuOverflow@288, + TrayIcon@292, + TrayTooltip@296, + TrayItems@304, + TrayItemCount@312, + Theme@316, + Generation@320 ], InputOffsets: [ Key@0, @@ -99,5 +104,5 @@ Triage=19, OpenMenu=20 ], - Version: 19 + Version: 20 } \ No newline at end of file diff --git a/src/BuildMonitor.Tests/Native/PixelTests.Builds.Linux.verified.png b/src/BuildMonitor.Tests/Native/PixelTests.Builds.Linux.verified.png index bf72084..f81aa25 100644 Binary files a/src/BuildMonitor.Tests/Native/PixelTests.Builds.Linux.verified.png and b/src/BuildMonitor.Tests/Native/PixelTests.Builds.Linux.verified.png differ diff --git a/src/BuildMonitor.Tests/Native/PixelTests.Groups.Linux.verified.png b/src/BuildMonitor.Tests/Native/PixelTests.Groups.Linux.verified.png new file mode 100644 index 0000000..ece4401 Binary files /dev/null and b/src/BuildMonitor.Tests/Native/PixelTests.Groups.Linux.verified.png differ diff --git a/src/BuildMonitor.Tests/Native/PixelTests.Groups.OSX.verified.png b/src/BuildMonitor.Tests/Native/PixelTests.Groups.OSX.verified.png new file mode 100644 index 0000000..0678a28 Binary files /dev/null and b/src/BuildMonitor.Tests/Native/PixelTests.Groups.OSX.verified.png differ diff --git a/src/BuildMonitor.Tests/Native/PixelTests.Lanes.Linux.verified.png b/src/BuildMonitor.Tests/Native/PixelTests.Lanes.Linux.verified.png index 22d253e..f2f3524 100644 Binary files a/src/BuildMonitor.Tests/Native/PixelTests.Lanes.Linux.verified.png and b/src/BuildMonitor.Tests/Native/PixelTests.Lanes.Linux.verified.png differ diff --git a/src/BuildMonitor.Tests/Native/PixelTests.LocalRepos.Linux.verified.png b/src/BuildMonitor.Tests/Native/PixelTests.LocalRepos.Linux.verified.png index 6c7d2ce..3c1cf19 100644 Binary files a/src/BuildMonitor.Tests/Native/PixelTests.LocalRepos.Linux.verified.png and b/src/BuildMonitor.Tests/Native/PixelTests.LocalRepos.Linux.verified.png differ diff --git a/src/BuildMonitor.Tests/Native/PixelTests.PrefixGroup.Linux.verified.png b/src/BuildMonitor.Tests/Native/PixelTests.PrefixGroup.Linux.verified.png new file mode 100644 index 0000000..08abd94 Binary files /dev/null and b/src/BuildMonitor.Tests/Native/PixelTests.PrefixGroup.Linux.verified.png differ diff --git a/src/BuildMonitor.Tests/Native/PixelTests.PrefixGroup.OSX.verified.png b/src/BuildMonitor.Tests/Native/PixelTests.PrefixGroup.OSX.verified.png new file mode 100644 index 0000000..ad6eb08 Binary files /dev/null and b/src/BuildMonitor.Tests/Native/PixelTests.PrefixGroup.OSX.verified.png differ diff --git a/src/BuildMonitor.Tests/Native/PixelTests.cs b/src/BuildMonitor.Tests/Native/PixelTests.cs index 12808e7..6180df7 100644 --- a/src/BuildMonitor.Tests/Native/PixelTests.cs +++ b/src/BuildMonitor.Tests/Native/PixelTests.cs @@ -80,6 +80,23 @@ public Task Connections() => public Task Lanes() => Capture(Fixtures.WithLanes()); + // A closed group with the host's mark, behind its arrow, among builds that are not indented + // for it. The other baselines show no group, so nothing else draws how these heads size and + // place one. + [Test] + [PixelTest] + [NotInParallel(nameof(PixelTests), Order = 8)] + public Task Groups() => + Capture(Fixtures.WithTwoFailures()); + + // A prefix group, open: it names no one repository, so its row has no mark, and its members + // are indented under it, one naming the repository it came from. + [Test] + [PixelTest] + [NotInParallel(nameof(PixelTests), Order = 9)] + public Task PrefixGroup() => + Capture(MonitorSession.ToggleGroup(Fixtures.WithPrefixGroup(), Fixtures.VerifyPassing)); + static async Task Capture(SessionState state) { // Pinned rather than System, so a capture does not depend on the theme of whoever ran it. diff --git a/src/BuildMonitor.Tests/Native/ScreenPayloadTests.Builds.verified.txt b/src/BuildMonitor.Tests/Native/ScreenPayloadTests.Builds.verified.txt index 2f3f7dc..2f106ef 100644 --- a/src/BuildMonitor.Tests/Native/ScreenPayloadTests.Builds.verified.txt +++ b/src/BuildMonitor.Tests/Native/ScreenPayloadTests.Builds.verified.txt @@ -1,4 +1,4 @@ -page: 0 rows: 6 details: 5 chips: 7 spans: 12 fields: 0 options: 0 buttons: 5 menu: 0 tray: 9 strings: 2276 bytes +page: 0 rows: 6 details: 5 chips: 7 spans: 12 fields: 0 options: 0 buttons: 5 menu: 0 tray: 9 strings: 2339 bytes search='' tip='Filter by repository, pipeline or branch' empty='' status='Polled 5s ago' tip='' row status=1 flags=0 progress=0.20 'build-all' link=Build status=Build 'Build all main' spans=Pipeline:[]'Build all',None:[]' ',None:[branch]'main' icon='provider-jenkins-run' detail=None:'' '04:00 left' author='' chips=Cancel:Cancel[cancel|] diff --git a/src/BuildMonitor.Tests/Native/ScreenPayloadTests.Lanes.verified.txt b/src/BuildMonitor.Tests/Native/ScreenPayloadTests.Lanes.verified.txt index 892ed8d..fe2a548 100644 --- a/src/BuildMonitor.Tests/Native/ScreenPayloadTests.Lanes.verified.txt +++ b/src/BuildMonitor.Tests/Native/ScreenPayloadTests.Lanes.verified.txt @@ -1,4 +1,4 @@ -page: 0 rows: 9 details: 7 chips: 11 spans: 21 fields: 0 options: 0 buttons: 5 menu: 0 tray: 9 strings: 3421 bytes +page: 0 rows: 9 details: 7 chips: 11 spans: 21 fields: 0 options: 0 buttons: 5 menu: 0 tray: 9 strings: 3533 bytes search='' tip='Filter by repository, pipeline or branch' empty='' status='Polled 5s ago' tip='' row status=1 flags=0 progress=0.20 'build-all' link=Build status=Build 'Build all main' spans=Pipeline:[]'Build all',None:[]' ',None:[branch]'main' icon='provider-jenkins-run' detail=None:'' '04:00 left' author='' chips=Cancel:Cancel[cancel|] diff --git a/src/BuildMonitor.Tests/ScreenTests.AMembersPipelineDropsTheGroupsName.verified.txt b/src/BuildMonitor.Tests/ScreenTests.AMembersPipelineDropsTheGroupsName.verified.txt index 9d48132..75f2b74 100644 --- a/src/BuildMonitor.Tests/ScreenTests.AMembersPipelineDropsTheGroupsName.verified.txt +++ b/src/BuildMonitor.Tests/ScreenTests.AMembersPipelineDropsTheGroupsName.verified.txt @@ -1,17 +1,17 @@ +----------------------------------------------------------------------------------------------------------------------+ | BuildMonitor 10 pipelines, 1 failing, 4 running Filter: [ ] | +----------------------------------------------------------------------------------------------------------------------+ -| > build-all jenkins Build all @main [##------] 04:00 left [Cancel] | -| > Deploy Web octopus [###-----] 02:15 left [Cancel] | -| > DiffEngine github test.yml @main [####----] 03:00 left [Cancel] | -| ? nightly jenkins queued 30s [Cancel] | -| x Verify github test.yml @feature/inline 25m ago SimonCropp PR 42 [Retry] [Log] | -| > + [-] Verify 4 passing 2h ago | -| + github docs.yml @main 2h ago | -| + github nuget.yml @main 4h ago | -| + Xunit github test.yml @main 3h ago | -| + github Security @main 5h ago | -| + DiffEngine github docs.yml @main 23h ago | +| > build-all jenkins Build all @main [##------] 04:00 left [Cancel] | +| > Deploy Web octopus [###-----] 02:15 left [Cancel] | +| > DiffEngine github test.yml @main [####----] 03:00 left [Cancel] | +| ? nightly jenkins queued 30s [Cancel] | +| x Verify github test.yml @feature/inline 25m ago SimonCropp PR 42 [Retry] [Log] | +| > + [-] Verify 4 passing 2h ago | +| + github docs.yml @main 2h ago | +| + github nuget.yml @main 4h ago | +| + Xunit github test.yml @main 3h ago | +| + github Security @main 5h ago | +| + DiffEngine github docs.yml @main 23h ago | | | | | | | diff --git a/src/BuildMonitor.Tests/ScreenTests.APassingPipelineGroupsWhileItsPullRequestRuns.verified.txt b/src/BuildMonitor.Tests/ScreenTests.APassingPipelineGroupsWhileItsPullRequestRuns.verified.txt index ecdc99e..c120bcc 100644 --- a/src/BuildMonitor.Tests/ScreenTests.APassingPipelineGroupsWhileItsPullRequestRuns.verified.txt +++ b/src/BuildMonitor.Tests/ScreenTests.APassingPipelineGroupsWhileItsPullRequestRuns.verified.txt @@ -1,14 +1,14 @@ +----------------------------------------------------------------------------------------------------------------------+ | BuildMonitor 9 pipelines, 0 failing, 5 running Filter: [ ] | +----------------------------------------------------------------------------------------------------------------------+ -| > build-all jenkins Build all @main [##------] 04:00 left [Cancel] | -| > Deploy Web octopus [###-----] 02:15 left [Cancel] | -| > > DiffEngine github test.yml @main [####----] 03:00 left [Cancel] | -| > Verify github nuget.yml @feature/pack 04:00 PR 46 [Cancel] | -| ? nightly jenkins queued 30s [Cancel] | -| x Verify github test.yml @feature/inline 25m ago SimonCropp PR 42 [Retry] [Log] | -| + [+] Verify 4 passing 1h ago | -| + DiffEngine github docs.yml @main 23h ago | +| > build-all jenkins Build all @main [##------] 04:00 left [Cancel] | +| > Deploy Web octopus [###-----] 02:15 left [Cancel] | +| > > DiffEngine github test.yml @main [####----] 03:00 left [Cancel] | +| > Verify github nuget.yml @feature/pack 04:00 PR 46 [Cancel] | +| ? nightly jenkins queued 30s [Cancel] | +| x Verify github test.yml @feature/inline 25m ago SimonCropp PR 42 [Retry] [Log] | +| + [+] Verify 4 passing 1h ago | +| + DiffEngine github docs.yml @main 23h ago | | | | | | | diff --git a/src/BuildMonitor.Tests/ScreenTests.AProjectsNameStaysWhereItTellsPipelinesApart.verified.txt b/src/BuildMonitor.Tests/ScreenTests.AProjectsNameStaysWhereItTellsPipelinesApart.verified.txt index 647a5fc..e1fdee1 100644 --- a/src/BuildMonitor.Tests/ScreenTests.AProjectsNameStaysWhereItTellsPipelinesApart.verified.txt +++ b/src/BuildMonitor.Tests/ScreenTests.AProjectsNameStaysWhereItTellsPipelinesApart.verified.txt @@ -1,18 +1,18 @@ +----------------------------------------------------------------------------------------------------------------------+ | BuildMonitor 11 pipelines, 1 failing, 4 running Filter: [ ] | +----------------------------------------------------------------------------------------------------------------------+ -| > build-all jenkins Build all @main [##------] 04:00 left [Cancel] | -| > Deploy Web octopus [###-----] 02:15 left [Cancel] | -| > DiffEngine github test.yml @main [####----] 03:00 left [Cancel] | -| ? nightly jenkins queued 30s [Cancel] | -| x Verify github test.yml @feature/inline 25m ago SimonCropp PR 42 [Retry] [Log] | -| > + [-] Verify 5 passing 2h ago | -| + github docs.yml @main 2h ago | -| + github nuget.yml @main 4h ago | -| + Xunit github test.yml @main 3h ago | -| + github Xunit - Security @main 5h ago | -| + github Security @main 6h ago | -| + DiffEngine github docs.yml @main 23h ago | +| > build-all jenkins Build all @main [##------] 04:00 left [Cancel] | +| > Deploy Web octopus [###-----] 02:15 left [Cancel] | +| > DiffEngine github test.yml @main [####----] 03:00 left [Cancel] | +| ? nightly jenkins queued 30s [Cancel] | +| x Verify github test.yml @feature/inline 25m ago SimonCropp PR 42 [Retry] [Log] | +| > + [-] Verify 5 passing 2h ago | +| + github docs.yml @main 2h ago | +| + github nuget.yml @main 4h ago | +| + Xunit github test.yml @main 3h ago | +| + github Xunit - Security @main 5h ago | +| + github Security @main 6h ago | +| + DiffEngine github docs.yml @main 23h ago | | | | | | | diff --git a/src/BuildMonitor.Tests/ScreenTests.ARepositoryIsNamedOnceInAPrefixGroup.verified.txt b/src/BuildMonitor.Tests/ScreenTests.ARepositoryIsNamedOnceInAPrefixGroup.verified.txt index 5ad1e1d..e1de8ca 100644 --- a/src/BuildMonitor.Tests/ScreenTests.ARepositoryIsNamedOnceInAPrefixGroup.verified.txt +++ b/src/BuildMonitor.Tests/ScreenTests.ARepositoryIsNamedOnceInAPrefixGroup.verified.txt @@ -1,17 +1,17 @@ +----------------------------------------------------------------------------------------------------------------------+ | BuildMonitor 10 pipelines, 1 failing, 4 running Filter: [ ] | +----------------------------------------------------------------------------------------------------------------------+ -| > build-all jenkins Build all @main [##------] 04:00 left [Cancel] | -| > Deploy Web octopus [###-----] 02:15 left [Cancel] | -| > DiffEngine github test.yml @main [####----] 03:00 left [Cancel] | -| ? nightly jenkins queued 30s [Cancel] | -| x Verify github test.yml @feature/inline 25m ago SimonCropp PR 42 [Retry] [Log] | -| > + [-] Verify 4 passing 2h ago | -| + github docs.yml @main 2h ago | -| + github nuget.yml @main 4h ago | -| + Xunit github test.yml @main 3h ago | -| + github docs.yml @main 5h ago | -| + DiffEngine github docs.yml @main 23h ago | +| > build-all jenkins Build all @main [##------] 04:00 left [Cancel] | +| > Deploy Web octopus [###-----] 02:15 left [Cancel] | +| > DiffEngine github test.yml @main [####----] 03:00 left [Cancel] | +| ? nightly jenkins queued 30s [Cancel] | +| x Verify github test.yml @feature/inline 25m ago SimonCropp PR 42 [Retry] [Log] | +| > + [-] Verify 4 passing 2h ago | +| + github docs.yml @main 2h ago | +| + github nuget.yml @main 4h ago | +| + Xunit github test.yml @main 3h ago | +| + github docs.yml @main 5h ago | +| + DiffEngine github docs.yml @main 23h ago | | | | | | | diff --git a/src/BuildMonitor.Tests/ScreenTests.GreenGroup.verified.txt b/src/BuildMonitor.Tests/ScreenTests.GreenGroup.verified.txt index 353f69b..6c5e378 100644 --- a/src/BuildMonitor.Tests/ScreenTests.GreenGroup.verified.txt +++ b/src/BuildMonitor.Tests/ScreenTests.GreenGroup.verified.txt @@ -1,13 +1,13 @@ +----------------------------------------------------------------------------------------------------------------------+ | BuildMonitor 8 pipelines, 1 failing, 4 running Filter: [ ] | +----------------------------------------------------------------------------------------------------------------------+ -| > build-all jenkins Build all @main [##------] 04:00 left [Cancel] | -| > Deploy Web octopus [###-----] 02:15 left [Cancel] | -| > > DiffEngine github test.yml @main [####----] 03:00 left [Cancel] | -| ? nightly jenkins queued 30s [Cancel] | -| x Verify github test.yml @feature/inline 25m ago SimonCropp PR 42 [Retry] [Log] | -| + [+] Verify 2 passing 2h ago | -| + DiffEngine github docs.yml @main 23h ago | +| > build-all jenkins Build all @main [##------] 04:00 left [Cancel] | +| > Deploy Web octopus [###-----] 02:15 left [Cancel] | +| > > DiffEngine github test.yml @main [####----] 03:00 left [Cancel] | +| ? nightly jenkins queued 30s [Cancel] | +| x Verify github test.yml @feature/inline 25m ago SimonCropp PR 42 [Retry] [Log] | +| + [+] Verify 2 passing 2h ago | +| + DiffEngine github docs.yml @main 23h ago | | | | | | | diff --git a/src/BuildMonitor.Tests/ScreenTests.GreenGroupExpanded.verified.txt b/src/BuildMonitor.Tests/ScreenTests.GreenGroupExpanded.verified.txt index 1461370..2d1efd3 100644 --- a/src/BuildMonitor.Tests/ScreenTests.GreenGroupExpanded.verified.txt +++ b/src/BuildMonitor.Tests/ScreenTests.GreenGroupExpanded.verified.txt @@ -1,15 +1,15 @@ +----------------------------------------------------------------------------------------------------------------------+ | BuildMonitor 8 pipelines, 1 failing, 4 running Filter: [ ] | +----------------------------------------------------------------------------------------------------------------------+ -| > build-all jenkins Build all @main [##------] 04:00 left [Cancel] | -| > Deploy Web octopus [###-----] 02:15 left [Cancel] | -| > DiffEngine github test.yml @main [####----] 03:00 left [Cancel] | -| ? nightly jenkins queued 30s [Cancel] | -| x Verify github test.yml @feature/inline 25m ago SimonCropp PR 42 [Retry] [Log] | -| > + [-] Verify 2 passing 2h ago | -| + github docs.yml @main 2h ago | -| + github nuget.yml @main 4h ago | -| + DiffEngine github docs.yml @main 23h ago | +| > build-all jenkins Build all @main [##------] 04:00 left [Cancel] | +| > Deploy Web octopus [###-----] 02:15 left [Cancel] | +| > DiffEngine github test.yml @main [####----] 03:00 left [Cancel] | +| ? nightly jenkins queued 30s [Cancel] | +| x Verify github test.yml @feature/inline 25m ago SimonCropp PR 42 [Retry] [Log] | +| > + [-] Verify 2 passing 2h ago | +| + github docs.yml @main 2h ago | +| + github nuget.yml @main 4h ago | +| + DiffEngine github docs.yml @main 23h ago | | | | | | | diff --git a/src/BuildMonitor.Tests/ScreenTests.GreenGroupMenuOpen.verified.txt b/src/BuildMonitor.Tests/ScreenTests.GreenGroupMenuOpen.verified.txt index 91d397b..93db20f 100644 --- a/src/BuildMonitor.Tests/ScreenTests.GreenGroupMenuOpen.verified.txt +++ b/src/BuildMonitor.Tests/ScreenTests.GreenGroupMenuOpen.verified.txt @@ -1,13 +1,13 @@ +----------------------------------------------------------------------------------------------------------------------+ | BuildMonitor 8 pipelines, 1 failing, 4 running Filter: [ ] | +----------------------------------------------------------------------------------------------------------------------+ -| > build-all jenkins Build all @main [##------] 04:00 left [Cancel] | -| > Deploy Web octopus [###-----] 02:15 left [Cancel] | -| > DiffEngine github test.yml @main [####----] 03:00 left [Cancel] | -| ? nightly jenkins queued 30s [Cancel] | -| x Verify github test.yml @feature/inline 25m ago SimonCropp PR 42 [Retry] [Log] | -| > + [+] Verify 2 passing 2h ago | -| +----------------------------------+main 23h ago | +| > build-all jenkins Build all @main [##------] 04:00 left [Cancel] | +| > Deploy Web octopus [###-----] 02:15 left [Cancel] | +| > DiffEngine github test.yml @main [####----] 03:00 left [Cancel] | +| ? nightly jenkins queued 30s [Cancel] | +| x Verify github test.yml @feature/inline 25m ago SimonCropp PR 42 [Retry] [Log] | +| > + [+] Verify 2 passing 2h ago | +| +----------------------------------+ 23h ago | | | Expand | | | | Refresh | | | | Group by org: VerifyTests | | diff --git a/src/BuildMonitor.Tests/ScreenTests.GroupMemberMenuOpen.verified.txt b/src/BuildMonitor.Tests/ScreenTests.GroupMemberMenuOpen.verified.txt index 2446032..2902d48 100644 --- a/src/BuildMonitor.Tests/ScreenTests.GroupMemberMenuOpen.verified.txt +++ b/src/BuildMonitor.Tests/ScreenTests.GroupMemberMenuOpen.verified.txt @@ -1,15 +1,15 @@ +----------------------------------------------------------------------------------------------------------------------+ | BuildMonitor 8 pipelines, 1 failing, 4 running Filter: [ ] | +----------------------------------------------------------------------------------------------------------------------+ -| > build-all jenkins Build all @main [##------] 04:00 left [Cancel] | -| > Deploy Web octopus [###-----] 02:15 left [Cancel] | -| > DiffEngine github test.yml @main [####----] 03:00 left [Cancel] | -| ? nightly jenkins queued 30s [Cancel] | -| x Verify github test.yml @feature/inline 25m ago SimonCropp PR 42 [Retry] [Log] | -| + [-] Verify 2 passing 2h ago | -| > + github docs.yml @main 2h ago | -| +----------------------------------+@main 4h ago | -| | Open build |main 23h ago | +| > build-all jenkins Build all @main [##------] 04:00 left [Cancel] | +| > Deploy Web octopus [###-----] 02:15 left [Cancel] | +| > DiffEngine github test.yml @main [####----] 03:00 left [Cancel] | +| ? nightly jenkins queued 30s [Cancel] | +| x Verify github test.yml @feature/inline 25m ago SimonCropp PR 42 [Retry] [Log] | +| + [-] Verify 2 passing 2h ago | +| > + github docs.yml @main 2h ago | +| +----------------------------------+n 4h ago | +| | Open build | 23h ago | | | Open branch | | | | Copy build URL | | | |----------------------------------| | diff --git a/src/BuildMonitor.Tests/ScreenTests.PrefixGroup.verified.txt b/src/BuildMonitor.Tests/ScreenTests.PrefixGroup.verified.txt index 4cfcb5b..48bc5a2 100644 --- a/src/BuildMonitor.Tests/ScreenTests.PrefixGroup.verified.txt +++ b/src/BuildMonitor.Tests/ScreenTests.PrefixGroup.verified.txt @@ -1,13 +1,13 @@ +----------------------------------------------------------------------------------------------------------------------+ | BuildMonitor 9 pipelines, 1 failing, 4 running Filter: [ ] | +----------------------------------------------------------------------------------------------------------------------+ -| > build-all jenkins Build all @main [##------] 04:00 left [Cancel] | -| > Deploy Web octopus [###-----] 02:15 left [Cancel] | -| > > DiffEngine github test.yml @main [####----] 03:00 left [Cancel] | -| ? nightly jenkins queued 30s [Cancel] | -| x Verify github test.yml @feature/inline 25m ago SimonCropp PR 42 [Retry] [Log] | -| + [+] Verify 3 passing 2h ago | -| + DiffEngine github docs.yml @main 23h ago | +| > build-all jenkins Build all @main [##------] 04:00 left [Cancel] | +| > Deploy Web octopus [###-----] 02:15 left [Cancel] | +| > > DiffEngine github test.yml @main [####----] 03:00 left [Cancel] | +| ? nightly jenkins queued 30s [Cancel] | +| x Verify github test.yml @feature/inline 25m ago SimonCropp PR 42 [Retry] [Log] | +| + [+] Verify 3 passing 2h ago | +| + DiffEngine github docs.yml @main 23h ago | | | | | | | diff --git a/src/BuildMonitor.Tests/ScreenTests.PrefixGroupExpanded.verified.txt b/src/BuildMonitor.Tests/ScreenTests.PrefixGroupExpanded.verified.txt index 5672af8..2c1c762 100644 --- a/src/BuildMonitor.Tests/ScreenTests.PrefixGroupExpanded.verified.txt +++ b/src/BuildMonitor.Tests/ScreenTests.PrefixGroupExpanded.verified.txt @@ -1,16 +1,16 @@ +----------------------------------------------------------------------------------------------------------------------+ | BuildMonitor 9 pipelines, 1 failing, 4 running Filter: [ ] | +----------------------------------------------------------------------------------------------------------------------+ -| > build-all jenkins Build all @main [##------] 04:00 left [Cancel] | -| > Deploy Web octopus [###-----] 02:15 left [Cancel] | -| > DiffEngine github test.yml @main [####----] 03:00 left [Cancel] | -| ? nightly jenkins queued 30s [Cancel] | -| x Verify github test.yml @feature/inline 25m ago SimonCropp PR 42 [Retry] [Log] | -| > + [-] Verify 3 passing 2h ago | -| + github docs.yml @main 2h ago | -| + github nuget.yml @main 4h ago | -| + Xunit github test.yml @main 3h ago | -| + DiffEngine github docs.yml @main 23h ago | +| > build-all jenkins Build all @main [##------] 04:00 left [Cancel] | +| > Deploy Web octopus [###-----] 02:15 left [Cancel] | +| > DiffEngine github test.yml @main [####----] 03:00 left [Cancel] | +| ? nightly jenkins queued 30s [Cancel] | +| x Verify github test.yml @feature/inline 25m ago SimonCropp PR 42 [Retry] [Log] | +| > + [-] Verify 3 passing 2h ago | +| + github docs.yml @main 2h ago | +| + github nuget.yml @main 4h ago | +| + Xunit github test.yml @main 3h ago | +| + DiffEngine github docs.yml @main 23h ago | | | | | | | diff --git a/src/BuildMonitor.Tests/ScreenTests.PrefixMenuOpen.verified.txt b/src/BuildMonitor.Tests/ScreenTests.PrefixMenuOpen.verified.txt index 28dbe80..d1fcd3d 100644 --- a/src/BuildMonitor.Tests/ScreenTests.PrefixMenuOpen.verified.txt +++ b/src/BuildMonitor.Tests/ScreenTests.PrefixMenuOpen.verified.txt @@ -1,14 +1,14 @@ +----------------------------------------------------------------------------------------------------------------------+ | BuildMonitor 9 pipelines, 1 failing, 4 running Filter: [ ] | +----------------------------------------------------------------------------------------------------------------------+ -| > build-all jenkins Build all @main [##------] 04:00 left [Cancel] | -| > Deploy Web octopus [###-----] 02:15 left [Cancel] | -| > DiffEngine github test.yml @main [####----] 03:00 left [Cancel] | -| ? nightly jenkins queued 30s [Cancel] | -| x Verify github test.yml @feature/inline 25m ago SimonCropp PR 42 [Retry] [Log] | -| + [+] Verify 2 passing 2h ago | -| > + VerifyXunit github test.yml @main 3h ago | -| +---------------------------------------+ 23h ago | +| > build-all jenkins Build all @main [##------] 04:00 left [Cancel] | +| > Deploy Web octopus [###-----] 02:15 left [Cancel] | +| > DiffEngine github test.yml @main [####----] 03:00 left [Cancel] | +| ? nightly jenkins queued 30s [Cancel] | +| x Verify github test.yml @feature/inline 25m ago SimonCropp PR 42 [Retry] [Log] | +| + [+] Verify 2 passing 2h ago | +| > + VerifyXunit github test.yml @main 3h ago | +| +---------------------------------------+ 23h ago | | | Open build | | | | Open branch | | | | Copy build URL | | diff --git a/src/BuildMonitor.Tests/ScreenTests.Searched.verified.txt b/src/BuildMonitor.Tests/ScreenTests.Searched.verified.txt index 51dfb29..4116b62 100644 --- a/src/BuildMonitor.Tests/ScreenTests.Searched.verified.txt +++ b/src/BuildMonitor.Tests/ScreenTests.Searched.verified.txt @@ -1,8 +1,8 @@ +----------------------------------------------------------------------------------------------------------------------+ | BuildMonitor 8 pipelines, 1 failing, 4 running Filter: [docs ][x] | +----------------------------------------------------------------------------------------------------------------------+ -| > + Verify github docs.yml @main 2h ago | -| + DiffEngine github docs.yml @main 23h ago | +| > + Verify github docs.yml @main 2h ago | +| + DiffEngine github docs.yml @main 23h ago | | | | | | | diff --git a/src/BuildMonitor.Tests/ScreenTests.TwoFailures.verified.txt b/src/BuildMonitor.Tests/ScreenTests.TwoFailures.verified.txt index c08dd6c..10d60f4 100644 --- a/src/BuildMonitor.Tests/ScreenTests.TwoFailures.verified.txt +++ b/src/BuildMonitor.Tests/ScreenTests.TwoFailures.verified.txt @@ -1,14 +1,14 @@ +----------------------------------------------------------------------------------------------------------------------+ | BuildMonitor 9 pipelines, 2 failing, 4 running Filter: [ ] | +----------------------------------------------------------------------------------------------------------------------+ -| > build-all jenkins Build all @main [##------] 04:00 left [Cancel] | -| > Deploy Web octopus [###-----] 02:15 left [Cancel] | -| > > DiffEngine github test.yml @main [####----] 03:00 left [Cancel] | -| ? nightly jenkins queued 30s [Cancel] | -| x Verify github test.yml @feature/inline 25m ago SimonCropp PR 42 [Retry] [Log] | -| x Verify github release.yml @main 50m ago [Retry] [Log] | -| + [+] Verify 2 passing 2h ago | -| + DiffEngine github docs.yml @main 23h ago | +| > build-all jenkins Build all @main [##------] 04:00 left [Cancel] | +| > Deploy Web octopus [###-----] 02:15 left [Cancel] | +| > > DiffEngine github test.yml @main [####----] 03:00 left [Cancel] | +| ? nightly jenkins queued 30s [Cancel] | +| x Verify github test.yml @feature/inline 25m ago SimonCropp PR 42 [Retry] [Log] | +| x Verify github release.yml @main 50m ago [Retry] [Log] | +| + [+] Verify 2 passing 2h ago | +| + DiffEngine github docs.yml @main 23h ago | | | | | | | diff --git a/src/BuildMonitor.Tests/ScreenTests.cs b/src/BuildMonitor.Tests/ScreenTests.cs index deafac3..c394e86 100644 --- a/src/BuildMonitor.Tests/ScreenTests.cs +++ b/src/BuildMonitor.Tests/ScreenTests.cs @@ -262,12 +262,12 @@ public Task Scrolled() => +----------------------------------------------------------------------------------------------------------------------+ | BuildMonitor 9 pipelines, 2 failing, 4 running Filter: [ ] | +----------------------------------------------------------------------------------------------------------------------+ - | > DiffEngine github test.yml @main [####----] 03:00 left [Cancel] | - | ? nightly jenkins queued 30s [Cancel] | - | x Verify github test.yml @feature/inline 25m ago SimonCropp PR 42 [Retry] [Log] | - | x Verify github release.yml @main 50m ago [Retry] [Log] | - | + [+] Verify 2 passing 2h ago | - | > + DiffEngine github docs.yml @main 23h ago | + | > DiffEngine github test.yml @main [####----] 03:00 left [Cancel] | + | ? nightly jenkins queued 30s [Cancel] | + | x Verify github test.yml @feature/inline 25m ago SimonCropp PR 42 [Retry] [Log] | + | x Verify github release.yml @main 50m ago [Retry] [Log] | + | + [+] Verify 2 passing 2h ago | + | > + DiffEngine github docs.yml @main 23h ago | +----------------------------------------------------------------------------------------------------------------------+ | [Refresh] [Connections] [Options] [Filters] [Hide] Polled 5s ago | +----------------------------------------------------------------------------------------------------------------------+ diff --git a/src/BuildMonitor.Windows.Tests/MonitorFormTests.Groups.verified.png b/src/BuildMonitor.Windows.Tests/MonitorFormTests.Groups.verified.png index 368821e..77d401f 100644 Binary files a/src/BuildMonitor.Windows.Tests/MonitorFormTests.Groups.verified.png and b/src/BuildMonitor.Windows.Tests/MonitorFormTests.Groups.verified.png differ diff --git a/src/BuildMonitor.Windows.Tests/MonitorFormTests.Narrow.verified.png b/src/BuildMonitor.Windows.Tests/MonitorFormTests.Narrow.verified.png index 2392357..756b0f1 100644 Binary files a/src/BuildMonitor.Windows.Tests/MonitorFormTests.Narrow.verified.png and b/src/BuildMonitor.Windows.Tests/MonitorFormTests.Narrow.verified.png differ diff --git a/src/BuildMonitor.Windows.Tests/MonitorFormTests.PrefixGroup.verified.png b/src/BuildMonitor.Windows.Tests/MonitorFormTests.PrefixGroup.verified.png index ab6ae98..c161093 100644 Binary files a/src/BuildMonitor.Windows.Tests/MonitorFormTests.PrefixGroup.verified.png and b/src/BuildMonitor.Windows.Tests/MonitorFormTests.PrefixGroup.verified.png differ diff --git a/src/BuildMonitor.Windows.Tests/MonitorFormTests.Searched.verified.png b/src/BuildMonitor.Windows.Tests/MonitorFormTests.Searched.verified.png index 6d3516f..b106795 100644 Binary files a/src/BuildMonitor.Windows.Tests/MonitorFormTests.Searched.verified.png and b/src/BuildMonitor.Windows.Tests/MonitorFormTests.Searched.verified.png differ diff --git a/src/BuildMonitor.Windows.Tests/RowsCanvasTests.cs b/src/BuildMonitor.Windows.Tests/RowsCanvasTests.cs index 75789c1..247eeb5 100644 --- a/src/BuildMonitor.Windows.Tests/RowsCanvasTests.cs +++ b/src/BuildMonitor.Windows.Tests/RowsCanvasTests.cs @@ -256,6 +256,239 @@ public async Task AGroupsArrowSelectsTheRowRatherThanOpeningTheRepository() await Assert.That(opened.ClickedChipRow).IsEqualTo(row); } + /// + /// The name column is as wide as its widest name, so the group with that name is the one row + /// with no slack: sized as one text and drawn as two, arrow then name, it was cut short with an + /// ellipsis beside an empty column. The link is as wide as what was drawn of the name, and the + /// mark's share of it is read from a row with room to spare, because both depend on the fonts + /// of the machine running the test. + /// + [Test] + public async Task TheWidestGroupNameIsDrawnInFull() + { + const string name = "FluentDateTimeOffset"; + const string repo = $"VerifyTests/{name}"; + var state = MonitorSession.ApplyPoll( + Fixtures.WithBuilds(), + Fixtures.GitHub.Id, + [ + ..Fixtures.GitHubPipelines, + new($"{name}/test.yml", "test.yml", repo, repo, $"https://github.com/{repo}"), + new($"{name}/docs.yml", "docs.yml", repo, repo, $"https://github.com/{repo}") + ], + [ + ..Fixtures.GitHubBuilds(), + Passing(name, "test.yml", "7"), + Passing(name, "docs.yml", "8") + ], + Fixtures.Now - TimeSpan.FromSeconds(12)); + using var canvas = Drawn(1000, state); + using var bold = new Font(canvas.Font, FontStyle.Bold); + var group = Fixtures.RowOf(state, _ => _.Kind == RowKind.Group); + var settled = Fixtures.RowOf(state, _ => _.Build?.Key == "gh/DiffEngine/docs.yml/main"); + + var mark = LinkWidth(canvas, settled, ChipKind.Repo) - TextRenderer.MeasureText("DiffEngine", canvas.Font, Size.Empty, TextFormatFlags.NoPrefix).Width; + var text = TextRenderer.MeasureText(name, bold, Size.Empty, TextFormatFlags.NoPrefix).Width; + await Assert.That(LinkWidth(canvas, group, ChipKind.Repo)).IsEqualTo(mark + text); + } + + /// + /// Only a member is drawn indented, so a group on the page moves no build's second cell: every + /// name sized as a member's, the column was an indent wider than anything drawn in it wherever + /// a build's name was the widest. + /// + [Test] + public async Task AGroupOnThePageDoesNotIndentTheBuilds() + { + const string key = "gh/Verify/test.yml/feature/inline"; + var alone = Fixtures.WithBuilds(); + var grouped = Fixtures.WithTwoFailures(); + using var aloneCanvas = Drawn(1000, alone); + using var groupedCanvas = Drawn(1000, grouped); + var expected = LinkSpan(aloneCanvas, Fixtures.RowOf(alone, _ => _.Build?.Key == key), ChipKind.Branch).Left; + var actual = LinkSpan(groupedCanvas, Fixtures.RowOf(grouped, _ => _.Build?.Key == key), ChipKind.Branch).Left; + await Assert.That(actual).IsEqualTo(expected); + } + + /// + /// The room for the mark leading the second cell is reserved while any row of the page has + /// one, in view or not. Read from the rows in view, a list scrolled to rows without one closed + /// the room up, and every column after it moved as a row with a mark came back. + /// + [Test] + public async Task ARowKeepsItsPlaceWhenTheRowsWithMarksAreOutOfView() + { + var page = ScreenBuilder.Build(Fixtures.WithBuilds(), Fixtures.Now).Builds!; + await Assert.That(page.Rows[0].DetailIcon).IsEmpty(); + using var whole = Drawn(1000); + using var scrolled = new RowsCanvas + { + Size = new(1000, 400) + }; + scrolled.Apply(page with { Rows = [page.Rows[0]] }, null); + using var bitmap = new Bitmap(1000, 400); + scrolled.DrawToBitmap(bitmap, new(0, 0, 1000, 400)); + + await Assert.That(LinkSpan(scrolled, 0, ChipKind.Pipeline)).IsEqualTo(LinkSpan(whole, 0, ChipKind.Pipeline)); + } + + static Build Passing(string name, string workflow, string run) => + Fixtures.Build( + Fixtures.GitHub.Id, + $"{name}/{workflow}", + workflow, + $"VerifyTests/{name}", + "main", + run, + BuildStatus.Succeeded, + started: Fixtures.Now - TimeSpan.FromHours(3), + finished: Fixtures.Now - TimeSpan.FromHours(3) + TimeSpan.FromMinutes(2), + branchUrl: $"https://github.com/VerifyTests/{name}/tree/main"); + + /// + /// While the chips are giving way the detail column is exactly as wide as it was sized, so the + /// row with the widest detail has no slack, as the widest name has none in its column. Compared + /// as pixels with the same row drawn with room to spare, because an ellipsis moves no link, and + /// at a width found by narrowing the canvas, because where the chips start to give way depends + /// on the fonts of the machine running the test. + /// + [Test] + public async Task TheWidestDetailIsDrawnInFullWhileTheChipsGiveWay() + { + var state = Fixtures.WithBuilds(); + var row = FailedRow(); + var page = ScreenBuilder.Build(state, Fixtures.Now).Builds!; + using var roomy = Drawn(1000, state); + var widest = page.Details.MaxBy(_ => TextRenderer.MeasureText(_, roomy.Font, Size.Empty, TextFormatFlags.NoPadding | TextFormatFlags.NoPrefix).Width); + await Assert.That(string.Concat(page.Rows[row].Detail.Select(_ => _.Text))).IsEqualTo(widest); + + using var squeezed = Squeezed(state, row, page.Rows[row].Chips[0].Kind); + var pipeline = LinkSpan(roomy, row, ChipKind.Pipeline); + var branch = LinkSpan(roomy, row, ChipKind.Branch); + await Assert.That(LinkSpan(squeezed, row, ChipKind.Branch)).IsEqualTo(branch); + + var strip = new Rectangle(pipeline.Left, roomy.RowHeight * row, branch.Right - pipeline.Left + 1, roomy.RowHeight); + await Assert.That(Pixels(squeezed, strip).SequenceEqual(Pixels(roomy, strip))).IsTrue(); + } + + /// + /// A run's link is as wide as the run was measured, and the next run starts where it ends. + /// Measured without the flag it is drawn with, an ampersand was read as a mnemonic and left out + /// of the width, so the link stopped short and the branch was drawn over the pipeline's end. + /// + [Test] + public async Task ARunWithAnAmpersandIsMeasuredAsItIsDrawn() + { + const string pipeline = "Build & Test"; + var state = MonitorSession.ApplyPoll( + Fixtures.WithBuilds(), + Fixtures.GitHub.Id, + [], + [ + ..Fixtures.GitHubBuilds(), + Fixtures.Build( + Fixtures.GitHub.Id, + "Reports/build.yml", + pipeline, + "VerifyTests/Reports", + "main", + "9", + BuildStatus.Failed, + started: Fixtures.Now - TimeSpan.FromMinutes(10), + finished: Fixtures.Now - TimeSpan.FromMinutes(8), + branchUrl: "https://github.com/VerifyTests/Reports/tree/main") + ], + Fixtures.Now - TimeSpan.FromSeconds(12)); + using var canvas = Drawn(1000, state); + var row = Fixtures.RowOf(state, _ => _.Build?.PipelineName == pipeline); + var text = TextRenderer.MeasureText(pipeline, canvas.Font, Size.Empty, TextFormatFlags.NoPadding | TextFormatFlags.NoPrefix).Width; + await Assert.That(LinkWidth(canvas, row, ChipKind.Pipeline)).IsEqualTo(text); + } + + /// + /// The canvas narrowed until the row keeps its first chip and puts the rest behind the overflow + /// chip: the chips column has given up some of its width and still has some left, which is when + /// the name and detail columns are exactly as wide as they were sized. + /// + static RowsCanvas Squeezed(SessionState state, int row, ChipKind first) + { + for (var width = 1000; width > 300; width -= 4) + { + var canvas = Drawn(width, state); + var from = OverflowFrom(canvas, row); + if (from != ChipKind.None && + from != first) + { + return canvas; + } + + canvas.Dispose(); + } + + throw new("No width puts some of the row's chips behind the overflow chip"); + } + + /// + /// The first of the chips the row's overflow chip stands in for, or none where it has no + /// overflow chip. + /// + static ChipKind OverflowFrom(RowsCanvas canvas, int row) + { + var y = canvas.RowHeight * row + canvas.RowHeight / 2; + for (var x = canvas.Width - 1; x >= 0; x -= 2) + { + Click(canvas, MouseButtons.Left, x, y); + var input = canvas.Drain(); + if (input.ClickedOverflowRow == row) + { + return input.OverflowFrom; + } + } + + return ChipKind.None; + } + + static List Pixels(RowsCanvas canvas, Rectangle area) + { + using var bitmap = new Bitmap(canvas.Width, canvas.Height); + canvas.DrawToBitmap(bitmap, new(0, 0, canvas.Width, canvas.Height)); + var pixels = new List(); + for (var y = area.Top; y < area.Bottom; y++) + { + for (var x = area.Left; x < area.Right; x++) + { + pixels.Add(bitmap.GetPixel(x, y).ToArgb()); + } + } + + return pixels; + } + + /// + /// How far along a row a link runs, from the first pixel that reports it to the last. + /// + static int LinkWidth(RowsCanvas canvas, int row, ChipKind link) + { + var (left, right) = LinkSpan(canvas, row, link); + return right - left + 1; + } + + static (int Left, int Right) LinkSpan(RowsCanvas canvas, int row, ChipKind link) + { + var y = canvas.RowHeight * row + canvas.RowHeight / 2; + var reported = new List(); + for (var x = 0; x < canvas.Width; x++) + { + Click(canvas, MouseButtons.Left, x, y); + if (canvas.Drain().ClickedChip == link) + { + reported.Add(x); + } + } + + return (reported.Min(), reported.Max()); + } + /// /// The mark before the name opens what the name does, which is the run on a row that broke /// and the repository on a settled one. A picture standing for the same page as the link diff --git a/src/BuildMonitor.Windows/RowsCanvas.cs b/src/BuildMonitor.Windows/RowsCanvas.cs index bedafeb..f676984 100644 --- a/src/BuildMonitor.Windows/RowsCanvas.cs +++ b/src/BuildMonitor.Windows/RowsCanvas.cs @@ -32,12 +32,14 @@ sealed class RowsCanvas : Control const int tipDuration = 20000; // Clear of the pointer, so the text is not under the hand that asked for it. const int tipOffset = 18; - // Without padding, so each run of the detail starts where the text before it ended. + // The flags that decide how wide a run of the detail is, which it is both measured and drawn + // with. Without padding, so each run starts where the text before it ended. + const TextFormatFlags runWidthFlags = TextFormatFlags.NoPrefix | + TextFormatFlags.NoPadding; const TextFormatFlags runFlags = TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis | - TextFormatFlags.NoPrefix | - TextFormatFlags.NoPadding; + runWidthFlags; // Stands in for the chips a row has no room for, and opens the drop down that holds them. const string overflowLabel = "…"; // Between a chip's icon and the text after it, where it has both. @@ -271,22 +273,16 @@ static bool SameColumns(BuildsPage previous, BuildsPage next) => Same(previous.GroupNames, next.GroupNames) && Same(previous.Details, next.Details) && Same(previous.Authors ?? [], next.Authors ?? []) && - Marks(previous) == Marks(next); + Same(previous.MemberNames ?? [], next.MemberNames ?? []) && + Same(previous.MarkedGroupNames ?? [], next.MarkedGroupNames ?? []) && + Same(previous.MarkedDetails ?? [], next.MarkedDetails ?? []) && + previous.NameMarks == next.NameMarks && + previous.DetailMarks == next.DetailMarks; static bool Same(IReadOnlyList previous, IReadOnlyList next) => ReferenceEquals(previous, next) || previous.SequenceEqual(next); - /// - /// Which marks the page draws at all: the one leading the second cell, the one before the name, - /// and one inside a run of the second cell. Room for each is reserved on every row once any row - /// has one, so a row gaining or losing a mark can move them all. - /// - static (bool Detail, bool Name, bool Span) Marks(BuildsPage page) => - (page.Rows.Any(_ => _.DetailIcon.Length > 0), - page.Rows.Any(_ => _.NameIcon.Length > 0), - page.Rows.Any(_ => _.Detail.Any(_ => _.Icon.Length > 0))); - Rectangle RowBounds(int row) => new(0, row * RowHeight, Width, RowHeight); @@ -431,10 +427,11 @@ public bool PaintRows(Graphics graphics, Rectangle clip) // Reserved on every row once any row has an icon, so a group's row, which has none, keeps // its name in line with the rows under it. The same for the host's mark before the name, - // which a row whose provider gave no repository URL does not have. + // which a row whose provider gave no repository URL does not have. Any row of the page + // rather than of those in view, or the columns move as a row with one scrolls in or out. var logo = LogoSize + LogicalToDeviceUnits(padding); - var iconWidth = page.Rows.Any(_ => _.DetailIcon.Length > 0) ? logo : 0; - var markWidth = page.Rows.Any(_ => _.NameIcon.Length > 0) ? logo : 0; + var iconWidth = page.DetailMarks ? logo : 0; + var markWidth = page.NameMarks ? logo : 0; // A detail's runs are measured as the text so far, which differs by row, so the widths would // grow without end as builds come and go. Emptied once they hold several times what the // columns measure, which leaves room for every row's runs, and here rather than as they @@ -497,22 +494,29 @@ public bool PaintRows(Graphics graphics, Rectangle clip) // After the status square, a gap after each of the name, detail, timing and chips, and the // author and its gap when shown. var available = Width - RowHeight - TimingWidth() - 5 * gap - (authorWidth > 0 ? authorWidth + gap : 0); - // With the padding Draw leaves, so the widest text fits without an ellipsis. A member is - // indented by the arrow its group is drawn behind, and the room for it is reserved wherever - // the page has a group at all, so the column does not shift as one is opened. - var indent = builds.GroupNames.Count > 0 ? Indent() : 0; - var nameWanted = markWidth + builds.Names - .Select(_ => indent + MeasureName(_, Font)) - .Concat(builds.GroupNames.Select(_ => MeasureName($"▼ {_}", bold))) + // The widest cell DrawName makes of any name, from the parts it draws, each name as the + // kind of row that shows it: every name as a build's, those that are a member's indented + // as well, every group behind its arrow, and those with a mark with its width as well. A + // member's blank cell draws nothing, so it wants no room. Taking every name as a member + // with a mark gave the column an indent's width that no row drew in. + var nameWanted = builds.Names + .Select(_ => NameCell(_, RowKind.Build, true, markWidth)) + .Concat((builds.MemberNames ?? []).Where(_ => _.Length > 0).Select(_ => NameCell(_, RowKind.Member, true, markWidth))) + .Concat(builds.GroupNames.Select(_ => NameCell(_, RowKind.Group, false, markWidth))) + .Concat((builds.MarkedGroupNames ?? []).Select(_ => NameCell(_, RowKind.Group, true, markWidth))) + .Select(_ => _.Indent + _.Mark + _.Text) + .DefaultIfEmpty() + .Max(); + // Where DrawDetail ends the widest detail, by the offsets it draws at: every detail as + // text alone, and those that carry the branch's mark with it. Forty characters at most: + // past that a long pipeline or branch is cut short rather than pushing every row's chips + // into the drop down. + var longest = new string('0', 40); + var detailWanted = iconWidth + builds.Details + .Select(_ => Math.Min(DetailOffset(_, 0), DetailOffset(longest, 0))) + .Concat((builds.MarkedDetails ?? []).Select(_ => Math.Min(DetailOffset(_, 1), DetailOffset(longest, 1)))) .DefaultIfEmpty() .Max(); - // Forty characters at most: past that a long pipeline or branch is cut short rather than - // pushing every row's chips into the drop down. The details are text alone, so the branch's - // mark is added on top once any row draws one. - var spanIcons = builds.Rows.Any(_ => _.Detail.Any(_ => _.Icon.Length > 0)) ? SpanIconWidth() : 0; - var detailWanted = iconWidth + spanIcons + Math.Min( - builds.Details.Select(_ => MeasureName(_, Font)).DefaultIfEmpty().Max(), - MeasureName(new('0', 40), Font)); var widest = WidestChips(); var bar = LogicalToDeviceUnits(barLength); var barWidth = available - bar - gap - nameWanted - detailWanted >= widest ? bar : 0; @@ -529,9 +533,31 @@ public bool PaintRows(Graphics graphics, Rectangle clip) return (nameWidth, names - nameWidth, barWidth, authorWidth, chipsWidth); } + /// + /// The parts of a name's cell, left to right: the indent, the host's mark, and the name in the + /// font it is drawn in, with the padding Draw leaves. sizes the + /// column from these and draws at them, so what is measured is what is + /// drawn. Each working it out for itself, the column sized a group as the one text "▼ name" + /// while the arrow and the name were drawn as two, and the widest group's name came out a + /// text's padding short: cut off with an ellipsis beside an empty column. + /// + (int Indent, int Mark, int Text) NameCell(string name, RowKind kind, bool marked, int markWidth) + { + // A group's arrow is drawn in the indent, and a member starts past it, where its group's + // name does, under the group rather than beside it. + var indent = kind == RowKind.Build ? 0 : Indent(); + // The mark's width is reserved on every row once any row has one, so the names still line + // up where a provider gave no repository URL to read a host from. Not on a group with no + // mark of its own: its arrow already stands where the mark would, and a prefix group, + // which names no one repository, read as an indented heading. + var mark = kind == RowKind.Group && !marked ? 0 : markWidth; + return (indent, mark, MeasureName(name, NameFont(kind))); + } + /// /// How far a member's first cell is pushed in: the width of the arrow its group is drawn /// behind, so its mark starts where the group's name does and the rows read as being under it. + /// The open arrow's, whichever is drawn, so a group's name does not move as it opens. /// int Indent() => MeasureName("▼ ", bold); @@ -562,9 +588,9 @@ int TimingWidth() => LogicalToDeviceUnits(minimumTiming), Progress.Widest.Select(_ => MeasureName(_, Font)).Max()); - Font NameFont(BuildRow row) + Font NameFont(RowKind kind) { - if (row.Kind == RowKind.Group) + if (kind == RowKind.Group) { return bold; } @@ -654,14 +680,9 @@ void DrawName(Graphics graphics, BuildRow row, int index, int x, Rectangle bound { // Through NameFont, so a group's name keeps the weight that makes it read as a heading // while it takes the link colour, and the hit rectangle is measured in the font drawn. - var font = NameFont(row); - // A member starts where its group's name does, under the group rather than beside it. - if (row.Kind == RowKind.Member) - { - var indent = Math.Min(Indent(), width); - x += indent; - width -= indent; - } + var font = NameFont(row.Kind); + // The same parts the column was sized from, so the name has the room that was measured. + var cell = NameCell(row.Name, row.Kind, row.NameIcon.Length > 0, markWidth); // The arrow is drawn before the name but is no part of it: it is what opens and closes the // group, so it stays in the ordinary colour and outside the link's rectangle, and a click @@ -670,36 +691,29 @@ void DrawName(Graphics graphics, BuildRow row, int index, int x, Rectangle bound if (arrow.Length > 0) { Draw(graphics, arrow, font, x, bounds, width, Palette.Text); - var arrowWidth = Math.Min(MeasureName(arrow, font), width); - x += arrowWidth; - width -= arrowWidth; } - // The host's mark leads the name, in a width reserved on every row once any row has one, - // so the names still line up where a provider gave no repository URL to read a host from. - // Not on a group with no mark of its own: its arrow already stands where the mark would, - // and a prefix group, which names no one repository, read as an indented heading. + var indent = Math.Min(cell.Indent, width); + x += indent; + width -= indent; + var markLeft = x; - if (markWidth > 0 && - (arrow.Length == 0 || row.NameIcon.Length > 0)) + if (row.NameIcon.Length > 0) { - if (row.NameIcon.Length > 0) - { - var side = LogoSize; - Icons.Draw(graphics, row.NameIcon, new(x, bounds.Top + (bounds.Height - side) / 2, side, side), logoSource); - } - - x += markWidth; - width -= markWidth; + var side = LogoSize; + Icons.Draw(graphics, row.NameIcon, new(x, bounds.Top + (bounds.Height - side) / 2, side, side), logoSource); } + x += cell.Mark; + width -= cell.Mark; + if (row.NameLink == ChipKind.None) { Draw(graphics, row.Name, font, x, bounds, width, Palette.Text); return; } - var link = LinkBounds(x, Math.Min(MeasureName(row.Name, font), width), bounds); + var link = LinkBounds(x, Math.Min(cell.Text, width), bounds); if (row.NameIcon.Length > 0) { // The mark opens what the name does, so the two are one target rather than a link with @@ -737,10 +751,10 @@ void DrawDetail(Graphics graphics, BuildRow row, int index, int x, Rectangle bou { var right = x + width; var before = ""; - var icons = 0; + var marks = 0; foreach (var span in row.Detail) { - var left = x + icons + Measure(before); + var left = x + DetailOffset(before, marks); if (left >= right) { return; @@ -759,10 +773,10 @@ void DrawDetail(Graphics graphics, BuildRow row, int index, int x, Rectangle bou } Icons.Draw(graphics, span.Icon, new(left, bounds.Top + (bounds.Height - side) / 2, side, side)); - icons += SpanIconWidth(); + marks++; } - var textLeft = x + icons + Measure(before); + var textLeft = x + DetailOffset(before, marks); before += span.Text; var cell = new Rectangle(textLeft, bounds.Top, Math.Max(0, right - textLeft), bounds.Height); if (span.Link == ChipKind.None) @@ -771,13 +785,33 @@ void DrawDetail(Graphics graphics, BuildRow row, int index, int x, Rectangle bou continue; } - var link = LinkBounds(left, Math.Min(x + icons + Measure(before), right) - left, bounds); + var link = LinkBounds(left, Math.Min(x + DetailOffset(before, marks), right) - left, bounds); TextRenderer.DrawText(graphics, span.Text, link == hoverLink ? underline : Font, cell, Palette.ChipText, runFlags); chips.Add((index, span.Link, false, link)); Tip(index, link, row.Tooltip(span.Link == ChipKind.Branch ? RowPart.Branch : RowPart.Pipeline)); } } + /// + /// How far into its cell a detail has got once is drawn behind + /// of its runs' icons. sizes the column + /// from where this puts the end of each detail and starts each run at + /// it, so the column is as wide as what is drawn in it. Measured with the flags that decide a + /// run's width as it is drawn: the column was sized with the padding the runs leave out, and + /// the runs placed by a measure that read an ampersand as a mnemonic, so the branch of a + /// pipeline named "Build & Test" was drawn over the end of it. + /// + int DetailOffset(string text, int marks) + { + var offset = marks * SpanIconWidth(); + if (text.Length == 0) + { + return offset; + } + + return offset + TextWidth(text, Font, runWidthFlags); + } + /// /// How far a run's icon pushes its text along: the room before it, the icon at a chip icon's /// size, and the gap a chip leaves between its icon and its text.