登录
ZH
games/xencenter-xenadmin-xcp-jumphp / src / Gui / DataGrid.php · commit fb1b7c9
<?phpnamespace App\Gui;use JumPHP\Gui\Pointer;use JumPHP\Gui\Theme;use JumPHP\Renderer\Renderer;/** * A table with a header (the Storage, Networking, Snapshots, Search, Events lists): columns [['title' => 'Name', 'width' => 3, * 'align' => 'right'], ...] (widths share the room), rows [['id' => 'OpaqueRef:...', 'cells' => [...], 'icon' => 'vm-running', * 'muted' => bool]]. A cell is a text, or ['bar' => 0.42, 'text' => '42%', 'color' => '#...'] (a usage bar), or ['icon' => .., 'text' => ..]. * A click on a header sorts by it. Events: change (?string $id), activate (string $id), contextmenu (x, y). * * @property string $empty */final class DataGrid extends Canvas{    public const DEFAULTS = ['height' => 'fill', 'width' => 'fill', 'background' => '#ffffff', 'border' => true, 'empty' => ''];    /** @var list<array<string, mixed>> */    public array $columns = [];    /** @var list<array<string, mixed>> */    public array $rows = [];    public ?string $selected = null;    public int $sortColumn = -1;    public bool $sortDescending = false;    public static function events(): array    {        return ['change', 'activate', 'contextmenu', 'click'];    }    public function focusable(): bool    {        return true;    }    /** @param list<array<string, mixed>> $columns @param list<array<string, mixed>> $rows */    public function set(array $columns, array $rows): void    {        if (array_column($columns, 'title') !== array_column($this->columns, 'title')) {            $this->sortColumn = -1;            $this->scrollY = 0;        }        $this->columns = $columns;        $this->rows = $rows;        $this->sort();        if ($this->selected !== null && !in_array($this->selected, array_column($this->rows, 'id'), true)) {            $this->selected = null;        }        $this->form?->invalidate();    }    public function selectedRow(): ?array    {        foreach ($this->rows as $row) {            if (($row['id'] ?? null) === $this->selected) {                return $row;            }        }        return null;    }    public function select(?string $id): void    {        if ($id !== $this->selected) {            $this->selected = $id;            $this->emit('change', $id);        }    }    private function sort(): void    {        if ($this->sortColumn < 0) {            return;        }        $c = $this->sortColumn;        $key = function (array $row) use ($c): mixed {            $cell = $row['sort'][$c] ?? $row['cells'][$c] ?? '';            return is_array($cell) ? ($cell['sort'] ?? $cell['bar'] ?? $cell['text'] ?? '') : $cell;        };        // numbers as numbers (0.42 < 0.5, sizes over 2 GiB), the rest as natural text        $compare = fn (mixed $a, mixed $b): int => is_numeric($a) && is_numeric($b) ? ((float)$a <=> (float)$b) : strnatcasecmp((string)$a, (string)$b);        usort($this->rows, fn ($a, $b) => ($this->sortDescending ? -1 : 1) * $compare($key($a), $key($b)));    }    private function headerHeight(Theme $t): float    {        return $t->px(32);    }    public function rowHeight(Theme $t): float    {        return $t->px(30);    }    protected function scrollTop(Theme $t): float    {        return $this->headerHeight($t);    }    protected function contentHeight(Theme $t): float    {        return count($this->rows) * $this->rowHeight($t) + $t->px(2);    }    /** @return list<array{0: float, 1: float}> x and width of each column */    private function columnBoxes(Theme $t): array    {        $total = 0;        foreach ($this->columns as $col) {            $total += (float)($col['width'] ?? 1);        }        $room = $this->w - $t->px(12);        $x = $this->x;        $out = [];        foreach ($this->columns as $col) {            $w = $room * (float)($col['width'] ?? 1) / max(0.01, $total);            $out[] = [$x, $w];            $x += $w;        }        return $out;    }    protected function paint(Renderer $r, Theme $t): void    {        $font = $t->font();        $boxes = $this->columnBoxes($t);        $hh = $this->headerHeight($t);        $rh = $this->rowHeight($t);        $pad = $t->px(10);        $top = $this->y + $hh;        $first = max(0, (int)floor($this->scrollY / $rh));        $last = min(count($this->rows) - 1, (int)ceil(($this->scrollY + $this->h) / $rh));        for ($i = $first; $i <= $last; $i++) {            $row = $this->rows[$i];            $y = $top + $i * $rh - $this->scrollY;            $selected = ($row['id'] ?? null) === $this->selected && $this->selected !== null;            if ($selected) {                $r->fillRect($this->x + 1, $y, $this->w - 2, $rh, $this->focused ? '#dce8fb' : '#ebf1fb');            }            $r->fillRect($this->x + $t->px(6), $y + $rh - 1, $this->w - $t->px(12), 1, '#eef0f3');            foreach ($boxes as $c => [$x, $w]) {                $cell = $row['cells'][$c] ?? '';                $cx = $x + $pad;                $cw = $w - 2 * $pad;                if ($c === 0 && !empty($row['icon'])) {                    Icons::draw($r, (string)$row['icon'], $cx, $y + ($rh - $t->px(16)) / 2, $t->px(16));                    $cx += $t->px(22);                    $cw -= $t->px(22);                }                $color = !empty($row['muted']) ? '#8a8a8a' : (string)($row['color'] ?? '#1a1a1a');                if (is_array($cell) && isset($cell['icon'])) {                    Icons::draw($r, (string)$cell['icon'], $cx, $y + ($rh - $t->px(16)) / 2, $t->px(16));                    $cx += $t->px(22);                    $cw -= $t->px(22);                }                if (is_array($cell) && isset($cell['bar'])) {                    $ratio = max(0.0, min(1.0, (float)$cell['bar']));                    $bh = $rh - $t->px(12);                    $by = $y + $t->px(6);                    $t->box($r, $cx, $by, $cw, $bh, '#e9edf2', $t->px(4));                    if ($ratio > 0) {                        $t->box($r, $cx, $by, max($t->px(8), $cw * $ratio), $bh, (string)($cell['color'] ?? ($ratio > 0.9 ? '#e5735f' : '#7cc47f')), $t->px(4));                    }                    $text = (string)($cell['text'] ?? '');                    $font->draw($r, self::fit($font, $text, $cw), $cx + $cw / 2, $y + ($rh - $font->height()) / 2, 1, '#1a1a1a', 'center');                    continue;                }                $text = self::oneLine(is_array($cell) ? (string)($cell['text'] ?? '') : (string)$cell);                $text = self::fit($font, $text, $cw);                $align = (string)($this->columns[$c]['align'] ?? 'left');                $font->draw($r, $text, $align === 'right' ? $cx + $cw : $cx, $y + ($rh - $font->height()) / 2, 1, is_array($cell) && isset($cell['color']) ? (string)$cell['color'] : $color, $align);            }        }        if ($this->rows === [] && $this->props['empty'] !== '') {            $t->font(null)->draw($r, \JumPHP\Util\Lang::t((string)$this->props['empty']), $this->x + $this->w / 2, $top + $t->px(18), 1, '#8a8a8a', 'center');        }        // the header stays on top        $r->fillRect($this->x, $this->y, $this->w, $hh, '#f7f8fa');        $r->fillRect($this->x, $this->y + $hh - 1, $this->w, 1, '#e1e5eb');        $font = $t->font($t->fontSize * 0.95, true);        foreach ($boxes as $c => [$x, $w]) {            $title = \JumPHP\Util\Lang::t((string)$this->columns[$c]['title']);            $align = (string)($this->columns[$c]['align'] ?? 'left');            $font->draw($r, self::fit($font, $title, $w - 2 * $pad - $t->px(10)), $align === 'right' ? $x + $w - $pad : $x + $pad, $this->y + ($hh - $font->height()) / 2, 1, '#57606a', $align);            if ($c > 0) {                $r->fillRect($x, $this->y + $t->px(9), 1, $hh - $t->px(18), '#dde2e8');            }            if ($c === $this->sortColumn) {                $ax = $align === 'right' ? $x + $t->px(8) : $x + $w - $t->px(12);                for ($k = 0; $k < 4; $k++) {                    $ay = $this->sortDescending ? $this->y + $hh / 2 - $t->px(2) + $k : $this->y + $hh / 2 + $t->px(2) - $k;                    $r->fillRect($ax + $k * $t->scale, $ay, max(1, $t->px(7) - 2 * $k * $t->scale), 1, '#606060');                }            }        }    }    /** A cell is one line: line breaks and tabs (XAPI's message bodies have them) become spaces, else the text runs over the rows below. */    public static function oneLine(string $text): string    {        return trim((string)preg_replace('/\s+/u', ' ', $text));    }    private function rowAt(float $py): ?int    {        $t = $this->form?->theme;        if ($t === null || $py < $this->y + $this->headerHeight($t)) {            return null;        }        $index = (int)floor(($py - $this->y - $this->headerHeight($t) + $this->scrollY) / $this->rowHeight($t));        return isset($this->rows[$index]) ? $index : null;    }    protected function contextAt(float $x, float $y): void    {        $index = $this->rowAt($y);        $this->select($index === null ? null : (string)($this->rows[$index]['id'] ?? ''));    }    public function pointerUp(Pointer $p, bool $inside): void    {        if ($this->popupUp($p) || $this->barUp() || !$inside || $this->form === null) {            return;        }        $t = $this->form->theme;        if ($p->y < $this->y + $this->headerHeight($t)) {            foreach ($this->columnBoxes($t) as $c => [$x, $w]) {                if ($p->x >= $x && $p->x < $x + $w) {                    $this->sortDescending = $this->sortColumn === $c && !$this->sortDescending;                    $this->sortColumn = $c;                    $this->sort();                }            }            return;        }        $index = $this->rowAt($p->y);        $this->select($index === null ? null : (string)($this->rows[$index]['id'] ?? ''));        if ($index !== null && $p->double) {            $this->emit('activate', (string)($this->rows[$index]['id'] ?? ''));        }        $this->emit('click');    }    public function keys(Pointer $p): bool    {        if ($this->popup !== null) {            if ($p->pressed(Pointer::ESCAPE)) {                $this->close();            }            return true;           // the keys do not move the selection under an open menu        }        if ($this->popupKeys($p)) {            return true;        }        $ids = array_map(fn ($row) => (string)($row['id'] ?? ''), $this->rows);        if ($ids === []) {            return false;        }        $at = $this->selected === null ? -1 : (int)array_search($this->selected, $ids, true);        foreach ($p->keys as $key) {            if ($key === Pointer::UP) {                $at = max(0, $at - 1);            } elseif ($key === Pointer::DOWN) {                $at = min(count($ids) - 1, $at + 1);            } elseif (($key === Pointer::RETURN || $key === Pointer::KP_ENTER) && $this->selected !== null) {                $this->emit('activate', $this->selected);                return true;            } else {                continue;            }            $this->select($ids[$at]);            $t = $this->form?->theme;            if ($t !== null) {                $rh = $this->rowHeight($t);                $this->scrollY = max(min($this->scrollY, $at * $rh), ($at + 1) * $rh - $this->viewHeight($t) + $t->px(2));            }            return true;        }        return false;    }}