diff --git a/app/Devnull/Data/BodyRun.php b/app/Devnull/Data/BodyRun.php index ed713af..598c4bf 100644 --- a/app/Devnull/Data/BodyRun.php +++ b/app/Devnull/Data/BodyRun.php @@ -4,15 +4,24 @@ /** * A run of a paragraph: plain prose, a mention, inline code, a link, or a - * standalone `/dev/null`, which draws as the wordmark. A link's text is the - * URL as written. + * standalone `/dev/null`, which draws as the wordmark, or a hex color, which + * draws with a swatch of it. A link's text is the URL as written, and a + * color's is the color as written, `#` included. */ final readonly class BodyRun { - public function __construct(public string $text, public ?string $username = null, public bool $isCode = false, public bool $isWordmark = false, public ?BodyLink $link = null) {} + public function __construct(public string $text, public ?string $username = null, public bool $isCode = false, public bool $isWordmark = false, public ?BodyLink $link = null, public ?string $color = null) {} public function isMention(): bool { return $this->username !== null; } + + /** + * Whether this run is a hex color, whose digits are in $color. + */ + public function isColor(): bool + { + return $this->color !== null; + } } diff --git a/app/Devnull/Data/CommitBody.php b/app/Devnull/Data/CommitBody.php index 575d787..26e8f62 100644 --- a/app/Devnull/Data/CommitBody.php +++ b/app/Devnull/Data/CommitBody.php @@ -4,11 +4,12 @@ /** * Turns a commit's `body_segments` into blocks to draw, the way the web's - * CommitBody does: prose (with URLs and mentions linked, and `/dev/null` as - * the wordmark) and inline code flow together in paragraphs, lines that start - * with `>` group into quotes, and fenced code blocks stand on their own. + * CommitBody does: prose (with URLs and mentions linked, hex colors beside a + * swatch, and `/dev/null` as the wordmark) and inline code flow together in + * paragraphs, lines that start with `>` group into quotes, and fenced code + * blocks stand on their own. * - * Mention offsets count code points into the whole body, but segments aren't + * Mention and color offsets count code points into the whole body, but segments aren't * exact slices of it: inline code loses its backticks, a block its fences, and * prose the line break next to a block. So each segment is found in the body, * moving forward, and mentions are placed by where their segment starts. @@ -31,6 +32,15 @@ final class CommitBody private const string FencedBlock = '/\G`{3,}[^`\r\n]*\r?\n(?:([\s\S]*?)\r?\n)?`{3,}[ \t]*(?![^\r\n])/u'; + /** + * A hex color: 3, 4, 6 or 8 hex digits after a `#`. One of 3 or 4 digits + * also needs a letter or a leading zero, so `#123` stays an issue number + * while `#000` is black. It's a whole word: it starts a line, follows + * whitespace or a quoted line's `>`, and only punctuation may follow it + * before the next space. The web's rule, from colors.ts. + */ + private const string Color = '/(?:(?))[#\x{FF03}]([0-9a-f]{8}|[0-9a-f]{6}|(?=[0-9]*[a-f]|0)[0-9a-f]{3,4})(?=\p{P}*(?:[\s\x{00AD}\x{2060}\x{200A}-\x{200D}\x{20E2}]|\z))/iu'; + /** * A `/dev/null` that stands on its own, not part of a longer path, a word * or a name. The web's rule, from withWordmarks() in linkified-text.tsx. @@ -105,6 +115,7 @@ public static function blocks(string $body, ?array $segments, array $mentions = $blocks = []; $runs = []; $cursor = 0; + $colors = self::colors($body); foreach ($segments as $segment) { switch ($segment['type'] ?? null) { @@ -121,12 +132,12 @@ public static function blocks(string $body, ?array $segments, array $mentions = $start = $text === '' ? false : mb_strpos($body, $text, $cursor); if ($start === false) { - array_push($runs, ...self::withLinks($text.($cut ? self::Ellipsis : ''), null, [])); + array_push($runs, ...self::withLinks($text.($cut ? self::Ellipsis : ''), null, [], [])); break; } - array_push($runs, ...self::withLinks($text, $start, $mentions)); + array_push($runs, ...self::withLinks($text, $start, $mentions, $colors)); $cursor = $start + mb_strlen($text); if ($cut) { @@ -182,7 +193,7 @@ private static function paragraphs(array $runs): array $quoted = false; foreach ($runs as $run) { - $prose = ! $run->isMention() && ! $run->isCode && ! $run->isWordmark && $run->link === null; + $prose = ! $run->isMention() && ! $run->isCode && ! $run->isWordmark && $run->link === null && ! $run->isColor(); $lines = $prose ? array_map(fn (string $text): BodyRun => new BodyRun($text), preg_split('/(?<=\n)/', $run->text, flags: PREG_SPLIT_NO_EMPTY) ?: []) @@ -254,15 +265,16 @@ private static function withoutTrailingBreak(array $runs): array * Prose that wasn't found in the body has no start, so no mentions. * * @param list $mentions + * @param list $colors * @return list */ - private static function withLinks(string $text, ?int $start, array $mentions): array + private static function withLinks(string $text, ?int $start, array $mentions, array $colors): array { $runs = []; $position = 0; $prose = fn (string $part, int $at): array => $start === null ? self::withWordmarks($part) - : self::withMentions($part, $start + $at, $mentions); + : self::withMentions($part, $start + $at, $mentions, $colors); foreach (BodyLink::in($text) as [$offset, $length, $link]) { if ($offset > $position) { @@ -284,9 +296,10 @@ private static function withLinks(string $text, ?int $start, array $mentions): a * Splits prose that starts at $start in the body around the mentions inside it. * * @param list $mentions + * @param list $colors * @return list */ - private static function withMentions(string $text, int $start, array $mentions): array + private static function withMentions(string $text, int $start, array $mentions, array $colors): array { $runs = []; $position = 0; @@ -308,7 +321,7 @@ private static function withMentions(string $text, int $start, array $mentions): } if ($offset > $position) { - array_push($runs, ...self::withWordmarks(mb_substr($text, $position, $offset - $position))); + array_push($runs, ...self::withColors(mb_substr($text, $position, $offset - $position), $start + $position, $colors)); } $runs[] = new BodyRun($handle, username: $mention->username); @@ -316,12 +329,67 @@ private static function withMentions(string $text, int $start, array $mentions): } if ($position < $length) { - array_push($runs, ...self::withWordmarks(mb_substr($text, $position))); + array_push($runs, ...self::withColors(mb_substr($text, $position), $start + $position, $colors)); } return $runs; } + /** + * Splits prose that starts at $start in the body around the colors found + * in the whole body that fall inside it, as the web's withColors() does. + * + * @param list $colors + * @return list + */ + private static function withColors(string $text, int $start, array $colors): array + { + $runs = []; + $position = 0; + $length = mb_strlen($text); + + foreach ($colors as [$offset, $colorLength, $value]) { + $offset -= $start; + + if ($offset < $position || $offset + $colorLength > $length) { + continue; + } + + array_push($runs, ...self::withWordmarks(mb_substr($text, $position, $offset - $position))); + $runs[] = new BodyRun(mb_substr($text, $offset, $colorLength), color: $value); + $position = $offset + $colorLength; + } + + array_push($runs, ...self::withWordmarks(mb_substr($text, $position))); + + return $runs; + } + + /** + * The hex colors in the body's prose, in order, as where each starts and + * how long it is in code points, `#` included, and its digits. Code is + * blanked out first, so nothing in it counts, and the body's start is a + * line's start. + * + * @return list + */ + private static function colors(string $body): array + { + $prose = ''; + + foreach (self::parts($body) as $index => $part) { + $prose .= $index % 2 === 0 ? $part : str_repeat(' ', mb_strlen($part)); + } + + preg_match_all(self::Color, "\n".$prose, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); + + return array_map(fn (array $match): array => [ + mb_strlen(substr("\n".$prose, 0, $match[0][1])) - 1, + mb_strlen($match[0][0]), + $match[1][0], + ], $matches); + } + /** * Splits prose around each standalone `/dev/null`, which becomes a * wordmark run. Like the web, the prose between two mentions is looked at diff --git a/resources/views/native/partials/commit-body.blade.php b/resources/views/native/partials/commit-body.blade.php index 75118b7..27a1ad9 100644 --- a/resources/views/native/partials/commit-body.blade.php +++ b/resources/views/native/partials/commit-body.blade.php @@ -10,6 +10,9 @@ slash, even after a space, so a zero-width space (U+200B) after a space lets the line break there as a browser would; without it, a few wordmarks in a row can't wrap at all. VoiceOver skips both and still reads "/dev/null". + A hex color has a square of that color before it, as the web's swatch, held to it by a narrow no-break space so + they wrap together. + A quote, lines that start with ">", sits behind an amber rule on the left like the web's blockquote, its runs drawn as a paragraph's. @@ -47,6 +50,8 @@ ⁠ @elseif ($run->link) +@elseif ($run->isColor()) +  @elseif ($run->isWordmark) @php($afterSpace = $loop->index > 0 && ctype_space(substr($block->runs[$loop->index - 1]->text, -1))) @if ($afterSpace)​@endif/⁠dev⁠null diff --git a/resources/views/native/partials/message-body.blade.php b/resources/views/native/partials/message-body.blade.php index ca8a1b7..35fbc49 100644 --- a/resources/views/native/partials/message-body.blade.php +++ b/resources/views/native/partials/message-body.blade.php @@ -1,8 +1,9 @@ {{-- A message's body, drawn from its segments as a commit's is (see partials/commit-body): prose with tappable - mentions and inline code, quotes behind a rule on the left, and fenced code as mono text in a box. $ink is the - bubble's text colour, so a body on your lane reads in that lane's label colour, and $quoteRule the quote's rule: - amber, or the label colour on your lane, as the web's pale rule in your own bubbles. + mentions and inline code, hex colors beside their swatch, quotes behind a rule on the left, and fenced code as + mono text in a box. $ink is the bubble's text colour, so a body on your lane reads in that lane's label colour, + and $quoteRule the quote's rule: amber, or the label colour on your lane, as the web's pale rule in your own + bubbles. --}} @foreach ($message->content as $block) @if ($block->isCode()) @@ -18,6 +19,8 @@ @elseif ($run->isCode) +@elseif ($run->isColor()) +  @else @endif diff --git a/tests/Feature/Screens/CommitDetailTest.php b/tests/Feature/Screens/CommitDetailTest.php index ab0bf52..b341b6e 100644 --- a/tests/Feature/Screens/CommitDetailTest.php +++ b/tests/Feature/Screens/CommitDetailTest.php @@ -464,6 +464,24 @@ function fakeThread(array $commit = [], array $replies = [], array $extra = []): $screen->press("openProfile('ada')")->assertNavigatedTo('/log/users/ada'); }); +it('draws a hex color after a square of that color, in light and dark mode alike', function () { + $body = 'Brand orange is #ff8a4c, not #123'; + + fakeThread([ + 'body' => $body, + 'body_segments' => [['type' => 'text', 'text' => $body]], + ]); + + $screen = Native::visit('/log/commits/c2')->assertSee('#ff8a4c'); + + $swatches = findNodes($screen->tree(), fn (array $node): bool => ($node['props']['text'] ?? null) === '■'); + + expect($swatches)->toHaveCount(1) + ->and($swatches[0]['props']['color'] ?? null)->toBe('#FF8A4C') + // The paragraph's dark ink would otherwise draw the square in dark mode. + ->and($swatches[0]['props']['dark_color'] ?? null)->toBe('#FF8A4C'); +}); + it('draws quote lines behind an amber rule, without their markers', function () { $body = "> Ship it on Friday?\n> @ada said so\nNot on my watch"; diff --git a/tests/Unit/Devnull/CommitBodyTest.php b/tests/Unit/Devnull/CommitBodyTest.php index ea950e1..f59f206 100644 --- a/tests/Unit/Devnull/CommitBodyTest.php +++ b/tests/Unit/Devnull/CommitBodyTest.php @@ -300,6 +300,80 @@ function linkIn(array $blocks): array 'https://notgithub.com/laravel/framework', ]); +/** + * The colors in a body that is all prose, as written and as their digits. + * + * @return list + */ +function colorsOf(string $body): array +{ + $colors = []; + + foreach (CommitBody::blocks($body, null) as $block) { + foreach ($block->runs as $run) { + if ($run->isColor()) { + $colors[] = [$run->text, (string) $run->color]; + } + } + } + + return $colors; +} + +it('shows hex colors with their swatch, as the web does', function () { + expect(colorsOf('Brand orange is #ff8a4c, white is #fff, and #ff8a4c80 or #fffa'))->toBe([ + ['#ff8a4c', 'ff8a4c'], + ['#fff', 'fff'], + ['#ff8a4c80', 'ff8a4c80'], + ['#fffa', 'fffa'], + ])->and(runsOf(CommitBody::blocks('Brand orange is #ff8a4c, ok', null)))->toBe([ + ['Brand orange is '], + ['#ff8a4c'], + [', ok'], + ]); +}); + +it('leaves digit-only issue numbers alone but counts digits with a leading zero or six of them', function () { + expect(colorsOf('Fixes #123 and #333, in #000 and #000000 and #333333'))->toBe([ + ['#000', '000'], + ['#000000', '000000'], + ['#333333', '333333'], + ]); +}); + +it('only counts a color that is a whole word', function () { + expect(colorsOf('#fff. and #fff-x and #ff8a4c.com and (#fff) and a#fff and #ff and #fffff'))->toBe([ + ['#fff', 'fff'], + ]); +}); + +it('counts a color at the start of a quoted line', function () { + expect(colorsOf(">#fff\n> #000"))->toBe([['#fff', 'fff'], ['#000', '000']]); +}); + +it('leaves colors in code as written, and places colors after code and mentions by where they are in the body', function () { + $body = 'Use `#fff` with @ada on #ff8a4c'; + + $blocks = CommitBody::blocks($body, [ + ['type' => 'text', 'text' => 'Use '], + ['type' => 'code', 'code' => '#fff'], + ['type' => 'text', 'text' => ' with @ada on #ff8a4c'], + ], [new Mention('ada', 16, 4)]); + + expect(array_map(fn (BodyRun $run): array => [$run->text, $run->isCode, $run->username, $run->color], $blocks[0]->runs))->toBe([ + ['Use ', false, null, null], + ['#fff', true, null, null], + [' with ', false, null, null], + ['@ada', false, 'ada', null], + [' on ', false, null, null], + ['#ff8a4c', false, null, 'ff8a4c'], + ]); +}); + +it('does not take a URL fragment for a color', function () { + expect(colorsOf('See https://example.com/#fff for #abc'))->toBe([['#abc', 'abc']]); +}); + /** * Each block as its kind and its text, runs joined. *