
| Current Path : /var/www/html/strat/vendor/psy/psysh/src/Readline/Interactive/ |
Linux ift1.ift-informatik.de 5.4.0-216-generic #236-Ubuntu SMP Fri Apr 11 19:53:21 UTC 2025 x86_64 |
| Current File : /var/www/html/strat/vendor/psy/psysh/src/Readline/Interactive/HistorySearch.php |
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive;
use Psy\Output\Theme;
use Psy\Readline\Interactive\Helper\HistorySearchRenderer;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Input\History;
use Psy\Readline\Interactive\Input\Key;
use Psy\Readline\Interactive\Input\WordNavigationPolicy;
use Psy\Readline\Interactive\Renderer\FrameRenderer;
use Psy\Readline\Interactive\Renderer\OverlayViewport;
/**
* History search state machine for interactive readline.
*
* Manages the search query, match list, selection, viewport scrolling,
* and rendering for reverse history search (Ctrl-R).
*/
class HistorySearch
{
private Terminal $terminal;
private History $history;
private FrameRenderer $frameRenderer;
private OverlayViewport $overlayViewport;
private Theme $theme;
private bool $active = false;
private string $searchQuery = '';
/** @var string[] */
private array $searchMatches = [];
private int $currentMatchIndex = -1;
private int $searchScrollOffset = 0;
private bool $searchExpanded = false;
private ?string $savedBufferText = null;
private int $savedCursorPosition = 0;
private ?HistorySearchRenderer $renderer = null;
public function __construct(
Terminal $terminal,
History $history,
FrameRenderer $frameRenderer,
OverlayViewport $overlayViewport,
Theme $theme
) {
$this->terminal = $terminal;
$this->history = $history;
$this->frameRenderer = $frameRenderer;
$this->overlayViewport = $overlayViewport;
$this->theme = $theme;
}
/**
* Set the theme.
*/
public function setTheme(Theme $theme): void
{
$this->theme = $theme;
$this->renderer = null;
}
/**
* Check if search mode is active.
*/
public function isActive(): bool
{
return $this->active;
}
/**
* Enter search mode with an optional initial query.
*/
public function enter(string $query = ''): void
{
$this->active = true;
$this->updateQuery($query);
}
/**
* Exit search mode and clear state.
*/
public function exit(): void
{
$this->active = false;
$this->resetState();
$this->savedBufferText = null;
$this->savedCursorPosition = 0;
$this->frameRenderer->setOverlayLines([]);
}
/**
* Save the current buffer before starting search.
*/
public function saveBuffer(Buffer $buffer): void
{
$this->savedBufferText = $buffer->getText();
$this->savedCursorPosition = $buffer->getCursor();
}
/**
* Handle input while in search mode.
*
* @return bool|null True to stay in search, false if exited (key consumed), null if exited (replay key)
*/
public function handleInput(Key $key, Buffer $buffer)
{
$value = $key->getValue();
$keyStr = (string) $key;
if ($value === "\x07" || $value === "\x1b") { // Ctrl-G or Escape
$this->cancelSearch($buffer);
return false;
} elseif ($value === "\r" || $value === "\n") {
$this->acceptMatch($buffer);
return false;
} elseif ($keyStr === 'escape:[C' || $value === "\x06") { // Right or Ctrl-F: accept, cursor at end
$this->acceptMatch($buffer);
return false;
} elseif ($keyStr === 'escape:[D' || $value === "\x02") { // Left or Ctrl-B: accept, cursor at start
$this->acceptMatch($buffer);
$buffer->setCursor(0);
return false;
} elseif ($keyStr === 'escape:[A' || $value === "\x10" || $value === "\x13") { // Up, Ctrl-P, or Ctrl-S
$this->moveSelection(-1);
return true;
} elseif ($keyStr === 'escape:[B' || $value === "\x0e" || $value === "\x12") { // Down, Ctrl-N, or Ctrl-R
$this->moveSelection(1);
return true;
} elseif ($value === "\x17") { // Ctrl-W: delete last word
$this->removeWord();
return true;
} elseif ($value === "\x08" || $value === "\x7f") { // Backspace
$this->removeChar();
return true;
} elseif ($key->isChar() && !$key->isControl()) {
$this->addChar($value);
return true;
} else {
$this->acceptMatch($buffer);
return null;
}
}
/**
* Render the search UI with overlay.
*/
public function display(): void
{
$preview = $this->getSelectedMatch() ?? '';
$searchPrompt = $this->terminal->format('<whisper>search:</whisper>').' '.$this->searchQuery;
$this->updateOverlay();
$this->frameRenderer->renderSearchFrame($preview, $searchPrompt);
}
/**
* Get the search query.
*/
public function getQuery(): string
{
return $this->searchQuery;
}
/**
* Get the number of search matches.
*/
public function getMatchCount(): int
{
return \count($this->searchMatches);
}
/**
* Get the search selection index.
*/
public function getSelectedIndex(): int
{
return $this->currentMatchIndex;
}
/**
* Get the current search match.
*
* @return string|null The current match, or null if no match
*/
public function getSelectedMatch(): ?string
{
if (empty($this->searchMatches) || $this->currentMatchIndex < 0) {
return null;
}
return $this->searchMatches[$this->currentMatchIndex] ?? null;
}
/**
* Find next match in search results (older entries).
*/
public function findNext(): void
{
$this->moveSelection(1);
}
/**
* Find previous match in search results (newer entries).
*/
public function findPrevious(): void
{
$this->moveSelection(-1);
}
/**
* Update search query and find matches.
*/
public function updateQuery(string $query): void
{
$this->searchQuery = $query;
$this->searchMatches = $this->history->search($query);
$this->currentMatchIndex = empty($this->searchMatches) ? -1 : 0;
$this->searchScrollOffset = 0;
$this->searchExpanded = false;
}
/**
* Add a character to the search query.
*/
private function addChar(string $char): void
{
$this->updateQuery($this->searchQuery.$char);
}
/**
* Remove last character from search query.
*/
private function removeChar(): void
{
if ($this->searchQuery !== '') {
$this->updateQuery(\mb_substr($this->searchQuery, 0, -1));
}
}
/**
* Remove last word from search query.
*/
private function removeWord(): void
{
if ($this->searchQuery === '') {
return;
}
$policy = new WordNavigationPolicy();
$boundary = $policy->findPreviousWord($this->searchQuery, \mb_strlen($this->searchQuery));
$this->updateQuery(\mb_substr($this->searchQuery, 0, $boundary));
}
/**
* Reset search query and match state.
*/
private function resetState(): void
{
$this->searchQuery = '';
$this->searchMatches = [];
$this->currentMatchIndex = -1;
$this->searchScrollOffset = 0;
$this->searchExpanded = false;
}
/**
* Move search selection up or down, adjusting viewport as needed.
*/
private function moveSelection(int $delta): void
{
$count = \count($this->searchMatches);
if ($count === 0) {
$this->terminal->bell();
return;
}
$newIndex = $this->currentMatchIndex + $delta;
if ($newIndex < 0) {
$newIndex = $count - 1;
$this->terminal->bell();
} elseif ($newIndex >= $count) {
$newIndex = 0;
$this->terminal->bell();
}
$this->currentMatchIndex = $newIndex;
$this->adjustViewport();
}
/**
* Accept current search match and exit search mode.
*/
private function acceptMatch(Buffer $buffer): void
{
$match = $this->getSelectedMatch();
if ($match !== null) {
$buffer->clear();
$buffer->insert($match);
}
$this->exit();
}
/**
* Cancel search and restore original buffer.
*/
private function cancelSearch(Buffer $buffer): void
{
if ($this->savedBufferText !== null) {
$buffer->clear();
$buffer->insert($this->savedBufferText);
$buffer->setCursor($this->savedCursorPosition);
}
$this->exit();
}
/**
* Build and set overlay lines for the current search results.
*/
private function updateOverlay(): void
{
if (empty($this->searchMatches)) {
if ($this->searchQuery !== '') {
$noMatch = $this->terminal->format(' <whisper>(no matches)</whisper>');
$this->frameRenderer->setOverlayLines([$noMatch]);
} else {
$this->frameRenderer->setOverlayLines([]);
}
return;
}
if ($this->renderer === null) {
$this->renderer = new HistorySearchRenderer($this->terminal, $this->theme->replayPrompt());
}
$this->renderer->setQuery($this->searchQuery);
$maxRows = $this->getMaxRows();
$lines = $this->renderer->render(
$this->searchMatches,
$this->currentMatchIndex,
$maxRows,
$this->searchScrollOffset,
\count($this->searchMatches),
);
$this->frameRenderer->setOverlayLines($lines);
}
/**
* Get the maximum visible rows for search results.
*
* Returns null if all rows fit without truncation.
*/
private function getMaxRows(): ?int
{
$available = $this->overlayViewport->getAvailableRows(!$this->searchExpanded);
// OverlayViewport already accounts for input frame + search prompt rows.
$menuBudget = \max(1, $available);
$totalRows = \count($this->searchMatches);
if ($totalRows <= $menuBudget) {
return null;
}
// Reserve one row for the status line
return \max(1, $menuBudget - 1);
}
/**
* Adjust search scroll offset so the selected item is visible.
*/
private function adjustViewport(): void
{
$maxRows = $this->getMaxRows();
if ($maxRows === null) {
$this->searchScrollOffset = 0;
return;
}
$selected = $this->currentMatchIndex;
// Scrolled past the top: snap to selection
if ($selected < $this->searchScrollOffset) {
$this->searchScrollOffset = $selected;
}
// Scrolled past the bottom: try expanding the viewport first
if ($selected >= $this->searchScrollOffset + $maxRows && !$this->searchExpanded) {
$this->searchExpanded = true;
$maxRows = $this->getMaxRows();
if ($maxRows === null) {
$this->searchScrollOffset = 0;
return;
}
}
// Still past the bottom after expanding: scroll down
if ($selected >= $this->searchScrollOffset + $maxRows) {
$this->searchScrollOffset = $selected - $maxRows + 1;
}
$count = \count($this->searchMatches);
$this->searchScrollOffset = \max(0, \min($this->searchScrollOffset, $count - $maxRows));
}
}