<?phpnamespace App\Gui;use App\Chat\Smileys;use App\Chat\Styled;use JumPHP\Gui\Pointer;use JumPHP\Gui\Theme;use JumPHP\Gui\Widget;use JumPHP\Renderer\Renderer;/** * The applet's list canvas (net.spray.chat.a.g), used for the room, the private talks, the members and the rooms: * items of coded text (Styled) with their smileys drawn, wrapped to the width, a white page in a grey frame, hover * (#eeeeff) and selection (#000080, white text), a tooltip, and the scroll: stuck to the bottom (`follow`) or not. * * Props: max (items kept, oldest dropped), selectable, follow (true: the newest line stays in sight), tooltips. * Events: change (int $index, array $item), activate (int $index, array $item) on a double click. * * An item: ['text' => coded line, 'bold' => bool, 'tip' => string, 'tipColor' => int, 'data' => mixed]. */final class ChatList extends Widget{ public const DEFAULTS = ['max' => 2000, 'selectable' => false, 'follow' => true, 'tooltips' => false, 'height' => 'fill', 'width' => 'fill']; public const MARGIN = 4; public const ROW = 19; // dp: the height of one line (k of the applet: the 16 pt bold font) public const BAR = 16; // dp: the scrollbar (net.spray.chat.a.j), always there private const HOVER = '#eeeeff'; private const SELECTED = '#000080'; /** @var list<array{text: string, bold?: bool, tip?: string, tipColor?: int, data?: mixed}> */ private array $items = []; /** @var list<list<list<array{0: string, 1: mixed, 2: float}>>> per item, its rows of runs: [kind text|smiley, value, width] */ private array $rows = []; private float $layoutWidth = -1; private float $layoutScale = -1; public float $scrollY = 0; public int $selected = -1; public int $hover = -1; private ?float $dragFrom = null; private float $dragged = 0; private bool $stick = true; public static function events(): array { return ['change', 'activate', 'click']; } // ---- the items ------------------------------------------------------------------------------------------ /** @param array{text: string, bold?: bool, tip?: string, tipColor?: int, data?: mixed} $item */ public function add(array $item): void { $this->items[] = $item; $max = (int)$this->props['max']; if ($max > 0 && count($this->items) > $max) { array_shift($this->items); $this->selected = max(-1, $this->selected - 1); } $this->layoutWidth = -1; } /** @param list<array{text: string, bold?: bool, tip?: string, tipColor?: int, data?: mixed}> $items */ public function setItems(array $items): void { $this->items = $items; $this->selected = min($this->selected, count($this->items) - 1); $this->layoutWidth = -1; } /** @return list<array{text: string, bold?: bool, tip?: string, tipColor?: int, data?: mixed}> */ public function items(): array { return $this->items; } public function count(): int { return count($this->items); } public function clear(): void { $this->setItems([]); $this->selected = -1; $this->scrollY = 0; } /** @return array{text: string, bold?: bool, tip?: string, tipColor?: int, data?: mixed}|null */ public function selectedItem(): ?array { return $this->items[$this->selected] ?? null; } /** The plain text of every item (tests). @return list<string> */ public function texts(): array { return array_map(static fn (array $i) => Styled::plain($i['text']), $this->items); } public function select(int $index): void { if ($index < 0 || $index >= count($this->items)) { return; } $this->selected = $index; $this->emit('change', $index, $this->items[$index]); } // ---- layout --------------------------------------------------------------------------------------------- protected function content(Theme $t, float $maxWidth, string $breakpoint): array { return [$t->px(160), $t->px(100)]; } private function relayout(Theme $t): void { $width = $this->w - 2 * $t->px(self::MARGIN) - $t->px(self::BAR); if ($width === $this->layoutWidth && $t->scale === $this->layoutScale) { return; } $this->layoutWidth = $width; $this->layoutScale = $t->scale; $this->rows = []; foreach ($this->items as $item) { $this->rows[] = $this->wrap($t, $item, max(20, $width)); } } /** @return list<list<array{0: string, 1: mixed, 2: float}>> */ private function wrap(Theme $t, array $item, float $width): array { $rows = [[]]; $x = 0.0; foreach (Styled::segments($item['text'], (bool)($item['bold'] ?? false)) as $seg) { $font = $t->font($seg['size'], $seg['bold']); $pieces = $seg['smileys'] ? Smileys::parse($seg['text']) : [$seg['text']]; foreach ($pieces as $piece) { if (is_array($piece)) { $w = $t->px(SmileyPainter::width($piece) + 2); if ($x + $w > $width && $x > 0) { $rows[] = []; $x = 0; } $rows[count($rows) - 1][] = ['smiley', $piece, $w]; $x += $w; continue; } // words, cut at the last space that fits (else anywhere), as the applet did $rest = $piece; while ($rest !== '') { $fit = $this->fitting($font, $rest, $width - $x); if ($fit === '') { if ($x === 0.0) { $fit = mb_substr($rest, 0, 1); } else { $rows[] = []; $x = 0; $rest = ltrim($rest, ' '); continue; } } $w = (float)$font->width($fit); $rows[count($rows) - 1][] = ['text', ['text' => $fit, 'seg' => $seg], $w]; $x += $w; $rest = (string)substr($rest, strlen($fit)); if ($rest !== '') { $rows[] = []; $x = 0; $rest = ltrim($rest, ' '); } } } } return $rows; } /** The longest beginning of $text that fits $room, ending at a space when it is cut. */ private function fitting(\JumPHP\Renderer\TextDrawer $font, string $text, float $room): string { if ($font->width($text) <= $room) { return $text; } $chars = mb_str_split($text); $lo = 0; $hi = count($chars); while ($lo < $hi) { $mid = intdiv($lo + $hi + 1, 2); if ($font->width(implode('', array_slice($chars, 0, $mid))) <= $room) { $lo = $mid; } else { $hi = $mid - 1; } } $cut = implode('', array_slice($chars, 0, $lo)); $space = strrpos($cut, ' '); return ($space !== false && $space > 0 && $lo < count($chars)) ? substr($cut, 0, $space + 1) : $cut; } private function rowHeight(Theme $t): float { return $t->px(self::ROW); } private function contentHeight(Theme $t): float { $n = 0; foreach ($this->rows as $rows) { $n += count($rows); } return $n * $this->rowHeight($t) + 2 * $t->px(self::MARGIN); } private function clampScroll(Theme $t): void { $max = max(0, $this->contentHeight($t) - $this->h); if ($this->props['follow'] && $this->stick) { $this->scrollY = $max; } $this->scrollY = max(0, min($this->scrollY, $max)); } // ---- drawing -------------------------------------------------------------------------------------------- public function render(Renderer $r, Theme $t): void { $this->relayout($t); $this->clampScroll($t); $r->fillRect($this->x, $this->y, $this->w, $this->h, '#ffffff'); $rh = $this->rowHeight($t); $m = $t->px(self::MARGIN); $row = 0; $tip = null; foreach ($this->rows as $index => $rows) { $top = $this->y + $m + $row * $rh - $this->scrollY; $height = count($rows) * $rh; $row += count($rows); if ($top + $height < $this->y || $top > $this->y + $this->h) { continue; } $selected = $index === $this->selected && $this->props['selectable']; if ($selected || ($index === $this->hover && ($this->props['selectable'] || $this->props['tooltips']))) { $y0 = max($this->y + 1, $top); $y1 = min($this->y + $this->h - 1, $top + $height); if ($y1 > $y0) { $r->fillRect($this->x + $m, $y0, $this->w - 2 * $m - $t->px(self::BAR), $y1 - $y0, $selected ? self::SELECTED : self::HOVER); } if ($index === $this->hover && ($this->items[$index]['tip'] ?? '') !== '') { $tip = [$this->items[$index], $top]; } } foreach ($rows as $n => $runs) { $y = $top + $n * $rh; if ($y < $this->y + $t->px(1) || $y + $rh > $this->y + $this->h - $t->px(1)) { continue; // no clipping in SDL's binding: a line cut by the edges is not drawn } $x = $this->x + $m; foreach ($runs as [$kind, $value, $w]) { if ($kind === 'smiley') { SmileyPainter::draw($r, $this->form?->sdlOrNull(), $value, $x + $t->px(1), $y + ($rh - $t->px(SmileyPainter::HEIGHT)) / 2, $t->scale); } else { $seg = $value['seg']; $font = $t->font($seg['size'], $seg['bold']); $color = $selected ? '#ffffff' : Styled::PALETTE[$seg['color']]; $font->draw($r, $value['text'], $x, $y + ($rh - $font->height()) / 2, 1, $color); } $x += $w; } } } if ($tip !== null && $this->props['tooltips']) { $this->drawTip($r, $t, $tip[0], $tip[1]); } $this->drawScrollBar($r, $t); // the grey frame $r->drawRect($this->x, $this->y, $this->w, $this->h, '#808080', Awt::px($t)); } private function drawTip(Renderer $r, Theme $t, array $item, float $top): void { $font = $t->font(10); $text = (string)$item['tip']; $w = $font->width($text) + $t->px(8); $h = $this->rowHeight($t); $x = $this->x + $t->px(14); $y = $top - $h; if ($y < $this->y) { $y = $top + $h; } $r->fillRect($x, $y, $w, $h, Styled::PALETTE[$item['tipColor'] ?? 15] ?? '#ffffc0'); $r->drawRect($x, $y, $w, $h, '#000000'); $font->draw($r, $text, $x + $t->px(4), $y + ($h - $font->height()) / 2, 1, '#000000'); } /** @return array{0: float, 1: float, 2: float, 3: float, 4: float} the bar's x, its track's top and height, the thumb's top and height */ private function bar(Theme $t): array { $bw = $t->px(self::BAR); $bx = $this->x + $this->w - $bw - $t->px(1); $top = $this->y + $t->px(1) + $t->px(13); $track = $this->h - 2 * $t->px(1) - 2 * $t->px(13); $content = $this->contentHeight($t); $visible = $this->h; if ($content <= $visible) { return [$bx, $top, $track, $top, $track]; } $thumb = max($t->px(8), $track * $visible / $content); $thumbY = $top + ($track - $thumb) * $this->scrollY / max(1, $content - $visible); return [$bx, $top, $track, $thumbY, $thumb]; } /** The applet's scrollbar: a dark sunken track, two raised arrow buttons, a raised thumb. */ private function drawScrollBar(Renderer $r, Theme $t): void { $px = Awt::px($t); [$bx, $top, $track, $thumbY, $thumb] = $this->bar($t); $bw = $t->px(self::BAR); $btn = $t->px(13); $r->fillRect($bx, $this->y + $t->px(1), $bw, $this->h - 2 * $t->px(1), '#868686'); // C.darker(): the applet's dark track Awt::raised($r, $bx, $this->y + $t->px(1), $bw, $btn, Awt::$face, $this->barHeld === 'up', $px); Awt::arrow($r, $bx + $bw / 2, $this->y + $t->px(1) + $btn / 2, 'up', $px); Awt::raised($r, $bx, $top + $track, $bw, $btn, Awt::$face, $this->barHeld === 'down', $px); Awt::arrow($r, $bx + $bw / 2, $top + $track + $btn / 2, 'down', $px); if ($this->contentHeight($t) > $this->h) { Awt::raised($r, $bx, $thumbY, $bw, $thumb, Awt::$face, false, $px); } } private string $barHeld = ''; private float $barRepeat = 0; private ?float $thumbFrom = null; private float $thumbStart = 0; private function scrollBy(Theme $t, float $dy): void { $this->scrollY += $dy; $this->stick = false; $this->clampScroll($t); $this->stick = $this->scrollY >= $this->contentHeight($t) - $this->h - 1; } // ---- the pointer ---------------------------------------------------------------------------------------- private function itemAt(float $py): int { if ($this->form === null) { return -1; } $t = $this->form->theme; $this->relayout($t); $line = (int)floor(($py - $this->y - $t->px(self::MARGIN) + $this->scrollY) / $this->rowHeight($t)); foreach ($this->rows as $index => $rows) { if ($line < count($rows)) { return $line >= 0 ? $index : -1; } $line -= count($rows); } return -1; } public function update(float $dt): void { parent::update($dt); // a held arrow repeats (every 100 ms after a moment), as the applet's scrollbar thread did if ($this->barHeld !== '' && $this->form !== null) { $this->barRepeat -= $dt; if ($this->barRepeat <= 0) { $this->barRepeat = 0.1; $this->scrollBy($this->form->theme, ($this->barHeld === 'up' ? -1 : 1) * $this->rowHeight($this->form->theme)); } } $mouse = $this->hovered ? $this->form?->engineOrNull()?->input->getMouse() : null; $this->hover = $mouse !== null ? $this->itemAt((float)$mouse->y) : -1; } public function wheel(float $notches): bool { if ($this->form === null) { return false; } $t = $this->form->theme; $before = $this->scrollY; $this->stick = false; $this->scrollY -= $notches * 3 * $this->rowHeight($t); $this->clampScroll($t); $this->stick = $this->scrollY >= $this->contentHeight($t) - $this->h - 1; return $this->scrollY !== $before; } public function pointerDown(Pointer $p): void { $this->dragFrom = $p->y; $this->dragged = 0; $t = $this->form?->theme; if ($t === null) { return; } [$bx, $top, $track, $thumbY, $thumb] = $this->bar($t); if ($p->x < $bx) { return; } $this->dragFrom = null; $rh = $this->rowHeight($t); if ($p->y < $top) { $this->barHeld = 'up'; $this->scrollBy($t, -$rh); } elseif ($p->y > $top + $track) { $this->barHeld = 'down'; $this->scrollBy($t, $rh); } elseif ($p->y < $thumbY) { $this->scrollBy($t, -($this->h - $rh)); } elseif ($p->y > $thumbY + $thumb) { $this->scrollBy($t, $this->h - $rh); } else { $this->thumbFrom = $p->y; $this->thumbStart = $this->scrollY; } $this->barRepeat = 0.4; } public function pointerDrag(Pointer $p): void { if ($this->thumbFrom !== null && $this->form !== null) { $t = $this->form->theme; [, , $track, , $thumb] = $this->bar($t); $range = max(1, $this->contentHeight($t) - $this->h); $this->scrollY = $this->thumbStart + ($p->y - $this->thumbFrom) * $range / max(1, $track - $thumb); $this->scrollBy($t, 0); return; } if ($this->dragFrom === null || $this->form === null) { return; } $dy = $p->y - $this->dragFrom; $this->dragFrom = $p->y; $this->dragged += abs($dy); if ($this->dragged > 4) { $t = $this->form->theme; $this->stick = false; $this->scrollY -= $dy; $this->clampScroll($t); $this->stick = $this->scrollY >= $this->contentHeight($t) - $this->h - 1; } } public function pointerUp(Pointer $p, bool $inside): void { $wasBar = $this->barHeld !== '' || $this->thumbFrom !== null || ($this->form !== null && $p->x >= $this->bar($this->form->theme)[0]); $this->dragFrom = null; $this->barHeld = ''; $this->thumbFrom = null; if (!$inside || $this->dragged > 4 || $wasBar) { return; } $index = $this->itemAt($p->y); if ($index >= 0) { if ($this->props['selectable']) { $this->select($index); } if ($p->double) { $this->emit('activate', $index, $this->items[$index]); } } $this->click($p); } /** Scrolls to the newest line and keeps following (a new room, a sentence sent). */ public function toBottom(): void { $this->stick = true; }}