): 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|string> $items @param \Closure(array): 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) . '…'; } }