Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions app/Devnull/Data/BodyRun.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
92 changes: 80 additions & 12 deletions app/Devnull/Data/CommitBody.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 = '/(?:(?<!\S)|(?<=\n>))[#\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.
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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) ?: [])
Expand Down Expand Up @@ -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<Mention> $mentions
* @param list<array{0: int, 1: int, 2: string}> $colors
* @return list<BodyRun>
*/
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) {
Expand All @@ -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<Mention> $mentions
* @param list<array{0: int, 1: int, 2: string}> $colors
* @return list<BodyRun>
*/
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;
Expand All @@ -308,20 +321,75 @@ 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);
$position = $offset + $mention->length;
}

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<array{0: int, 1: int, 2: string}> $colors
* @return list<BodyRun>
*/
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<array{0: int, 1: int, 2: string}>
*/
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
Expand Down
5 changes: 5 additions & 0 deletions resources/views/native/partials/commit-body.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -47,6 +50,8 @@
<native:text font="mono" class="text-[13px] text-theme-primary" @press="openLink('{{ $commit->id }}', {{ $linkIndex++ }})"><native:text :text="$run->link->prefix" class="text-theme-ink-muted" />&#x2060;<native:text :text="$run->link->shown" /></native:text>
@elseif ($run->link)
<native:text :text="$run->link->shown" class="text-theme-primary" @press="openLink('{{ $commit->id }}', {{ $linkIndex++ }})" />
@elseif ($run->isColor())
<native:text><native:text text="■" class="text-[#{{ $run->color }}] dark:text-[#{{ $run->color }}]" />&#x202F;<native:text :text="$run->text" /></native:text>
@elseif ($run->isWordmark)
@php($afterSpace = $loop->index > 0 && ctype_space(substr($block->runs[$loop->index - 1]->text, -1)))
<native:text font="semibold" class="font-semibold tracking-tight">@if ($afterSpace)&#x200B;@endif/&#x2060;dev<native:text text="/" class="text-outline text-theme-lane-0" />&#x2060;null</native:text>
Expand Down
9 changes: 6 additions & 3 deletions resources/views/native/partials/message-body.blade.php
Original file line number Diff line number Diff line change
@@ -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())
Expand All @@ -18,6 +19,8 @@
<native:text :text="$run->text" font="semibold" class="font-semibold {{ $mentionInk }}" @press="openProfile('{{ $run->username }}')" />
@elseif ($run->isCode)
<native:text :text="$run->text" font="mono" class="text-[13px]" />
@elseif ($run->isColor())
<native:text><native:text text="■" class="text-[#{{ $run->color }}] dark:text-[#{{ $run->color }}]" />&#x202F;<native:text :text="$run->text" /></native:text>
@else
<native:text :text="$run->text" />
@endif
Expand Down
18 changes: 18 additions & 0 deletions tests/Feature/Screens/CommitDetailTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
74 changes: 74 additions & 0 deletions tests/Unit/Devnull/CommitBodyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<array{0: string, 1: string}>
*/
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.
*
Expand Down
Loading