bluetree-service /
symfony-console-style
| 1 | <?php |
||
| 2 | |||
| 3 | declare(strict_types=1); |
||
| 4 | |||
| 5 | namespace BlueConsole; |
||
| 6 | |||
| 7 | use Symfony\Component\Console\Style\SymfonyStyle; |
||
| 8 | |||
| 9 | class MultiSelect |
||
| 10 | { |
||
| 11 | /** |
||
| 12 | * @todo add help bellow (move up, down, space enter) |
||
| 13 | * @todo add separators |
||
| 14 | * @todo count line length and select highest value to replace all chars |
||
| 15 | * @todo option scroll |
||
| 16 | * @todo separator (---- by default, optionally message) |
||
| 17 | * @todo comments on array list |
||
| 18 | * @todo use different select char & brackets for single select |
||
| 19 | */ |
||
| 20 | protected const CHARS = [ |
||
| 21 | 'enter' => 10, |
||
| 22 | 'space' => 32, |
||
| 23 | 'key_up' => 65, |
||
| 24 | 'key_down' => 66 |
||
| 25 | ]; |
||
| 26 | |||
| 27 | public const MOD_LINE_CHAR = "\033[1A"; |
||
| 28 | |||
| 29 | /** |
||
| 30 | * @var string |
||
| 31 | */ |
||
| 32 | protected $selectChar = '<fg=blue>❯</>'; |
||
| 33 | |||
| 34 | /** |
||
| 35 | * @var string |
||
| 36 | */ |
||
| 37 | protected $selectedChar = '<info>✓</info>'; |
||
| 38 | |||
| 39 | /** |
||
| 40 | * @var SymfonyStyle $output |
||
| 41 | */ |
||
| 42 | protected $output; |
||
| 43 | |||
| 44 | /** |
||
| 45 | * @var bool |
||
| 46 | */ |
||
| 47 | protected $showInfo = true; |
||
| 48 | |||
| 49 | /** |
||
| 50 | * @var bool|resource |
||
| 51 | */ |
||
| 52 | protected $stdin; |
||
| 53 | |||
| 54 | /** |
||
| 55 | * @param $output |
||
| 56 | */ |
||
| 57 | public function __construct(SymfonyStyle $output) |
||
| 58 | { |
||
| 59 | $this->output = $output; |
||
| 60 | $this->stdin = \fopen('php://stdin', 'rb'); |
||
| 61 | system('stty cbreak -echo'); |
||
| 62 | } |
||
| 63 | |||
| 64 | /** |
||
| 65 | * @param bool $showInfo |
||
| 66 | * @return $this |
||
| 67 | */ |
||
| 68 | public function toggleShowInfo(bool $showInfo): self |
||
| 69 | { |
||
| 70 | $this->showInfo = $showInfo; |
||
| 71 | |||
| 72 | return $this; |
||
| 73 | } |
||
| 74 | |||
| 75 | /** |
||
| 76 | * @param string $char |
||
| 77 | * @return $this |
||
| 78 | */ |
||
| 79 | public function setSelectChar(string $char): self |
||
| 80 | { |
||
| 81 | $this->selectChar = $char; |
||
| 82 | |||
| 83 | return $this; |
||
| 84 | } |
||
| 85 | |||
| 86 | /** |
||
| 87 | * @param string $char |
||
| 88 | * @return $this |
||
| 89 | */ |
||
| 90 | public function setSelectedChar(string $char): self |
||
| 91 | { |
||
| 92 | $this->selectedChar = $char; |
||
| 93 | |||
| 94 | return $this; |
||
| 95 | } |
||
| 96 | |||
| 97 | /** |
||
| 98 | * @param array $dataList |
||
| 99 | * @return array |
||
| 100 | */ |
||
| 101 | public function renderMultiSelect(array $dataList): array |
||
| 102 | { |
||
| 103 | return $this->renderList($dataList, false); |
||
| 104 | } |
||
| 105 | |||
| 106 | /** |
||
| 107 | * @param array $dataList |
||
| 108 | * @return null|int |
||
| 109 | */ |
||
| 110 | public function renderSingleSelect(array $dataList): ?int |
||
| 111 | { |
||
| 112 | $selectedOptions = $this->renderList($dataList, true); |
||
| 113 | |||
| 114 | $keys = \array_keys($selectedOptions); |
||
| 115 | return \reset($keys); |
||
| 116 | } |
||
| 117 | |||
| 118 | /** |
||
| 119 | * @param array $dataList |
||
| 120 | * @param bool $isSingleSelect |
||
| 121 | * @return array |
||
| 122 | */ |
||
| 123 | protected function renderList(array $dataList, bool $isSingleSelect): array |
||
| 124 | { |
||
| 125 | $selectedOptions = []; |
||
| 126 | $cursor = 0; |
||
| 127 | $listSize = \count($dataList); |
||
| 128 | |||
| 129 | $this->renderBasicList($dataList); |
||
| 130 | |||
| 131 | while (true) { |
||
| 132 | if (!$this->stdin) { |
||
| 133 | continue; |
||
| 134 | } |
||
| 135 | |||
| 136 | $char = \ord(\fgetc($this->stdin)); |
||
|
0 ignored issues
–
show
Bug
introduced
by
Loading history...
|
|||
| 137 | |||
| 138 | if (!\in_array($char, self::CHARS, true)) { |
||
| 139 | continue; |
||
| 140 | } |
||
| 141 | |||
| 142 | if ($char === self::CHARS['enter']) { |
||
| 143 | $this->renderSelectionInfo($dataList, $selectedOptions); |
||
| 144 | |||
| 145 | break; |
||
| 146 | } |
||
| 147 | |||
| 148 | for ($i = 0; $i < $listSize; $i++) { |
||
| 149 | echo self::MOD_LINE_CHAR; |
||
| 150 | } |
||
| 151 | |||
| 152 | [$cursor, $selectedOptions] = $this->manageCursor( |
||
| 153 | $cursor, |
||
| 154 | $char, |
||
| 155 | $listSize, |
||
| 156 | $selectedOptions, |
||
| 157 | $isSingleSelect |
||
| 158 | ); |
||
| 159 | |||
| 160 | $this->renderListWithSelection($dataList, $cursor, $selectedOptions); |
||
| 161 | |||
| 162 | \usleep(100000); |
||
| 163 | } |
||
| 164 | |||
| 165 | return $selectedOptions; |
||
| 166 | } |
||
| 167 | |||
| 168 | /** |
||
| 169 | * @param int $cursor |
||
| 170 | * @param int $char |
||
| 171 | * @param int $listSize |
||
| 172 | * @param bool $isSingleSelect |
||
| 173 | * @param array $selectedOptions |
||
| 174 | * @return array |
||
| 175 | */ |
||
| 176 | protected function manageCursor( |
||
| 177 | int $cursor, |
||
| 178 | int $char, |
||
| 179 | int $listSize, |
||
| 180 | array $selectedOptions, |
||
| 181 | bool $isSingleSelect = false |
||
| 182 | ): array { |
||
| 183 | if ($cursor > 0 && $char === self::CHARS['key_up']) { |
||
| 184 | $cursor--; |
||
| 185 | } |
||
| 186 | |||
| 187 | if ($cursor < $listSize - 1 && $char === self::CHARS['key_down']) { |
||
| 188 | $cursor++; |
||
| 189 | } |
||
| 190 | |||
| 191 | if ($char === self::CHARS['space']) { |
||
| 192 | [$selectedOptions, $oldSelections] = $this->singleSelection($isSingleSelect, $cursor, $selectedOptions); |
||
| 193 | |||
| 194 | if ($oldSelections || isset($selectedOptions[$cursor])) { |
||
| 195 | unset($selectedOptions[$cursor]); |
||
| 196 | } else { |
||
| 197 | $selectedOptions[$cursor] = true; |
||
| 198 | } |
||
| 199 | } |
||
| 200 | |||
| 201 | return [$cursor, $selectedOptions]; |
||
| 202 | } |
||
| 203 | |||
| 204 | /** |
||
| 205 | * @param bool $isSingleSelect |
||
| 206 | * @param int $cursor |
||
| 207 | * @param array $selectedOptions |
||
| 208 | * @return array |
||
| 209 | */ |
||
| 210 | protected function singleSelection(bool $isSingleSelect, int $cursor, array $selectedOptions): array |
||
| 211 | { |
||
| 212 | $oldSelections = false; |
||
| 213 | |||
| 214 | if ($isSingleSelect) { |
||
| 215 | if (isset($selectedOptions[$cursor])) { |
||
| 216 | $oldSelections = true; |
||
| 217 | } |
||
| 218 | |||
| 219 | $selectedOptions = []; |
||
| 220 | } |
||
| 221 | |||
| 222 | return [$selectedOptions, $oldSelections]; |
||
| 223 | } |
||
| 224 | |||
| 225 | /** |
||
| 226 | * @param array $dataList |
||
| 227 | * @param array $selectedOptions |
||
| 228 | * @return MultiSelect |
||
| 229 | */ |
||
| 230 | protected function renderSelectionInfo(array $dataList, array $selectedOptions): self |
||
| 231 | { |
||
| 232 | if (!$this->showInfo) { |
||
| 233 | return $this; |
||
| 234 | } |
||
| 235 | |||
| 236 | $this->output->writeln(''); |
||
| 237 | $this->output->title('Selected:'); |
||
| 238 | |||
| 239 | echo self::MOD_LINE_CHAR; |
||
| 240 | |||
| 241 | foreach ($dataList as $key => $row) { |
||
| 242 | if (\array_key_exists($key, $selectedOptions)) { |
||
| 243 | $this->output->writeln("$key: <info>$row</info>"); |
||
| 244 | } |
||
| 245 | } |
||
| 246 | |||
| 247 | return $this; |
||
| 248 | } |
||
| 249 | |||
| 250 | /** |
||
| 251 | * @param array $dataList |
||
| 252 | * @param int $cursor |
||
| 253 | * @param array $selectedOptions |
||
| 254 | * @return MultiSelect |
||
| 255 | */ |
||
| 256 | protected function renderListWithSelection(array $dataList, int $cursor, array $selectedOptions): self |
||
| 257 | { |
||
| 258 | foreach ($dataList as $key => $row) { |
||
| 259 | $cursorChar = ' '; |
||
| 260 | $selected = '[ ]'; |
||
| 261 | |||
| 262 | if ($cursor === $key) { |
||
| 263 | $cursorChar = $this->selectChar; |
||
| 264 | } |
||
| 265 | |||
| 266 | if ($cursorChar !== ' ') { |
||
| 267 | $selected = "<fg=blue>$selected</>"; |
||
| 268 | } |
||
| 269 | |||
| 270 | if (\array_key_exists($key, $selectedOptions)) { |
||
| 271 | $selected = '[' . $this->selectedChar . ']'; |
||
| 272 | } |
||
| 273 | |||
| 274 | //@todo resolve colors |
||
| 275 | if ($cursorChar !== ' ') { |
||
| 276 | $row = "<fg=blue>$row</>"; |
||
| 277 | } else { |
||
| 278 | $row = "<comment>$row</comment>"; |
||
| 279 | } |
||
| 280 | |||
| 281 | $this->output->writeln(" $cursorChar $selected $row"); |
||
| 282 | } |
||
| 283 | |||
| 284 | return $this; |
||
| 285 | } |
||
| 286 | |||
| 287 | /** |
||
| 288 | * @param array $dataList |
||
| 289 | * @return MultiSelect |
||
| 290 | */ |
||
| 291 | protected function renderBasicList(array $dataList): self |
||
| 292 | { |
||
| 293 | $count = 0; |
||
| 294 | |||
| 295 | foreach ($dataList as $key => $row) { |
||
| 296 | $cursorChar = ' '; |
||
| 297 | |||
| 298 | if ($key === 0) { |
||
| 299 | $cursorChar = $this->selectChar; |
||
| 300 | } |
||
| 301 | |||
| 302 | if ($count++ === 0) { |
||
| 303 | $this->output->writeln(" $cursorChar <fg=blue>[ ]</> <comment>$row</comment>"); |
||
| 304 | } else { |
||
| 305 | $this->output->writeln(" $cursorChar [ ] <comment>$row</comment>"); |
||
| 306 | } |
||
| 307 | } |
||
| 308 | |||
| 309 | return $this; |
||
| 310 | } |
||
| 311 | } |
||
| 312 |