JA
apps/caramail-chat / src / Gui / ChatList.php · commit 6eebcdb
<?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;    }}