'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> */ public array $columns = []; /** @var list> */ 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> $columns @param list> $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 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; } }