[...], '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> */ private array $roots = []; /** @var list, depth: int, parentId: ?string}> the rows shown */ private array $rows = []; /** @var array */ 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> $roots */ public function setNodes(array $roots): void { $this->roots = $roots; $this->flatten(); } /** @return list 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|null */ public function node(string $id): ?array { foreach ($this->walk($this->roots) as $node) { if ($node['id'] === $id) { return $node; } } return null; } /** @return \Generator> */ 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|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; } }