JA
games/xencenter-xenadmin-xcp-jumphp / src / Gui / Canvas.php · commit fb1b7c9
<?phpnamespace App\Gui;use JumPHP\Gui\Pointer;use JumPHP\Gui\Theme;use JumPHP\Gui\Widget;use JumPHP\Renderer\Renderer;/** * A widget that draws itself clipped to its box (SDL's binding has no clip rectangle: it draws into a texture of its size, as the * engine's scrolled containers do), with a vertical scroll (wheel, drag of its bar). Subclasses paint() in view coordinates and * give contentHeight(). */abstract class Canvas extends Widget implements \JumPHP\Gui\Overlay{    public ?PopupMenu $popup = null;    /** @var \Closure(array<string, mixed>): void|null */    private ?\Closure $onPick = null;    public const DEFAULTS = ['height' => 'fill', 'width' => 'fill', 'background' => '', 'border' => true];    public float $scrollY = 0;    /** @var resource|object|null */    private $texture = null;    /** @var array{0: int, 1: int} */    private array $textureSize = [0, 0];    private ?float $barDrag = null;    abstract protected function paint(Renderer $r, Theme $t): void;    /** The height of what is drawn (pixels), for the scroll. */    protected function contentHeight(Theme $t): float    {        return 0;    }    /** Where the scrolled part starts (a header that stays): pixels below $this->y. */    protected function scrollTop(Theme $t): float    {        return 0;    }    protected function content(Theme $t, float $maxWidth, string $breakpoint): array    {        return [$t->px(120), $t->px(80)];    }    public function stretches(string $axis): bool    {        return true;    }    protected function background(Theme $t): \JumPHP\Util\Color    {        return $this->tint($t, 'background', 'surface');    }    public function render(Renderer $r, Theme $t): void    {        $this->clampScroll($t);        $size = [(int)ceil($this->w), (int)ceil($this->h)];        if ($size[0] < 2 || $size[1] < 2) {            return;        }        if ($this->form?->sdlOrNull() === null) {            $this->paint($r, $t);            return;        }        if ($this->texture === null || $this->textureSize !== $size) {            if ($this->texture !== null) {                \SDL_DestroyTexture($this->texture);            }            $this->texture = $r->createTargetTexture($size[0], $size[1]);            $this->textureSize = $size;        }        $previous = $r->getTarget();        [$ox, $oy] = $r->translation();        $r->setTarget($this->texture);        $r->translate(-$ox - $this->x, -$oy - $this->y);        $r->clear($this->background($t));        $this->paint($r, $t);        $this->paintBar($r, $t);        if ($this->props['border']) {            $r->drawRect($this->x, $this->y, $this->w, $this->h, (string)$this->props['border'] === '1' || $this->props['border'] === true ? '#dde2ea' : (string)$this->props['border']);        }        $r->translate($ox + $this->x, $oy + $this->y);        $r->setTarget($previous);        $r->copy($this->texture, null, $this->x, $this->y, $this->w, $this->h);    }    protected function viewHeight(Theme $t): float    {        return $this->h - $this->scrollTop($t);    }    protected function clampScroll(Theme $t): void    {        $this->scrollY = max(0, min($this->scrollY, $this->contentHeight($t) - $this->viewHeight($t)));    }    private function paintBar(Renderer $r, Theme $t): void    {        $content = $this->contentHeight($t);        $view = $this->viewHeight($t);        if ($content <= $view + 1) {            return;        }        [$x, $y, $w, $h] = $this->thumb($t);        $t->box($r, $x, $y, $w, $h, $this->barDrag !== null ? '#8c96a3' : '#c9d0da', $w / 2);    }    /** @return array{0: float, 1: float, 2: float, 3: float} */    private function thumb(Theme $t): array    {        $content = max(1, $this->contentHeight($t));        $view = $this->viewHeight($t);        $h = max($t->px(24), $view * $view / $content);        $y = $this->y + $this->scrollTop($t) + ($view - $h) * $this->scrollY / max(1, $content - $view);        return [$this->x + $this->w - $t->px(8), $y, $t->px(6), $h];    }    public function wheel(float $notches): bool    {        if ($this->form === null) {            return false;        }        $before = $this->scrollY;        $this->scrollY -= $notches * $this->form->theme->px(60);        $this->clampScroll($this->form->theme);        return $before !== $this->scrollY;    }    /** The pointer is on the scroll bar: it drags it. */    protected function barDown(Pointer $p): bool    {        $t = $this->form?->theme;        if ($t === null || $this->contentHeight($t) <= $this->viewHeight($t) + 1 || $p->x < $this->x + $this->w - $t->px(12)) {            return false;        }        $this->barDrag = $p->y;        return true;    }    public function pointerDown(Pointer $p): void    {        if ($this->popup !== null) {            if ($this->popup->contains($p->x, $p->y)) {                return;            }            $this->close();          // a press beside the menu: it closes (and the press is an ordinary one)        }        $this->barDown($p);    }    public function pointerDrag(Pointer $p): void    {        $t = $this->form?->theme;        if ($this->barDrag === null || $t === null) {            return;        }        $dy = $p->y - $this->barDrag;        $this->barDrag = $p->y;        $view = $this->viewHeight($t);        $content = $this->contentHeight($t);        [, , , $thumbH] = $this->thumb($t);        $this->scrollY += $dy * ($content - $view) / max(1, $view - $thumbH);        $this->clampScroll($t);    }    /** @return bool true when the press was the bar's (not a click on the content) */    protected function barUp(): bool    {        $was = $this->barDrag !== null;        $this->barDrag = null;        return $was;    }    // a right click's menu --------------------------------------------------------------------------------------------    /** The form saw a right click on it: the subclass tells what is under the pointer (contextAt), the form fills the menu. */    public function openMenu(float $x, float $y): void    {        $this->contextAt($x, $y);        $this->emit('contextmenu', $x, $y);    }    /** A right click at this point: select what is there before the menu opens. */    protected function contextAt(float $x, float $y): void    {    }    /** @param list<array<string, mixed>|string> $items @param \Closure(array<string, mixed>): void $onPick */    public function popup(array $items, float $x, float $y, \Closure $onPick): void    {        if ($this->form === null || $items === []) {            return;        }        $this->popup = new PopupMenu($items, $x, $y, $this->form->theme, $this->form->viewWidth(), $this->form->viewHeight());        $this->onPick = $onPick;        $this->form->setOverlay($this);    }    public function renderOverlay(Renderer $r, Theme $t): void    {        $this->popup?->render($r, $t);    }    public function overlayContains(float $px, float $py): bool    {        return $this->popup !== null && $this->popup->contains($px, $py);    }    public function hoverAt(float $py): void    {        if ($this->popup !== null && $this->form !== null) {            $mouse = $this->form->engineOrNull()?->input->getMouse();            $inside = $mouse === null || $this->popup->contains($mouse->x, $py);            $this->popup->hover = $inside ? $this->popup->indexAt($this->form->theme, $py) : -1;        }    }    /** Escape closes an open menu (the widgets' keys() ask first). */    protected function popupKeys(Pointer $p): bool    {        if ($this->popup !== null && $p->pressed(Pointer::ESCAPE)) {            $this->close();            return true;        }        return $this->popup !== null;          // the arrows do not move the selection under an open menu    }    public function close(): void    {        $this->popup = null;        if ($this->form?->overlay === $this) {            $this->form->setOverlay(null);        }    }    protected bool $swallowUp = false;    /** @return bool the release was on the open menu (a command chosen, or nothing) */    protected function popupUp(Pointer $p): bool    {        if ($this->swallowUp) {            $this->swallowUp = false;            return true;        }        if ($this->popup === null || $this->form === null) {            return false;        }        if (!$this->popup->contains($p->x, $p->y)) {            return false;        }        $item = $this->popup->pick($this->form->theme, $p->y);        if ($item === null) {            return true;        }        $pick = $this->onPick;        $this->close();        if ($pick !== null) {            $pick($item);        }        return true;    }    /** A text cut with "…" to fit a width. */    public static function fit(\JumPHP\Renderer\TextDrawer $font, string $text, float $width): string    {        if ($width <= 0) {            return '';        }        if ($font->width($text) <= $width) {            return $text;        }        $lo = 0;        $hi = mb_strlen($text);        while ($lo < $hi) {            $mid = intdiv($lo + $hi + 1, 2);            $font->width(mb_substr($text, 0, $mid) . '…') <= $width ? $lo = $mid : $hi = $mid - 1;        }        return $lo === 0 ? '' : mb_substr($text, 0, $lo) . '…';    }}