From b514c5620e1b3b61221b0024dc88def26d9654f4 Mon Sep 17 00:00:00 2001 From: Jerry Ma Date: Fri, 18 Aug 2023 21:32:23 +0800 Subject: [PATCH] Add view state for scroll prompts (#43) * Add view state for scroll prompts * Fix search and suggest prompt view state not create bug * Add reset for suggest and search prompt * Fix search and suggest view state reset time * Add zero count reset for view state * Use view state instead of highlighted value to calculate scrollbar * Formatting * Refactor --------- Co-authored-by: Jess Archer --- src/MultiSelectPrompt.php | 27 +++++++++ src/SearchPrompt.php | 27 +++++++++ src/SelectPrompt.php | 27 +++++++++ src/SuggestPrompt.php | 27 +++++++++ .../Default/Concerns/DrawsScrollbars.php | 57 +++++++------------ .../Default/MultiSelectPromptRenderer.php | 18 +++--- src/Themes/Default/SearchPromptRenderer.php | 23 +++++--- src/Themes/Default/SelectPromptRenderer.php | 22 ++++--- src/Themes/Default/SuggestPromptRenderer.php | 12 ++-- 9 files changed, 173 insertions(+), 67 deletions(-) diff --git a/src/MultiSelectPrompt.php b/src/MultiSelectPrompt.php index 0d4614e1..8c77184c 100644 --- a/src/MultiSelectPrompt.php +++ b/src/MultiSelectPrompt.php @@ -12,6 +12,11 @@ class MultiSelectPrompt extends Prompt */ public int $highlighted = 0; + /** + * The index of the first visible option. + */ + public int $firstVisible = 0; + /** * The options for the multi-select prompt. * @@ -85,6 +90,16 @@ public function labels(): array return array_values(array_intersect_key($this->options, array_flip($this->values))); } + /** + * The currently visible options. + * + * @return array + */ + public function visible(): array + { + return array_slice($this->options, $this->firstVisible, $this->scroll, preserve_keys: true); + } + /** * Check whether the value is currently highlighted. */ @@ -111,6 +126,12 @@ public function isSelected(string $value): bool protected function highlightPrevious(): void { $this->highlighted = $this->highlighted === 0 ? count($this->options) - 1 : $this->highlighted - 1; + + if ($this->highlighted < $this->firstVisible) { + $this->firstVisible--; + } elseif ($this->highlighted === count($this->options) - 1) { + $this->firstVisible = count($this->options) - min($this->scroll, count($this->options)); + } } /** @@ -119,6 +140,12 @@ protected function highlightPrevious(): void protected function highlightNext(): void { $this->highlighted = $this->highlighted === count($this->options) - 1 ? 0 : $this->highlighted + 1; + + if ($this->highlighted > $this->firstVisible + $this->scroll - 1) { + $this->firstVisible++; + } elseif ($this->highlighted === 0) { + $this->firstVisible = 0; + } } /** diff --git a/src/SearchPrompt.php b/src/SearchPrompt.php index d76f08ff..80cc7e94 100644 --- a/src/SearchPrompt.php +++ b/src/SearchPrompt.php @@ -14,6 +14,11 @@ class SearchPrompt extends Prompt */ public ?int $highlighted = null; + /** + * The index of the first visible option. + */ + public int $firstVisible = 0; + /** * The cached matches. * @@ -89,6 +94,16 @@ public function matches(): array return $this->matches = ($this->options)($this->typedValue); } + /** + * The currently visible matches. + * + * @return array + */ + public function visible(): array + { + return array_slice($this->matches(), $this->firstVisible, $this->scroll, preserve_keys: true); + } + /** * Highlight the previous entry, or wrap around to the last entry. */ @@ -103,6 +118,12 @@ protected function highlightPrevious(): void } else { $this->highlighted = $this->highlighted - 1; } + + if ($this->highlighted < $this->firstVisible) { + $this->firstVisible--; + } elseif ($this->highlighted === count($this->matches) - 1) { + $this->firstVisible = count($this->matches) - min($this->scroll, count($this->matches)); + } } /** @@ -117,6 +138,12 @@ protected function highlightNext(): void } else { $this->highlighted = $this->highlighted === count($this->matches) - 1 ? null : $this->highlighted + 1; } + + if ($this->highlighted > $this->firstVisible + $this->scroll - 1) { + $this->firstVisible++; + } elseif ($this->highlighted === 0 || $this->highlighted === null) { + $this->firstVisible = 0; + } } /** diff --git a/src/SelectPrompt.php b/src/SelectPrompt.php index dfb4a065..d8cb24d8 100644 --- a/src/SelectPrompt.php +++ b/src/SelectPrompt.php @@ -12,6 +12,11 @@ class SelectPrompt extends Prompt */ public int $highlighted = 0; + /** + * The index of the first visible option. + */ + public int $firstVisible = 0; + /** * The options for the select prompt. * @@ -74,12 +79,28 @@ public function label(): ?string } } + /** + * The currently visible options. + * + * @return array + */ + public function visible(): array + { + return array_slice($this->options, $this->firstVisible, $this->scroll, preserve_keys: true); + } + /** * Highlight the previous entry, or wrap around to the last entry. */ protected function highlightPrevious(): void { $this->highlighted = $this->highlighted === 0 ? count($this->options) - 1 : $this->highlighted - 1; + + if ($this->highlighted < $this->firstVisible) { + $this->firstVisible--; + } elseif ($this->highlighted === count($this->options) - 1) { + $this->firstVisible = count($this->options) - min($this->scroll, count($this->options)); + } } /** @@ -88,5 +109,11 @@ protected function highlightPrevious(): void protected function highlightNext(): void { $this->highlighted = $this->highlighted === count($this->options) - 1 ? 0 : $this->highlighted + 1; + + if ($this->highlighted > $this->firstVisible + $this->scroll - 1) { + $this->firstVisible++; + } elseif ($this->highlighted === 0) { + $this->firstVisible = 0; + } } } diff --git a/src/SuggestPrompt.php b/src/SuggestPrompt.php index 0cd42655..6e1f4237 100644 --- a/src/SuggestPrompt.php +++ b/src/SuggestPrompt.php @@ -15,6 +15,11 @@ class SuggestPrompt extends Prompt */ public ?int $highlighted = null; + /** + * The index of the first visible option. + */ + public int $firstVisible = 0; + /** * The options for the suggest prompt. * @@ -98,6 +103,16 @@ public function matches(): array })); } + /** + * The current visible matches. + * + * @return array + */ + public function visible(): array + { + return array_slice($this->matches(), $this->firstVisible, $this->scroll, preserve_keys: true); + } + /** * Highlight the previous entry, or wrap around to the last entry. */ @@ -112,6 +127,12 @@ protected function highlightPrevious(): void } else { $this->highlighted = $this->highlighted - 1; } + + if ($this->highlighted < $this->firstVisible) { + $this->firstVisible--; + } elseif ($this->highlighted === count($this->matches()) - 1) { + $this->firstVisible = count($this->matches()) - min($this->scroll, count($this->matches())); + } } /** @@ -126,6 +147,12 @@ protected function highlightNext(): void } else { $this->highlighted = $this->highlighted === count($this->matches()) - 1 ? null : $this->highlighted + 1; } + + if ($this->highlighted > $this->firstVisible + $this->scroll - 1) { + $this->firstVisible++; + } elseif ($this->highlighted === 0 || $this->highlighted === null) { + $this->firstVisible = 0; + } } /** diff --git a/src/Themes/Default/Concerns/DrawsScrollbars.php b/src/Themes/Default/Concerns/DrawsScrollbars.php index 84e4811d..13836994 100644 --- a/src/Themes/Default/Concerns/DrawsScrollbars.php +++ b/src/Themes/Default/Concerns/DrawsScrollbars.php @@ -7,66 +7,49 @@ trait DrawsScrollbars { /** - * Scroll the given lines. + * Render a scrollbar beside the visible items. * - * @param \Illuminate\Support\Collection $lines + * @param \Illuminate\Support\Collection $visible * @return \Illuminate\Support\Collection */ - protected function scroll(Collection $lines, ?int $focused, int $height, int $width, string $color = 'cyan'): Collection + protected function scrollbar(Collection $visible, int $firstVisible, int $height, int $total, int $width, string $color = 'cyan'): Collection { - if ($lines->count() <= $height) { - return $lines; + if ($height >= $total) { + return $visible; } - $visible = $this->visible($lines, $focused, $height); + $scrollPosition = $this->scrollPosition($firstVisible, $height, $total); return $visible + ->values() ->map(fn ($line) => $this->pad($line, $width)) - ->map(fn ($line, $index) => match (true) { - $index === $this->scrollPosition($visible, $focused, $height, $lines->count()) => preg_replace('/.$/', $this->{$color}('┃'), $line), + ->map(fn ($line, $index) => match ($index) { + $scrollPosition => preg_replace('/.$/', $this->{$color}('┃'), $line), default => preg_replace('/.$/', $this->gray('│'), $line), }); } /** - * Get a scrolled version of the items. - * - * @param \Illuminate\Support\Collection $lines - * @return \Illuminate\Support\Collection + * Return the position where the scrollbar "handle" should be rendered. */ - protected function visible(Collection $lines, ?int $focused, int $height): Collection + protected function scrollPosition(int $firstVisible, int $height, int $total): int { - if ($lines->count() <= $height) { - return $lines; - } - - if ($focused === null || $focused < $height) { - return $lines->slice(0, $height); + if ($firstVisible === 0) { + return 0; } - return $lines->slice($focused - $height + 1, $height); - } + $maxPosition = $total - $height; - /** - * Scroll the given lines. - * - * @param \Illuminate\Support\Collection $visible - */ - protected function scrollPosition(Collection $visible, ?int $focused, int $height, int $total): int - { - if ($focused < $height) { - return 0; + if ($firstVisible === $maxPosition) { + return $height - 1; } - if ($focused === $total - 1) { - return $total - 1; + if ($height <= 2) { + return -1; } - $percent = ($focused + 1 - $height) / ($total - $height); - - $keys = $visible->slice(1, -1)->keys(); - $position = (int) ceil($percent * count($keys) - 1); + $percent = $firstVisible / $maxPosition; - return $keys[$position] ?? 0; + return (int) round($percent * ($height - 3)) + 1; } } diff --git a/src/Themes/Default/MultiSelectPromptRenderer.php b/src/Themes/Default/MultiSelectPromptRenderer.php index 55dcc53a..d8a6c7ef 100644 --- a/src/Themes/Default/MultiSelectPromptRenderer.php +++ b/src/Themes/Default/MultiSelectPromptRenderer.php @@ -14,6 +14,8 @@ class MultiSelectPromptRenderer extends Renderer */ public function __invoke(MultiSelectPrompt $prompt): string { + $prompt->scroll = min($prompt->scroll, $prompt->terminal()->lines() - 5); + return match ($prompt->state) { 'submit' => $this ->box( @@ -55,11 +57,11 @@ public function __invoke(MultiSelectPrompt $prompt): string */ protected function renderOptions(MultiSelectPrompt $prompt): string { - return $this->scroll( - collect($prompt->options) - ->values() + return $this->scrollbar( + collect($prompt->visible()) ->map(fn ($label) => $this->truncate($label, $prompt->terminal()->cols() - 12)) - ->map(function ($label, $index) use ($prompt) { + ->map(function ($label, $key) use ($prompt) { + $index = array_search($key, array_keys($prompt->options)); $active = $index === $prompt->highlighted; if (array_is_list($prompt->options)) { $value = $prompt->options[$index]; @@ -83,9 +85,11 @@ protected function renderOptions(MultiSelectPrompt $prompt): string $selected => " {$this->cyan('◼')} {$this->dim($label)} ", default => " {$this->dim('◻')} {$this->dim($label)} ", }; - }), - $prompt->highlighted, - min($prompt->scroll, $prompt->terminal()->lines() - 5), + }) + ->values(), + $prompt->firstVisible, + $prompt->scroll, + count($prompt->options), min($this->longest($prompt->options, padding: 6), $prompt->terminal()->cols() - 6), $prompt->state === 'cancel' ? 'dim' : 'cyan' )->implode(PHP_EOL); diff --git a/src/Themes/Default/SearchPromptRenderer.php b/src/Themes/Default/SearchPromptRenderer.php index a779af03..7142fa57 100644 --- a/src/Themes/Default/SearchPromptRenderer.php +++ b/src/Themes/Default/SearchPromptRenderer.php @@ -14,6 +14,7 @@ class SearchPromptRenderer extends Renderer */ public function __invoke(SearchPrompt $prompt): string { + $prompt->scroll = min($prompt->scroll, $prompt->terminal()->lines() - 7); $maxWidth = $prompt->terminal()->cols() - 6; return match ($prompt->state) { @@ -105,16 +106,20 @@ protected function renderOptions(SearchPrompt $prompt): string return $this->gray(' '.($prompt->state === 'searching' ? 'Searching...' : 'No results.')); } - return $this->scroll( - collect($prompt->matches()) - ->values() + return $this->scrollbar( + collect($prompt->visible()) ->map(fn ($label) => $this->truncate($label, $prompt->terminal()->cols() - 10)) - ->map(fn ($label, $i) => $prompt->highlighted === $i - ? "{$this->cyan('›')} {$label} " - : " {$this->dim($label)} " - ), - $prompt->highlighted, - min($prompt->scroll, $prompt->terminal()->lines() - 7), + ->map(function ($label, $key) use ($prompt) { + $index = array_search($key, array_keys($prompt->matches())); + + return $prompt->highlighted === $index + ? "{$this->cyan('›')} {$label} " + : " {$this->dim($label)} "; + }) + ->values(), + $prompt->firstVisible, + $prompt->scroll, + count($prompt->matches()), min($this->longest($prompt->matches(), padding: 4), $prompt->terminal()->cols() - 6) )->implode(PHP_EOL); } diff --git a/src/Themes/Default/SelectPromptRenderer.php b/src/Themes/Default/SelectPromptRenderer.php index 34b87939..a7b73433 100644 --- a/src/Themes/Default/SelectPromptRenderer.php +++ b/src/Themes/Default/SelectPromptRenderer.php @@ -14,6 +14,7 @@ class SelectPromptRenderer extends Renderer */ public function __invoke(SelectPrompt $prompt): string { + $prompt->scroll = min($prompt->scroll, $prompt->terminal()->lines() - 5); $maxWidth = $prompt->terminal()->cols() - 6; return match ($prompt->state) { @@ -57,24 +58,27 @@ public function __invoke(SelectPrompt $prompt): string */ protected function renderOptions(SelectPrompt $prompt): string { - return $this->scroll( - collect($prompt->options) - ->values() + return $this->scrollbar( + collect($prompt->visible()) ->map(fn ($label) => $this->truncate($label, $prompt->terminal()->cols() - 12)) - ->map(function ($label, $i) use ($prompt) { + ->map(function ($label, $key) use ($prompt) { + $index = array_search($key, array_keys($prompt->options)); + if ($prompt->state === 'cancel') { - return $this->dim($prompt->highlighted === $i + return $this->dim($prompt->highlighted === $index ? "› ● {$this->strikethrough($label)} " : " ○ {$this->strikethrough($label)} " ); } - return $prompt->highlighted === $i + return $prompt->highlighted === $index ? "{$this->cyan('›')} {$this->cyan('●')} {$label} " : " {$this->dim('○')} {$this->dim($label)} "; - }), - $prompt->highlighted, - min($prompt->scroll, $prompt->terminal()->lines() - 5), + }) + ->values(), + $prompt->firstVisible, + $prompt->scroll, + count($prompt->options), min($this->longest($prompt->options, padding: 6), $prompt->terminal()->cols() - 6), $prompt->state === 'cancel' ? 'dim' : 'cyan' )->implode(PHP_EOL); diff --git a/src/Themes/Default/SuggestPromptRenderer.php b/src/Themes/Default/SuggestPromptRenderer.php index 46ff34d4..be04221e 100644 --- a/src/Themes/Default/SuggestPromptRenderer.php +++ b/src/Themes/Default/SuggestPromptRenderer.php @@ -14,6 +14,7 @@ class SuggestPromptRenderer extends Renderer */ public function __invoke(SuggestPrompt $prompt): string { + $prompt->scroll = min($prompt->scroll, $prompt->terminal()->lines() - 7); $maxWidth = $prompt->terminal()->cols() - 6; return match ($prompt->state) { @@ -96,15 +97,16 @@ protected function renderOptions(SuggestPrompt $prompt): string return ''; } - return $this->scroll( - collect($prompt->matches()) + return $this->scrollbar( + collect($prompt->visible()) ->map(fn ($label) => $this->truncate($label, $prompt->terminal()->cols() - 10)) - ->map(fn ($label, $i) => $prompt->highlighted === $i + ->map(fn ($label, $key) => $prompt->highlighted === $key ? "{$this->cyan('›')} {$label} " : " {$this->dim($label)} " ), - $prompt->highlighted, - min($prompt->scroll, $prompt->terminal()->lines() - 7), + $prompt->firstVisible, + $prompt->scroll, + count($prompt->matches()), min($this->longest($prompt->matches(), padding: 4), $prompt->terminal()->cols() - 6), $prompt->state === 'cancel' ? 'dim' : 'cyan' )->implode(PHP_EOL);