로그인
KO
games/xencenter-xenadmin-xcp-jumphp / src / Gui / TreeView.php · commit fb1b7c9
<?phpnamespace App\Gui;use JumPHP\Gui\Pointer;use JumPHP\Gui\Theme;use JumPHP\Renderer\Renderer;/** * The resource tree on the left of XenCenter: nodes with an icon, opened / closed with their [+] / [-] box (or a double click, * or the arrows), one selected. setNodes() takes nested nodes: ['id', 'text', 'icon', 'children' => [...], 'open' => bool (first * time), 'muted' => bool, 'bold' => bool, 'suffix' => ' (3)']. * Events: change (string $id), activate (string $id: double click / Return), contextmenu (x, y: the right click's node is selected). */final class TreeView extends Canvas{    public const DEFAULTS = ['height' => 'fill', 'width' => 'fill', 'background' => '#ffffff', 'border' => true];    /** @var list<array<string, mixed>> */    private array $roots = [];    /** @var list<array{node: array<string, mixed>, depth: int, parentId: ?string}> the rows shown */    private array $rows = [];    /** @var array<string, bool> */    public array $expanded = [];    public ?string $selected = null;    public static function events(): array    {        return ['change', 'activate', 'contextmenu', 'click', 'pick'];    }    public function focusable(): bool    {        return true;    }    /** @param list<array<string, mixed>> $roots */    public function setNodes(array $roots): void    {        $this->roots = $roots;        $this->flatten();    }    /** @return list<string> the ids of the rows shown, in order */    public function visibleIds(): array    {        return array_map(fn ($row) => (string)$row['node']['id'], $this->rows);    }    /** @return array<string, mixed>|null */    public function node(string $id): ?array    {        foreach ($this->walk($this->roots) as $node) {            if ($node['id'] === $id) {                return $node;            }        }        return null;    }    /** @return \Generator<array<string, mixed>> */    private function walk(array $nodes): \Generator    {        foreach ($nodes as $node) {            yield $node;            yield from $this->walk((array)($node['children'] ?? []));        }    }    public function expand(string $id, bool $open = true): void    {        $this->expanded[$id] = $open;        $this->flatten();    }    /** Selects a node (opening its parents), and shows it. */    public function select(?string $id, bool $emit = true): void    {        if ($id !== null) {            $path = $this->pathTo($this->roots, $id);            if ($path === null) {                return;            }            foreach ($path as $parent) {                $this->expanded[$parent] = true;            }            $this->flatten();        }        $changed = $id !== $this->selected;        $this->selected = $id;        $this->reveal();        if ($changed && $emit && $id !== null) {            $this->emit('change', $id);        }    }    /** @return list<string>|null the ids above a node */    private function pathTo(array $nodes, string $id): ?array    {        foreach ($nodes as $node) {            if ($node['id'] === $id) {                return [];            }            $below = $this->pathTo((array)($node['children'] ?? []), $id);            if ($below !== null) {                return [(string)$node['id'], ...$below];            }        }        return null;    }    private function flatten(): void    {        $this->rows = [];        $add = function (array $nodes, int $depth, ?string $parent) use (&$add): void {            foreach ($nodes as $node) {                $this->rows[] = ['node' => $node, 'depth' => $depth, 'parentId' => $parent];                $id = (string)$node['id'];                $open = !empty($node['force']) || ($this->expanded[$id] ??= (bool)($node['open'] ?? false));                if (!empty($node['children']) && $open) {                    $add((array)$node['children'], $depth + 1, $id);                }            }        };        $add($this->roots, 0, null);        if ($this->selected !== null && !in_array($this->selected, $this->visibleIds(), true)) {            // the selected node went away (a VM deleted) or is now hidden under a closed node: its nearest shown parent            $path = $this->pathTo($this->roots, $this->selected);            if ($path === null) {                $this->selected = null;            } else {                $shown = array_values(array_intersect($path, $this->visibleIds()));                if ($shown !== []) {                    $this->selected = end($shown);                    $this->emit('change', $this->selected);                }            }        }    }    public function rowHeight(Theme $t): float    {        return $t->px(26);    }    protected function contentHeight(Theme $t): float    {        return count($this->rows) * $this->rowHeight($t) + $t->px(4);    }    private function reveal(): void    {        $t = $this->form?->theme;        $index = array_search($this->selected, $this->visibleIds(), true);        if ($t === null || $index === false || $this->h <= 0) {            return;        }        $rh = $this->rowHeight($t);        $top = $index * $rh;        if ($top < $this->scrollY) {            $this->scrollY = $top;        } elseif ($top + $rh > $this->scrollY + $this->h - $t->px(4)) {            $this->scrollY = $top + $rh - $this->h + $t->px(4);        }    }    protected function paint(Renderer $r, Theme $t): void    {        $rh = $this->rowHeight($t);        $font = $t->font();        $bold = $t->font(null, true);        $indent = $t->px(18);        $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];            $node = $row['node'];            $y = $this->y + $t->px(2) + $i * $rh - $this->scrollY;            $x = $this->x + $t->px(4) + $row['depth'] * $indent;            $id = (string)$node['id'];            $selected = $id === $this->selected;            $f = !empty($node['bold']) ? $bold : $font;            $label = (string)$node['text'] . (string)($node['suffix'] ?? '');                        if ($selected) {                $t->box($r, $this->x + $t->px(4), $y + $t->px(1), $this->w - $t->px(8), $rh - $t->px(2), $this->focused ? '#d6e4fb' : '#e6edf8', $t->px(5));            }            if (!empty($node['children'])) {                $bx = $x + $t->px(3);                $by = $y + ($rh - $t->px(9)) / 2;                // a chevron: > closed, v open                $open = $this->expanded[$id] ?? false;                $cx = $bx + $t->px(4.5);                $cy = $by + $t->px(4.5);                $line = max(1, round($t->scale * 1.3));                for ($k = 0; $k < 4; $k++) {                    $open                        ? $r->fillRect($cx - $t->px(3.5) + $k * $t->px(1), $cy - $t->px(1.5) + $k * $t->px(1), $t->px(7) - 2 * $k * $t->px(1), $line, '#6b7480')                        : $r->fillRect($cx - $t->px(1.5) + $k * $t->px(1), $cy - $t->px(3.5) + $k * $t->px(1), $line, $t->px(7) - 2 * $k * $t->px(1), '#6b7480');                }            }            Icons::draw($r, (string)($node['icon'] ?? 'folder'), $x + $t->px(15), $y + ($rh - $t->px(16)) / 2, $t->px(16));            $color = $selected ? '#14325e' : (!empty($node['muted']) ? '#8a919c' : '#24292f');            $f->draw($r, self::fit($f, DataGrid::oneLine($label), $this->x + $this->w - $t->px(14) - ($x + $t->px(36))), $x + $t->px(36), $y + ($rh - $f->height()) / 2, 1, $color);        }    }    private function rowAt(float $py): ?int    {        $t = $this->form?->theme;        if ($t === null) {            return null;        }        $index = (int)floor(($py - $this->y - $t->px(2) + $this->scrollY) / $this->rowHeight($t));        return isset($this->rows[$index]) ? $index : null;    }    protected function contextAt(float $x, float $y): void    {        $index = $this->rowAt($y);        if ($index !== null) {            $this->select((string)$this->rows[$index]['node']['id']);        }    }    public function pointerUp(Pointer $p, bool $inside): void    {        if ($this->popupUp($p) || $this->barUp() || !$inside) {            return;        }        $index = $this->rowAt($p->y);        if ($index === null || $this->form === null) {            return;        }        $t = $this->form->theme;        $row = $this->rows[$index];        $id = (string)$row['node']['id'];        $boxX = $this->x + $t->px(4) + $row['depth'] * $t->px(18);        if (!empty($row['node']['children']) && $p->x >= $boxX && $p->x < $boxX + $t->px(15)) {            $this->expand($id, !($this->expanded[$id] ?? false));            return;        }        $this->select($id);        $this->emit('pick', $id);          // a row chosen by the user (a phone then shows its pane)        if ($p->double) {            if (!empty($row['node']['children'])) {                $this->expand($id, !($this->expanded[$id] ?? false));            }            $this->emit('activate', $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 = $this->visibleIds();        $found = $this->selected === null ? false : array_search($this->selected, $ids, true);        $at = $found === false ? -1 : (int)$found;        $used = false;        foreach ($p->keys as $key) {            $row = $this->rows[$at] ?? null;            $id = $row !== null ? (string)$row['node']['id'] : null;            switch ($key) {                case Pointer::UP:                    $at = max(0, $at - 1);                    break;                case Pointer::DOWN:                    $at = min(count($ids) - 1, $at + 1);                    break;                case Pointer::HOME:                    $at = 0;                    break;                case Pointer::END:                    $at = count($ids) - 1;                    break;                case Pointer::RIGHT:                    if ($id !== null && !empty($row['node']['children'])) {                        $this->expanded[$id] ?? false ? $at++ : $this->expand($id);                        $ids = $this->visibleIds();                    }                    break;                case Pointer::LEFT:                    if ($id !== null && !empty($row['node']['children']) && ($this->expanded[$id] ?? false)) {                        $this->expand($id, false);                        $ids = $this->visibleIds();                    } elseif ($row !== null && $row['parentId'] !== null) {                        $at = (int)array_search($row['parentId'], $ids, true);                    }                    break;                case Pointer::RETURN:                case Pointer::KP_ENTER:                    if ($id !== null) {                        $this->emit('activate', $id);                    }                    break;                default:                    continue 2;            }            $used = true;        }        if ($used && isset($ids[$at])) {            $this->select($ids[$at]);        }        return $used;    }}