You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

617 lines
18 KiB

3 years ago
  1. <?php
  2. /*
  3. * This file is part of the Symfony package.
  4. *
  5. * (c) Fabien Potencier <fabien@symfony.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Symfony\Component\Console\Helper;
  11. use Symfony\Component\Console\Cursor;
  12. use Symfony\Component\Console\Exception\LogicException;
  13. use Symfony\Component\Console\Output\ConsoleOutputInterface;
  14. use Symfony\Component\Console\Output\ConsoleSectionOutput;
  15. use Symfony\Component\Console\Output\OutputInterface;
  16. use Symfony\Component\Console\Terminal;
  17. /**
  18. * The ProgressBar provides helpers to display progress output.
  19. *
  20. * @author Fabien Potencier <fabien@symfony.com>
  21. * @author Chris Jones <leeked@gmail.com>
  22. */
  23. final class ProgressBar
  24. {
  25. public const FORMAT_VERBOSE = 'verbose';
  26. public const FORMAT_VERY_VERBOSE = 'very_verbose';
  27. public const FORMAT_DEBUG = 'debug';
  28. public const FORMAT_NORMAL = 'normal';
  29. private const FORMAT_VERBOSE_NOMAX = 'verbose_nomax';
  30. private const FORMAT_VERY_VERBOSE_NOMAX = 'very_verbose_nomax';
  31. private const FORMAT_DEBUG_NOMAX = 'debug_nomax';
  32. private const FORMAT_NORMAL_NOMAX = 'normal_nomax';
  33. private $barWidth = 28;
  34. private $barChar;
  35. private $emptyBarChar = '-';
  36. private $progressChar = '>';
  37. private $format;
  38. private $internalFormat;
  39. private $redrawFreq = 1;
  40. private $writeCount;
  41. private $lastWriteTime;
  42. private $minSecondsBetweenRedraws = 0;
  43. private $maxSecondsBetweenRedraws = 1;
  44. private $output;
  45. private $step = 0;
  46. private $max;
  47. private $startTime;
  48. private $stepWidth;
  49. private $percent = 0.0;
  50. private $formatLineCount;
  51. private $messages = [];
  52. private $overwrite = true;
  53. private $terminal;
  54. private $previousMessage;
  55. private $cursor;
  56. private static $formatters;
  57. private static $formats;
  58. /**
  59. * @param int $max Maximum steps (0 if unknown)
  60. */
  61. public function __construct(OutputInterface $output, int $max = 0, float $minSecondsBetweenRedraws = 1 / 25)
  62. {
  63. if ($output instanceof ConsoleOutputInterface) {
  64. $output = $output->getErrorOutput();
  65. }
  66. $this->output = $output;
  67. $this->setMaxSteps($max);
  68. $this->terminal = new Terminal();
  69. if (0 < $minSecondsBetweenRedraws) {
  70. $this->redrawFreq = null;
  71. $this->minSecondsBetweenRedraws = $minSecondsBetweenRedraws;
  72. }
  73. if (!$this->output->isDecorated()) {
  74. // disable overwrite when output does not support ANSI codes.
  75. $this->overwrite = false;
  76. // set a reasonable redraw frequency so output isn't flooded
  77. $this->redrawFreq = null;
  78. }
  79. $this->startTime = time();
  80. $this->cursor = new Cursor($output);
  81. }
  82. /**
  83. * Sets a placeholder formatter for a given name.
  84. *
  85. * This method also allow you to override an existing placeholder.
  86. *
  87. * @param string $name The placeholder name (including the delimiter char like %)
  88. * @param callable $callable A PHP callable
  89. */
  90. public static function setPlaceholderFormatterDefinition(string $name, callable $callable): void
  91. {
  92. if (!self::$formatters) {
  93. self::$formatters = self::initPlaceholderFormatters();
  94. }
  95. self::$formatters[$name] = $callable;
  96. }
  97. /**
  98. * Gets the placeholder formatter for a given name.
  99. *
  100. * @param string $name The placeholder name (including the delimiter char like %)
  101. *
  102. * @return callable|null A PHP callable
  103. */
  104. public static function getPlaceholderFormatterDefinition(string $name): ?callable
  105. {
  106. if (!self::$formatters) {
  107. self::$formatters = self::initPlaceholderFormatters();
  108. }
  109. return self::$formatters[$name] ?? null;
  110. }
  111. /**
  112. * Sets a format for a given name.
  113. *
  114. * This method also allow you to override an existing format.
  115. *
  116. * @param string $name The format name
  117. * @param string $format A format string
  118. */
  119. public static function setFormatDefinition(string $name, string $format): void
  120. {
  121. if (!self::$formats) {
  122. self::$formats = self::initFormats();
  123. }
  124. self::$formats[$name] = $format;
  125. }
  126. /**
  127. * Gets the format for a given name.
  128. *
  129. * @param string $name The format name
  130. *
  131. * @return string|null A format string
  132. */
  133. public static function getFormatDefinition(string $name): ?string
  134. {
  135. if (!self::$formats) {
  136. self::$formats = self::initFormats();
  137. }
  138. return self::$formats[$name] ?? null;
  139. }
  140. /**
  141. * Associates a text with a named placeholder.
  142. *
  143. * The text is displayed when the progress bar is rendered but only
  144. * when the corresponding placeholder is part of the custom format line
  145. * (by wrapping the name with %).
  146. *
  147. * @param string $message The text to associate with the placeholder
  148. * @param string $name The name of the placeholder
  149. */
  150. public function setMessage(string $message, string $name = 'message')
  151. {
  152. $this->messages[$name] = $message;
  153. }
  154. public function getMessage(string $name = 'message')
  155. {
  156. return $this->messages[$name];
  157. }
  158. public function getStartTime(): int
  159. {
  160. return $this->startTime;
  161. }
  162. public function getMaxSteps(): int
  163. {
  164. return $this->max;
  165. }
  166. public function getProgress(): int
  167. {
  168. return $this->step;
  169. }
  170. private function getStepWidth(): int
  171. {
  172. return $this->stepWidth;
  173. }
  174. public function getProgressPercent(): float
  175. {
  176. return $this->percent;
  177. }
  178. public function getBarOffset(): float
  179. {
  180. return floor($this->max ? $this->percent * $this->barWidth : (null === $this->redrawFreq ? (int) (min(5, $this->barWidth / 15) * $this->writeCount) : $this->step) % $this->barWidth);
  181. }
  182. public function getEstimated(): float
  183. {
  184. if (!$this->step) {
  185. return 0;
  186. }
  187. return round((time() - $this->startTime) / $this->step * $this->max);
  188. }
  189. public function getRemaining(): float
  190. {
  191. if (!$this->step) {
  192. return 0;
  193. }
  194. return round((time() - $this->startTime) / $this->step * ($this->max - $this->step));
  195. }
  196. public function setBarWidth(int $size)
  197. {
  198. $this->barWidth = max(1, $size);
  199. }
  200. public function getBarWidth(): int
  201. {
  202. return $this->barWidth;
  203. }
  204. public function setBarCharacter(string $char)
  205. {
  206. $this->barChar = $char;
  207. }
  208. public function getBarCharacter(): string
  209. {
  210. if (null === $this->barChar) {
  211. return $this->max ? '=' : $this->emptyBarChar;
  212. }
  213. return $this->barChar;
  214. }
  215. public function setEmptyBarCharacter(string $char)
  216. {
  217. $this->emptyBarChar = $char;
  218. }
  219. public function getEmptyBarCharacter(): string
  220. {
  221. return $this->emptyBarChar;
  222. }
  223. public function setProgressCharacter(string $char)
  224. {
  225. $this->progressChar = $char;
  226. }
  227. public function getProgressCharacter(): string
  228. {
  229. return $this->progressChar;
  230. }
  231. public function setFormat(string $format)
  232. {
  233. $this->format = null;
  234. $this->internalFormat = $format;
  235. }
  236. /**
  237. * Sets the redraw frequency.
  238. *
  239. * @param int|null $freq The frequency in steps
  240. */
  241. public function setRedrawFrequency(?int $freq)
  242. {
  243. $this->redrawFreq = null !== $freq ? max(1, $freq) : null;
  244. }
  245. public function minSecondsBetweenRedraws(float $seconds): void
  246. {
  247. $this->minSecondsBetweenRedraws = $seconds;
  248. }
  249. public function maxSecondsBetweenRedraws(float $seconds): void
  250. {
  251. $this->maxSecondsBetweenRedraws = $seconds;
  252. }
  253. /**
  254. * Returns an iterator that will automatically update the progress bar when iterated.
  255. *
  256. * @param int|null $max Number of steps to complete the bar (0 if indeterminate), if null it will be inferred from $iterable
  257. */
  258. public function iterate(iterable $iterable, int $max = null): iterable
  259. {
  260. $this->start($max ?? (is_countable($iterable) ? \count($iterable) : 0));
  261. foreach ($iterable as $key => $value) {
  262. yield $key => $value;
  263. $this->advance();
  264. }
  265. $this->finish();
  266. }
  267. /**
  268. * Starts the progress output.
  269. *
  270. * @param int|null $max Number of steps to complete the bar (0 if indeterminate), null to leave unchanged
  271. */
  272. public function start(int $max = null)
  273. {
  274. $this->startTime = time();
  275. $this->step = 0;
  276. $this->percent = 0.0;
  277. if (null !== $max) {
  278. $this->setMaxSteps($max);
  279. }
  280. $this->display();
  281. }
  282. /**
  283. * Advances the progress output X steps.
  284. *
  285. * @param int $step Number of steps to advance
  286. */
  287. public function advance(int $step = 1)
  288. {
  289. $this->setProgress($this->step + $step);
  290. }
  291. /**
  292. * Sets whether to overwrite the progressbar, false for new line.
  293. */
  294. public function setOverwrite(bool $overwrite)
  295. {
  296. $this->overwrite = $overwrite;
  297. }
  298. public function setProgress(int $step)
  299. {
  300. if ($this->max && $step > $this->max) {
  301. $this->max = $step;
  302. } elseif ($step < 0) {
  303. $step = 0;
  304. }
  305. $redrawFreq = $this->redrawFreq ?? (($this->max ?: 10) / 10);
  306. $prevPeriod = (int) ($this->step / $redrawFreq);
  307. $currPeriod = (int) ($step / $redrawFreq);
  308. $this->step = $step;
  309. $this->percent = $this->max ? (float) $this->step / $this->max : 0;
  310. $timeInterval = microtime(true) - $this->lastWriteTime;
  311. // Draw regardless of other limits
  312. if ($this->max === $step) {
  313. $this->display();
  314. return;
  315. }
  316. // Throttling
  317. if ($timeInterval < $this->minSecondsBetweenRedraws) {
  318. return;
  319. }
  320. // Draw each step period, but not too late
  321. if ($prevPeriod !== $currPeriod || $timeInterval >= $this->maxSecondsBetweenRedraws) {
  322. $this->display();
  323. }
  324. }
  325. public function setMaxSteps(int $max)
  326. {
  327. $this->format = null;
  328. $this->max = max(0, $max);
  329. $this->stepWidth = $this->max ? Helper::width((string) $this->max) : 4;
  330. }
  331. /**
  332. * Finishes the progress output.
  333. */
  334. public function finish(): void
  335. {
  336. if (!$this->max) {
  337. $this->max = $this->step;
  338. }
  339. if ($this->step === $this->max && !$this->overwrite) {
  340. // prevent double 100% output
  341. return;
  342. }
  343. $this->setProgress($this->max);
  344. }
  345. /**
  346. * Outputs the current progress string.
  347. */
  348. public function display(): void
  349. {
  350. if (OutputInterface::VERBOSITY_QUIET === $this->output->getVerbosity()) {
  351. return;
  352. }
  353. if (null === $this->format) {
  354. $this->setRealFormat($this->internalFormat ?: $this->determineBestFormat());
  355. }
  356. $this->overwrite($this->buildLine());
  357. }
  358. /**
  359. * Removes the progress bar from the current line.
  360. *
  361. * This is useful if you wish to write some output
  362. * while a progress bar is running.
  363. * Call display() to show the progress bar again.
  364. */
  365. public function clear(): void
  366. {
  367. if (!$this->overwrite) {
  368. return;
  369. }
  370. if (null === $this->format) {
  371. $this->setRealFormat($this->internalFormat ?: $this->determineBestFormat());
  372. }
  373. $this->overwrite('');
  374. }
  375. private function setRealFormat(string $format)
  376. {
  377. // try to use the _nomax variant if available
  378. if (!$this->max && null !== self::getFormatDefinition($format.'_nomax')) {
  379. $this->format = self::getFormatDefinition($format.'_nomax');
  380. } elseif (null !== self::getFormatDefinition($format)) {
  381. $this->format = self::getFormatDefinition($format);
  382. } else {
  383. $this->format = $format;
  384. }
  385. $this->formatLineCount = substr_count($this->format, "\n");
  386. }
  387. /**
  388. * Overwrites a previous message to the output.
  389. */
  390. private function overwrite(string $message): void
  391. {
  392. if ($this->previousMessage === $message) {
  393. return;
  394. }
  395. $originalMessage = $message;
  396. if ($this->overwrite) {
  397. if (null !== $this->previousMessage) {
  398. if ($this->output instanceof ConsoleSectionOutput) {
  399. $messageLines = explode("\n", $message);
  400. $lineCount = \count($messageLines);
  401. foreach ($messageLines as $messageLine) {
  402. $messageLineLength = Helper::width(Helper::removeDecoration($this->output->getFormatter(), $messageLine));
  403. if ($messageLineLength > $this->terminal->getWidth()) {
  404. $lineCount += floor($messageLineLength / $this->terminal->getWidth());
  405. }
  406. }
  407. $this->output->clear($lineCount);
  408. } else {
  409. if ($this->formatLineCount > 0) {
  410. $this->cursor->moveUp($this->formatLineCount);
  411. }
  412. $this->cursor->moveToColumn(1);
  413. $this->cursor->clearLine();
  414. }
  415. }
  416. } elseif ($this->step > 0) {
  417. $message = \PHP_EOL.$message;
  418. }
  419. $this->previousMessage = $originalMessage;
  420. $this->lastWriteTime = microtime(true);
  421. $this->output->write($message);
  422. ++$this->writeCount;
  423. }
  424. private function determineBestFormat(): string
  425. {
  426. switch ($this->output->getVerbosity()) {
  427. // OutputInterface::VERBOSITY_QUIET: display is disabled anyway
  428. case OutputInterface::VERBOSITY_VERBOSE:
  429. return $this->max ? self::FORMAT_VERBOSE : self::FORMAT_VERBOSE_NOMAX;
  430. case OutputInterface::VERBOSITY_VERY_VERBOSE:
  431. return $this->max ? self::FORMAT_VERY_VERBOSE : self::FORMAT_VERY_VERBOSE_NOMAX;
  432. case OutputInterface::VERBOSITY_DEBUG:
  433. return $this->max ? self::FORMAT_DEBUG : self::FORMAT_DEBUG_NOMAX;
  434. default:
  435. return $this->max ? self::FORMAT_NORMAL : self::FORMAT_NORMAL_NOMAX;
  436. }
  437. }
  438. private static function initPlaceholderFormatters(): array
  439. {
  440. return [
  441. 'bar' => function (self $bar, OutputInterface $output) {
  442. $completeBars = $bar->getBarOffset();
  443. $display = str_repeat($bar->getBarCharacter(), $completeBars);
  444. if ($completeBars < $bar->getBarWidth()) {
  445. $emptyBars = $bar->getBarWidth() - $completeBars - Helper::length(Helper::removeDecoration($output->getFormatter(), $bar->getProgressCharacter()));
  446. $display .= $bar->getProgressCharacter().str_repeat($bar->getEmptyBarCharacter(), $emptyBars);
  447. }
  448. return $display;
  449. },
  450. 'elapsed' => function (self $bar) {
  451. return Helper::formatTime(time() - $bar->getStartTime());
  452. },
  453. 'remaining' => function (self $bar) {
  454. if (!$bar->getMaxSteps()) {
  455. throw new LogicException('Unable to display the remaining time if the maximum number of steps is not set.');
  456. }
  457. return Helper::formatTime($bar->getRemaining());
  458. },
  459. 'estimated' => function (self $bar) {
  460. if (!$bar->getMaxSteps()) {
  461. throw new LogicException('Unable to display the estimated time if the maximum number of steps is not set.');
  462. }
  463. return Helper::formatTime($bar->getEstimated());
  464. },
  465. 'memory' => function (self $bar) {
  466. return Helper::formatMemory(memory_get_usage(true));
  467. },
  468. 'current' => function (self $bar) {
  469. return str_pad($bar->getProgress(), $bar->getStepWidth(), ' ', \STR_PAD_LEFT);
  470. },
  471. 'max' => function (self $bar) {
  472. return $bar->getMaxSteps();
  473. },
  474. 'percent' => function (self $bar) {
  475. return floor($bar->getProgressPercent() * 100);
  476. },
  477. ];
  478. }
  479. private static function initFormats(): array
  480. {
  481. return [
  482. self::FORMAT_NORMAL => ' %current%/%max% [%bar%] %percent:3s%%',
  483. self::FORMAT_NORMAL_NOMAX => ' %current% [%bar%]',
  484. self::FORMAT_VERBOSE => ' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%',
  485. self::FORMAT_VERBOSE_NOMAX => ' %current% [%bar%] %elapsed:6s%',
  486. self::FORMAT_VERY_VERBOSE => ' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s%',
  487. self::FORMAT_VERY_VERBOSE_NOMAX => ' %current% [%bar%] %elapsed:6s%',
  488. self::FORMAT_DEBUG => ' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %memory:6s%',
  489. self::FORMAT_DEBUG_NOMAX => ' %current% [%bar%] %elapsed:6s% %memory:6s%',
  490. ];
  491. }
  492. private function buildLine(): string
  493. {
  494. $regex = "{%([a-z\-_]+)(?:\:([^%]+))?%}i";
  495. $callback = function ($matches) {
  496. if ($formatter = $this::getPlaceholderFormatterDefinition($matches[1])) {
  497. $text = $formatter($this, $this->output);
  498. } elseif (isset($this->messages[$matches[1]])) {
  499. $text = $this->messages[$matches[1]];
  500. } else {
  501. return $matches[0];
  502. }
  503. if (isset($matches[2])) {
  504. $text = sprintf('%'.$matches[2], $text);
  505. }
  506. return $text;
  507. };
  508. $line = preg_replace_callback($regex, $callback, $this->format);
  509. // gets string length for each sub line with multiline format
  510. $linesLength = array_map(function ($subLine) {
  511. return Helper::width(Helper::removeDecoration($this->output->getFormatter(), rtrim($subLine, "\r")));
  512. }, explode("\n", $line));
  513. $linesWidth = max($linesLength);
  514. $terminalWidth = $this->terminal->getWidth();
  515. if ($linesWidth <= $terminalWidth) {
  516. return $line;
  517. }
  518. $this->setBarWidth($this->barWidth - $linesWidth + $terminalWidth);
  519. return preg_replace_callback($regex, $callback, $this->format);
  520. }
  521. }