diff --git a/src/view/draw/bead.rs b/src/view/draw/bead.rs
index e822f05..07e9621 100644
--- a/src/view/draw/bead.rs
+++ b/src/view/draw/bead.rs
@@ -53,10 +53,10 @@ pub(super) fn bead_line(row: &Row, prefix: &str, id_width: usize) -> Fitted {
let mut shorter = Vec::new();
for badge in &row.badges {
title.push(Span::raw(" ".repeat(GAP)));
- if let Some(to) = &badge.link {
+ if let Some(to) = opens_at(badge) {
links.push(Link {
at: title.len(),
- to: to.clone(),
+ to: to.to_string(),
});
}
if let Some(said) = badge
@@ -84,18 +84,26 @@ pub(super) fn bead_line(row: &Row, prefix: &str, id_width: usize) -> Fitted {
fitted.toned(tone(row))
}
-/// The underline is the whole of what a reader can *see* about a link: its
-/// destination is nowhere in the row's text at any width. What the terminal
-/// acts on is the hyperlink `Fitted` writes round the badge.
+/// Where the badge takes a reader, where the emitter will write a sequence
+/// saying so.
///
-/// So the underline is drawn on the same answer the emitter gives, rather
-/// than on the config having named a link: one the emitter refuses would draw
-/// a badge that invites a click it cannot honour.
+/// Both the underline and the hyperlink are asked of this one answer. The
+/// underline is the whole of what a reader can *see* about a link, its
+/// destination being nowhere in the row's text at any width, so a badge
+/// marked as a link that does not open is a lie and one that opens unmarked
+/// is never found.
///
-/// The two knobs compose because `palette::LINK` is an underline carrying no
-/// colour of its own. A badge that names neither is left with a style of
-/// nothing, which is what lets the row's own tone reach it the way it reaches
-/// the title beside it.
+/// Asked of the badge's own text rather than of the words the row goes on to
+/// draw, so the answer does not turn on how wide the row is. A badge is a
+/// link or it is not.
+fn opens_at(badge: &Badged) -> Option<&str> {
+ badge.link.as_deref().filter(|to| openable(&badge.text, to))
+}
+
+/// A badge's colour and its underline compose, because `palette::LINK` is an
+/// underline carrying no colour of its own. A badge that names neither is
+/// left with a style of nothing, which is what lets the row's own tone reach
+/// it the way it reaches the title beside it.
fn badge_style(badge: &Badged, status: &Status) -> Style {
let coloured = match badge.colour {
Some(Colour::Status) => status_style(status),
@@ -103,9 +111,10 @@ fn badge_style(badge: &Badged, status: &Status) -> Style {
Some(Colour::Absolute(colour)) => palette::absolute(colour),
None => Style::new(),
};
- match &badge.link {
- Some(to) if openable(&badge.text, to) => coloured.patch(palette::LINK),
- _ => coloured,
+ if opens_at(badge).is_some() {
+ coloured.patch(palette::LINK)
+ } else {
+ coloured
}
}
@@ -753,20 +762,20 @@ mod tests {
assert_eq!(drawn, said(unlinked));
}
- /// A badge the row cut has no link behind it — `surviving` keeps only the
- /// spans the title block kept whole — so it is not drawn as one either.
+ /// A badge the row cut still points where it always did, so it is still
+ /// drawn as a link. The words are clipped; the destination is not.
///
/// Read as a pair on one badge rather than as a reading at the narrow
/// width alone. A badge that never carried a link is not underlined at
/// any width, so the narrow reading on its own passes whether the
- /// underline was taken off or was never there.
+ /// underline survived the cut or was never there.
///
/// The badge that never carried one is read at both widths, as a whole
- /// style rather than for its underline. The underline is all the cut
- /// takes, and a cut that reached the badge's colour would move a badge no
- /// link was ever drawn on.
+ /// style rather than for its underline. The cut moves nothing about a
+ /// badge's style, and a cut that reached the badge's colour would move a
+ /// badge no link was ever drawn on.
#[test]
- fn a_badge_the_row_cut_is_drawn_without_the_underline_it_lost_the_link_for() {
+ fn a_badge_the_row_cut_is_still_drawn_as_the_link_it_still_is() {
let badge_at = |width: u16, to: Option<&str>| {
let mut badged = node("smt-4kd3p.20", "a bead", Status::Blocked);
badged.badges = vec![Badged {
@@ -788,10 +797,10 @@ mod tests {
"the badge the row kept whole is not underlined"
);
assert!(
- !badge_at(EXACTLY_THE_ROW - 1, somewhere)
+ badge_at(EXACTLY_THE_ROW - 1, somewhere)
.add_modifier
.contains(Modifier::UNDERLINED),
- "the badge the row cut invites a click nothing is there to honour"
+ "the cut took the underline off a badge that still opens"
);
assert_eq!(
badge_at(EXACTLY_THE_ROW - 1, None),
@@ -800,6 +809,78 @@ mod tests {
);
}
+ /// The underline is only a promise; what the reader acts on is the
+ /// hyperlink. A cut badge that kept the one without the other would
+ /// invite the click and drop it.
+ ///
+ /// Read at the head the row kept rather than at the badge's whole words,
+ /// because the sequence has to close inside the columns the row still
+ /// has.
+ #[test]
+ fn a_badge_the_row_cut_opens_where_a_badge_it_kept_whole_opens() {
+ let somewhere = "https://forge.invalid/orbital/atlas/pull/12";
+ let mut badged = node("smt-4kd3p.20", "a bead", Status::Blocked);
+ badged.badges = vec![Badged {
+ key: "delivery_pr".into(),
+ text: "⇢ #12".into(),
+ link: Some(somewhere.into()),
+ short: None,
+ colour: None,
+ }];
+
+ let said = symbols(bead_line(&row(&badged), BRANCH, 4), EXACTLY_THE_ROW - 1);
+
+ assert!(
+ said.contains(
+ &hyperlink("⇢ #", somewhere).expect("this vocabulary holds no control character")
+ ),
+ "the badge the row cut was not made a link: {said:?}"
+ );
+ }
+
+ /// A badge whose words the emitter refuses is not opened at any width,
+ /// including the widths that cut those words back to a head it would
+ /// accept. The row would otherwise open a link it drew no underline on,
+ /// which is the opposite mistake to the one the cut used to make and just
+ /// as much of a lie.
+ ///
+ /// Swept over every width rather than read at the one that cuts, because
+ /// which width that is falls out of a control character's own zero
+ /// columns. The clean badge is swept alongside it, so a sweep that opened
+ /// nothing anywhere cannot pass as a sweep that refused.
+ ///
+ /// Read without the note the refused badge earns the row, which would
+ /// otherwise take the columns the sweep is spending on the badge. What
+ /// the note says is `row::cells`' to say and is read there.
+ #[test]
+ fn a_badge_the_emitter_refuses_is_not_opened_at_the_widths_that_cut_it() {
+ let somewhere = "https://forge.invalid/orbital/atlas/pull/12";
+ let held = "\u{1b}]0;owned\u{7}";
+ let opened_at = |text: String, width: u16| {
+ let mut badged = node("smt-4kd3p.20", "a bead", Status::Blocked);
+ badged.badges = vec![Badged {
+ key: "delivery_pr".into(),
+ text,
+ link: Some(somewhere.into()),
+ short: None,
+ colour: None,
+ }];
+ let mut unremarked = row(&badged);
+ unremarked.notes = Vec::new();
+ symbols(bead_line(&unremarked, BRANCH, 4), width).contains(somewhere)
+ };
+ let every_width = || 1..=60;
+
+ assert!(
+ every_width().any(|width| opened_at("⇢ #12".into(), width)),
+ "the sweep opened no link at any width, so it refuses nothing"
+ );
+ assert!(
+ !every_width().any(|width| opened_at(format!("⇢ #12{held}"), width)),
+ "a badge the emitter refuses whole was opened by a cut"
+ );
+ }
+
/// The badge that names a URL is the one the terminal is told about, and
/// it is told round the badge's own words — so the reader clicks the badge
/// rather than retyping what it stands for.
diff --git a/src/view/fitted.rs b/src/view/fitted.rs
index bc8dac2..ffa0462 100644
--- a/src/view/fitted.rs
+++ b/src/view/fitted.rs
@@ -141,8 +141,9 @@ impl Fitted {
/// Which spans of the title block are links, and where each one points.
///
- /// A link the row had to cut is dropped: an opening sequence with nothing
- /// to close it makes every cell after it on the terminal part of the link.
+ /// A link the row cut is told round the head it kept, because the cut
+ /// takes columns off what the link says and nothing off where it goes. A
+ /// link the row dropped altogether has no words left to be told round.
#[must_use]
pub(crate) fn linking(mut self, links: Vec) -> Self {
self.links = links;
@@ -325,12 +326,13 @@ impl Kept {
}
}
-/// The links whose span the title block kept whole, each at the column it
-/// starts on. A link whose span was cut or dropped is not among them.
-fn surviving(links: &[Link], title: &[Span<'static>], whole: usize, starts: usize) -> Vec {
+/// The links whose span the title block drew, each at the column it starts
+/// on and saying what that span says now. A link whose span was dropped is
+/// not among them; one whose span was cut is, round the head it kept.
+fn surviving(links: &[Link], title: &[Span<'static>], drawn: usize, starts: usize) -> Vec {
links
.iter()
- .filter(|link| link.at < whole)
+ .filter(|link| link.at < drawn)
.map(|link| Kept {
at: starts + columns(&title[..link.at]),
width: title[link.at].width(),
@@ -386,10 +388,10 @@ pub(crate) fn columns(spans: &[Span<'static>]) -> usize {
/// `spans` fitted into `limit` columns: cut with the cut marked, or, for a
/// block that says nothing in part, given up whole.
///
-/// Alongside the spans, how many of them the block kept whole — the leading
-/// run that says everything it was written to say. Anything after that run was
-/// cut short or dropped, and what a caller holds against a span of it no
-/// longer holds.
+/// Alongside the spans, how many of them the block drew — the leading run
+/// still standing as itself, the last of them possibly cut short. Anything
+/// after that run is nowhere on the row, and what a caller holds against a
+/// span of it has nothing left to hold it against.
fn fit(spans: Vec>, limit: usize, or_nothing: bool) -> (Vec>, usize) {
if or_nothing && columns(&spans) > limit {
return (Vec::new(), 0);
@@ -402,8 +404,8 @@ fn fit(spans: Vec>, limit: usize, or_nothing: bool) -> (Vec>, limit: usize) -> (Vec>, usize) {
if columns(&spans) <= limit {
- let whole = spans.len();
- return (spans, whole);
+ let drawn = spans.len();
+ return (spans, drawn);
}
if limit == 0 {
return (Vec::new(), 0);
@@ -447,39 +453,25 @@ fn cut_to(spans: Vec>, limit: usize) -> (Vec>, usize
let room = limit - columns(&[Span::raw(CUT.to_string())]);
let mut kept: Vec> = Vec::new();
- let mut whole = 0;
+ let mut drawn = 0;
let mut used = 0;
for span in spans {
let width = span.width();
if used + width <= room {
used += width;
- whole += 1;
+ drawn += 1;
kept.push(span);
continue;
}
let head = head_of(&span.content, room - used);
if !head.is_empty() {
- kept.push(Span::styled(head, unlinked(span.style)));
+ drawn += 1;
+ kept.push(Span::styled(head, span.style));
}
break;
}
kept.push(Span::raw(CUT.to_string()));
- (kept, whole)
-}
-
-/// `style` with the underline that stands for a link taken back off.
-///
-/// The other half of the rule `surviving` keeps: a span the row cut is not
-/// among the links the terminal is told about, so nothing is there to follow.
-/// Left underlined it would invite a click that cannot be honoured. Said as
-/// what `palette::LINK` adds rather than as the modifier itself, so the two
-/// cannot drift apart.
-///
-/// Asked of every span the row cuts rather than only of the ones a link
-/// names, because `cut_to` is told which columns it has and nothing about
-/// what it is cutting. A span that never carried the underline is unmoved.
-fn unlinked(style: Style) -> Style {
- style.remove_modifier(palette::LINK.add_modifier)
+ (kept, drawn)
}
/// As much of `text` as fits in `limit` columns, never splitting a glyph.
@@ -977,16 +969,45 @@ mod tests {
}
}
- /// A link cut for width loses the link rather than its closing sequence.
- /// An opening sequence with nothing to close it makes every cell after it
- /// on the terminal part of the link.
+ /// A link cut for width is opened round the head the row kept. The whole
+ /// sequence lives in the cell the link starts on, so the columns the cut
+ /// took are not columns the sequence needed.
+ ///
+ /// The width the cell reports is the head's rather than the badge's, so
+ /// the columns the diff skips behind it are the columns the link holds.
+ /// One column too many and a redraw leaves whatever stood in the next one.
+ #[test]
+ fn a_link_cut_for_width_is_opened_round_the_head_it_kept() {
+ let buf = rendered(a_linked_row(), 20);
+ let said = symbols(&buf);
+
+ assert!(
+ said.contains(CUT),
+ "the row was not cut at all, so it says nothing about a cut \
+ link: {said:?}"
+ );
+ assert!(
+ said.contains(
+ &hyperlink("⇢ #1", SOMEWHERE).expect("this vocabulary holds no control character")
+ ),
+ "a link the row cut was dropped: {said:?}"
+ );
+ assert_eq!(
+ buf[(opened_at(&buf), 0)].diff_option,
+ CellDiffOption::ForcedWidth(NonZeroU16::new(4).expect("⇢ #1 is four columns"))
+ );
+ }
+
+ /// Narrower still, and the cut leaves the link's span no columns at all.
+ /// There is nothing on the row for a sequence to be told round, and a
+ /// sequence round nothing would open a link over whatever came after it.
#[test]
- fn a_link_cut_for_width_is_not_opened_at_all() {
- let said = symbols(&rendered(a_linked_row(), 20));
+ fn a_link_the_cut_left_no_room_at_all_is_not_opened() {
+ let said = symbols(&rendered(a_linked_row(), 16));
assert!(
!said.contains(OSC_8),
- "a cut link opened a hyperlink: {said:?}"
+ "a link the row cut to nothing opened a hyperlink: {said:?}"
);
}