| Total Complexity | 45 | 
| Total Lines | 366 | 
| Duplicated Lines | 0 % | 
| Changes | 20 | ||
| Bugs | 0 | Features | 1 | 
Complex classes like SplitItem often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.
Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.
While breaking up the class, it is a good idea to analyze how other classes use SplitItem, and based on these observations, apply Extract Interface, too.
| 1 | <?php | ||
| 19 | class SplitItem implements MenuItemInterface, PropagatesStyles | ||
| 20 | { | ||
| 21 | /** | ||
| 22 | * @var array | ||
| 23 | */ | ||
| 24 | private $items = []; | ||
| 25 | |||
| 26 | /** | ||
| 27 | * @var int|null | ||
| 28 | */ | ||
| 29 | private $selectedItemIndex; | ||
| 30 | |||
| 31 | /** | ||
| 32 | * @var bool | ||
| 33 | */ | ||
| 34 | private $canBeSelected = true; | ||
| 35 | |||
| 36 | /** | ||
| 37 | * @var int | ||
| 38 | */ | ||
| 39 | private $gutter = 2; | ||
| 40 | |||
| 41 | /** | ||
| 42 | * @var DefaultStyle | ||
| 43 | */ | ||
| 44 | private $style; | ||
| 45 | |||
| 46 | /** | ||
| 47 | * @var array | ||
| 48 | */ | ||
| 49 | private static $blacklistedItems = [ | ||
| 50 | \PhpSchool\CliMenu\MenuItem\AsciiArtItem::class, | ||
| 51 | \PhpSchool\CliMenu\MenuItem\LineBreakItem::class, | ||
| 52 | \PhpSchool\CliMenu\MenuItem\SplitItem::class, | ||
| 53 | ]; | ||
| 54 | |||
| 55 | public function __construct(array $items = []) | ||
| 56 |     { | ||
| 57 | $this->addItems($items); | ||
| 58 | $this->setDefaultSelectedItem(); | ||
| 59 | |||
| 60 | $this->style = new DefaultStyle(); | ||
| 61 | } | ||
| 62 | |||
| 63 | public function getGutter() : int | ||
| 64 |     { | ||
| 65 | return $this->gutter; | ||
| 66 | } | ||
| 67 | |||
| 68 | public function setGutter(int $gutter) : void | ||
| 72 | } | ||
| 73 | |||
| 74 | public function addItem(MenuItemInterface $item) : self | ||
| 75 |     { | ||
| 76 |         foreach (self::$blacklistedItems as $bl) { | ||
| 77 |             if ($item instanceof $bl) { | ||
| 78 |                 throw new \InvalidArgumentException("Cannot add a $bl to a SplitItem"); | ||
| 79 | } | ||
| 80 | } | ||
| 81 | $this->items[] = $item; | ||
| 82 | $this->setDefaultSelectedItem(); | ||
| 83 | return $this; | ||
| 84 | } | ||
| 85 | |||
| 86 | public function addItems(array $items) : self | ||
| 87 |     { | ||
| 88 |         foreach ($items as $item) { | ||
| 89 | $this->addItem($item); | ||
| 90 | } | ||
| 91 | |||
| 92 | return $this; | ||
| 93 | } | ||
| 94 | |||
| 95 | public function setItems(array $items) : self | ||
| 96 |     { | ||
| 97 | $this->items = []; | ||
| 98 | $this->addItems($items); | ||
| 99 | return $this; | ||
| 100 | } | ||
| 101 | |||
| 102 | /** | ||
| 103 | * Select default item | ||
| 104 | */ | ||
| 105 | private function setDefaultSelectedItem() : void | ||
| 106 |     { | ||
| 107 |         foreach ($this->items as $index => $item) { | ||
| 108 |             if ($item->canSelect()) { | ||
| 109 | $this->canBeSelected = true; | ||
| 110 | $this->selectedItemIndex = $index; | ||
| 111 | return; | ||
| 112 | } | ||
| 113 | } | ||
| 114 | |||
| 115 | $this->canBeSelected = false; | ||
| 116 | $this->selectedItemIndex = null; | ||
| 117 | } | ||
| 118 | |||
| 119 | /** | ||
| 120 | * The output text for the item | ||
| 121 | */ | ||
| 122 | public function getRows(MenuStyle $style, bool $selected = false) : array | ||
| 123 |     { | ||
| 124 | $numberOfItems = count($this->items); | ||
| 125 | |||
| 126 |         if ($numberOfItems === 0) { | ||
| 127 |             throw new \RuntimeException(sprintf('There should be at least one item added to: %s', __CLASS__)); | ||
| 128 | } | ||
| 129 | |||
| 130 |         if (!$selected) { | ||
| 131 | $this->setDefaultSelectedItem(); | ||
| 132 | } | ||
| 133 | |||
| 134 | $largestItemExtra = $this->calculateItemExtra(); | ||
| 135 | |||
| 136 | $length = $largestItemExtra > 0 | ||
| 137 | ? floor($style->getContentWidth() / $numberOfItems) - ($largestItemExtra + 2) | ||
| 138 | : floor($style->getContentWidth() / $numberOfItems); | ||
| 139 | |||
| 140 | $length -= $this->gutter; | ||
| 141 | $length = (int) $length; | ||
| 142 | |||
| 143 | $missingLength = $style->getContentWidth() % $numberOfItems; | ||
| 144 | |||
| 145 | return $this->buildRows( | ||
| 146 |             mapWithKeys($this->items, function (int $index, MenuItemInterface $item) use ($selected, $length, $style) { | ||
| 147 | $isSelected = $selected && $index === $this->selectedItemIndex; | ||
| 148 | |||
| 149 | $marker = ''; | ||
| 150 |                 if ($item->canSelect()) { | ||
| 151 | $marker = $item->getStyle()->getMarker($item, $isSelected); | ||
| 152 | } | ||
| 153 | |||
| 154 | $itemExtra = ''; | ||
| 155 |                 if ($item->getStyle()->getDisplaysExtra()) { | ||
| 156 | $itemExtraVal = $item->getStyle()->getItemExtra(); | ||
| 157 | $itemExtra = $item->showsItemExtra() | ||
| 158 |                         ? sprintf('  %s', $itemExtraVal) | ||
| 159 |                         : sprintf('  %s', str_repeat(' ', mb_strlen($itemExtraVal))); | ||
| 160 | } | ||
| 161 | |||
| 162 | return $this->buildCell( | ||
| 163 | explode( | ||
| 164 | "\n", | ||
| 165 | StringUtil::wordwrap( | ||
| 166 |                             sprintf('%s%s', $marker, $item->getText()), | ||
| 167 | $length, | ||
| 168 |                             sprintf("\n%s", str_repeat(' ', mb_strlen($marker))) | ||
| 169 | ) | ||
| 170 | ), | ||
| 171 | $length, | ||
| 172 | $style, | ||
| 173 | $isSelected, | ||
| 174 | $itemExtra | ||
| 175 | ); | ||
| 176 | }), | ||
| 177 | $missingLength, | ||
| 178 | $length, | ||
| 179 | $largestItemExtra | ||
| 180 | ); | ||
| 181 | } | ||
| 182 | |||
| 183 | private function buildRows(array $cells, int $missingLength, int $length, int $largestItemExtra) : array | ||
| 184 |     { | ||
| 185 | $extraPadLength = $largestItemExtra > 0 ? 2 + $largestItemExtra : 0; | ||
| 186 | |||
| 187 | return array_map( | ||
| 188 |             function ($i) use ($cells, $length, $missingLength, $extraPadLength) { | ||
| 189 | return $this->buildRow($cells, $i, $length, $missingLength, $extraPadLength); | ||
| 190 | }, | ||
| 191 |             range(0, max(array_map('count', $cells)) - 1) | ||
| 192 | ); | ||
| 193 | } | ||
| 194 | |||
| 195 | private function buildRow(array $cells, int $index, int $length, int $missingLength, int $extraPadLength) : string | ||
| 196 |     { | ||
| 197 | return sprintf( | ||
| 198 | '%s%s', | ||
| 199 | implode( | ||
| 200 | '', | ||
| 201 | array_map( | ||
| 202 |                     function ($cell) use ($index, $length, $extraPadLength) { | ||
| 203 |                         return $cell[$index] ?? str_repeat(' ', $length + $this->gutter + $extraPadLength); | ||
| 204 | }, | ||
| 205 | $cells | ||
| 206 | ) | ||
| 207 | ), | ||
| 208 |             str_repeat(' ', $missingLength) | ||
| 209 | ); | ||
| 210 | } | ||
| 211 | |||
| 212 | private function buildCell( | ||
| 213 | array $content, | ||
| 214 | int $length, | ||
| 215 | MenuStyle $style, | ||
| 216 | bool $isSelected, | ||
| 217 | string $itemExtra | ||
| 218 |     ) : array { | ||
| 219 |         return array_map(function ($row, $index) use ($length, $style, $isSelected, $itemExtra) { | ||
| 220 | $invertedColoursSetCode = $isSelected | ||
| 221 | ? $style->getInvertedColoursSetCode() | ||
| 222 | : ''; | ||
| 223 | $invertedColoursUnsetCode = $isSelected | ||
| 224 | ? $style->getInvertedColoursUnsetCode() | ||
| 225 | : ''; | ||
| 226 | |||
| 227 | return sprintf( | ||
| 228 | '%s%s%s%s%s%s', | ||
| 229 | $invertedColoursSetCode, | ||
| 230 | $row, | ||
| 231 |                 str_repeat(' ', $length - mb_strlen($row)), | ||
| 232 |                 $index === 0 ? $itemExtra : str_repeat(' ', mb_strlen($itemExtra)), | ||
| 233 | $invertedColoursUnsetCode, | ||
| 234 |                 str_repeat(' ', $this->gutter) | ||
| 235 | ); | ||
| 236 | }, $content, array_keys($content)); | ||
| 237 | } | ||
| 238 | |||
| 239 | /** | ||
| 240 | * Is there an item with this index and can it be | ||
| 241 | * selected? | ||
| 242 | */ | ||
| 243 | public function canSelectIndex(int $index) : bool | ||
| 244 |     { | ||
| 245 | return isset($this->items[$index]) && $this->items[$index]->canSelect(); | ||
| 246 | } | ||
| 247 | |||
| 248 | /** | ||
| 249 | * Set the item index which should be selected. If the item does | ||
| 250 | * not exist then throw an exception. | ||
| 251 | */ | ||
| 252 | public function setSelectedItemIndex(int $index) : void | ||
| 253 |     { | ||
| 254 |         if (!isset($this->items[$index])) { | ||
| 255 |             throw new \InvalidArgumentException(sprintf('Index: "%s" does not exist', $index)); | ||
| 256 | } | ||
| 257 | |||
| 258 | $this->selectedItemIndex = $index; | ||
| 259 | } | ||
| 260 | |||
| 261 | /** | ||
| 262 | * Get the currently select item index. | ||
| 263 | * May be null in case of no selectable item. | ||
| 264 | */ | ||
| 265 | public function getSelectedItemIndex() : ?int | ||
| 266 |     { | ||
| 267 | return $this->selectedItemIndex; | ||
| 268 | } | ||
| 269 | |||
| 270 | /** | ||
| 271 | * Get the currently selected item - if no items are selectable | ||
| 272 | * then throw an exception. | ||
| 273 | */ | ||
| 274 | public function getSelectedItem() : MenuItemInterface | ||
| 275 |     { | ||
| 276 |         if (null === $this->selectedItemIndex) { | ||
| 277 |             throw new \RuntimeException('No item is selected'); | ||
| 278 | } | ||
| 279 | |||
| 280 | return $this->items[$this->selectedItemIndex]; | ||
| 281 | } | ||
| 282 | |||
| 283 | public function getItems() : array | ||
| 284 |     { | ||
| 285 | return $this->items; | ||
| 286 | } | ||
| 287 | |||
| 288 | /** | ||
| 289 | * Can the item be selected | ||
| 290 | * In this case, it indicates if at least 1 item inside the SplitItem can be selected | ||
| 291 | */ | ||
| 292 | public function canSelect() : bool | ||
| 293 |     { | ||
| 294 | return $this->canBeSelected; | ||
| 295 | } | ||
| 296 | |||
| 297 | /** | ||
| 298 | * Execute the items callable if required | ||
| 299 | */ | ||
| 300 | public function getSelectAction() : ?callable | ||
| 303 | } | ||
| 304 | |||
| 305 | /** | ||
| 306 | * Whether or not the menu item is showing the menustyle extra value | ||
| 307 | */ | ||
| 308 | public function showsItemExtra() : bool | ||
| 309 |     { | ||
| 310 | return false; | ||
| 311 | } | ||
| 312 | |||
| 313 | /** | ||
| 314 | * Enable showing item extra | ||
| 315 | */ | ||
| 316 | public function showItemExtra() : void | ||
| 317 |     { | ||
| 318 | //noop | ||
| 319 | } | ||
| 320 | |||
| 321 | /** | ||
| 322 | * Disable showing item extra | ||
| 323 | */ | ||
| 324 | public function hideItemExtra() : void | ||
| 325 |     { | ||
| 326 | //noop | ||
| 327 | } | ||
| 328 | |||
| 329 | /** | ||
| 330 | * Nothing to return with SplitItem | ||
| 331 | */ | ||
| 332 | public function getText() : string | ||
| 333 |     { | ||
| 334 |         throw new \BadMethodCallException(sprintf('Not supported on: %s', __CLASS__)); | ||
| 335 | } | ||
| 336 | |||
| 337 | /** | ||
| 338 | * Finds largest itemExtra value in items | ||
| 339 | */ | ||
| 340 | private function calculateItemExtra() : int | ||
| 348 | }) | ||
| 349 | )); | ||
| 350 | } | ||
| 351 | |||
| 352 | /** | ||
| 353 | * @return DefaultStyle | ||
| 354 | */ | ||
| 355 | public function getStyle(): ItemStyle | ||
| 356 |     { | ||
| 357 | return $this->style; | ||
| 358 | } | ||
| 359 | |||
| 360 | public function setStyle(DefaultStyle $style): void | ||
| 363 | } | ||
| 364 | |||
| 365 | /** | ||
| 366 | * @inheritDoc | ||
| 367 | */ | ||
| 368 | public function propagateStyles(CliMenu $parent): void | ||
| 385 | } | ||
| 386 | ); | ||
| 387 | } | ||
| 388 | } | ||
| 389 |