From e03f44bbe73eebfee77f53ac7d31272d37d21752 Mon Sep 17 00:00:00 2001 From: Mateusz Cholewka Date: Sat, 15 Aug 2026 23:06:34 +0200 Subject: [PATCH] Implement filtering --- AGENTS.md | 6 +-- docs/known-issues.md | 6 +-- src/Console/Application.php | 45 +++++++++++++++-- src/Console/Components/Filter.php | 77 ++++++++++++++++++++++++++++++ src/Console/Components/Header.php | 3 ++ src/Console/Components/LogItem.php | 22 ++++++--- src/Console/Components/LogList.php | 38 +++++++++++++-- src/Console/State/AppState.php | 17 +++++++ src/Console/Windows/Main.php | 19 ++++++++ src/Console/Windows/Popup.php | 2 + 10 files changed, 214 insertions(+), 21 deletions(-) create mode 100644 src/Console/Components/Filter.php diff --git a/AGENTS.md b/AGENTS.md index c5b6dae..6cce18e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,14 +34,14 @@ src/ MessageFormatter.php JSON syntax highlighting for log payloads CommandAttributes/ #[KeyPressed] #[Mouse] #[OnEvent] #[Periodic] #[FirstRender] Commands/Command.php Abstract marker class — currently has zero implementations (see known-issues.md) - Components/ Header, LogList, LogItem, Details, Navigation, Splash - Windows/ Main (root layout), Popup (stub, unused) + Components/ Header, LogList, LogItem, Details, Navigation, Splash, Filter (the `/` filter input line, see typingMode below) + Windows/ Main (root layout), Popup (working shortcuts modal, toggled with `?`) State/AppState.php Central observable state store (magic __get/__set) State/LogItem.php Value object for a single log entry Support/Scroll.php Cursor/offset scrolling math — the best-tested module in the repo docs/ Deeper documentation — see docs/README.md examples/BasicExample.php Runnable demo of the tp() API -tests/Unit/ScrollTest.php Only test suite that currently exists +tests/Unit/ ScrollTest.php, ScrollbarRenderTest.php — only test suites that currently exist ``` ## Running things diff --git a/docs/known-issues.md b/docs/known-issues.md index 9363ecd..0140931 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -43,15 +43,15 @@ Reported case: pressing Space in `LogList` with zero log entries hit `LogList::s 1. Root cause: `LogList::select()` now reads via `?? null` and no-ops when there's no log at the cursor, instead of indexing blind. 2. General case: `Console\ErrorHandler::install()` (called first thing in `Application::run()`, before raw mode/alt-screen are enabled) installs a `set_error_handler` that logs any warning/notice/deprecation to `tapper.log` (`LogPath::resolve()`, same directory as `tapper.sock`) and sets a 5-second `AppState::errorNotice` banner (rendered by `Header`) instead of letting PHP print to stdout. Uncaught `Throwable`s still propagate normally — `bin/tapper`'s `catch (\Throwable $e)` (widened from `\Exception`) calls `Application::close()` to restore the terminal, logs via `ErrorHandler::logThrowable()`, then rethrows so the trace prints on a normal, restored terminal rather than corrupting the TUI. -Note: this intentionally does *not* revive `Windows/Popup.php` for the "check the logs" notice — see the dead-scaffolding entry below; the banner lives in `Header` via two new `AppState` fields (`errorNotice`, `errorNoticeExpiresAt`) instead. +Note: this intentionally does *not* route the "check the logs" notice through `Windows/Popup.php` (a full-screen modal isn't the right shape for a transient 5-second notice); the banner lives in `Header` via two new `AppState` fields (`errorNotice`, `errorNoticeExpiresAt`) instead. ## Incomplete abstractions (finish or remove, don't extend as-is) - **`Rpc\JsonRpc` interface / `JsonRpcResult` / `JsonRpcError`** — `JsonRpcResult.php` and `JsonRpcError.php` are empty files; `JsonRpc`'s encode/parse methods exist only as commented-out code; `Server.php` builds raw arrays instead of using these types. See `rpc-protocol.md`. - **`Commands/Command.php` + `CommandInvoker`** — an empty abstract class and an invoker with zero concrete `Command` subclasses anywhere in the codebase. Either commit to modeling user actions as `Command` objects (useful for undo/redo, macro recording, remapping keys later) or remove the layer until there's a real consumer. - **`docs/openrpc.json`** — describes a method (`appendLog`, nested `details` param) that doesn't match the implemented protocol (`log`/`wait`, flat params). Regenerate from `Server.php` or delete. -- **`Windows/Popup.php`** — exists, is instantiated and checked (`Application::draw()` renders it when `isActive()`), but its `view()` just returns an empty `BlockWidget::default()` and nothing in the codebase ever calls `activate()` on it (confirmed by grep — zero hits). Dead scaffolding for a not-yet-built feature (likely a modal/dialog system) — fine to leave, but don't assume it's a working popup system if you go looking for one. -- **`AppState::typingMode` is only ever set to `false`**, never `true`, anywhere in the codebase (confirmed by grep). `Application::handleEventInTypingMode()` therefore always returns early — its entire body (redirecting character input, exiting on Esc) is currently unreachable dead code, presumably scaffolding for a not-yet-built text-input feature. +- ~~**`Windows/Popup.php`**~~ — corrected 2026-08-16: this entry was stale. `Popup` is a working shortcuts-help modal (toggled with `?`, closed with `Esc`, real `view()` content) — not dead scaffolding. `Application::draw()` renders it based on `AppState::popupOpen`, not `isActive()`; `Popup`'s own bindings are `global: true` so its `isActive` is never touched at all, by design. +- ~~**`AppState::typingMode` is only ever set to `false`**~~ — resolved 2026-08-16: it's now the basis for log filtering. `LogList::startFilter()` (bound to `/`) sets it `true`; `Console\Components\Filter` (a standalone overlay owned by `Application`, mirroring how `Popup` is owned rather than being a `Main` child) uses `Application::handleEventInTypingMode()`'s `'input'` event to build `AppState::filter`, live-filtered via `AppState::filteredLogs()` (case-insensitive substring match on `LogItem::$message`). Enter confirms and exits typing mode keeping the filter; Esc cancels and clears it. See `Filter.php` for why its key bindings are deliberately non-global. One related fix required to make this safe: `handleEventInTypingMode()` used to pass the *raw stdin chunk* as the `'input'` payload for every drained event — since one stdin read can drain several events (fast typing/paste), they'd all have shared and duplicated the same bytes. It now emits the event's own `CharKeyEvent::$char` instead. Also: `Application::startInputHandling()`'s `'q'`-quits-the-app check now only fires outside typing mode, since `q` is a normal character you'd type into the filter box. - **`EventBus` has no unsubscribe** — every `listen()` is permanent. This is fine for the fixed component tree Tapper has today (components are never torn down mid-run), but is a real gap the moment anything needs dynamic component lifecycles. (The `wait()` leak that used to be a direct symptom of this gap is fixed above by having `Server` register one permanent listener instead of one per call — that sidesteps the gap for this one case, it doesn't close it.) ## Framework-extraction readiness diff --git a/src/Console/Application.php b/src/Console/Application.php index ee65e9c..e7c48cd 100644 --- a/src/Console/Application.php +++ b/src/Console/Application.php @@ -18,6 +18,7 @@ use PhpTui\Tui\Extension\Core\Widget\CompositeWidget; use React\EventLoop\LoopInterface; use React\Stream\ReadableResourceStream; +use Tapper\Console\Components\Filter; use Tapper\Console\State\AppState; use Tapper\Console\Windows\Main; use Tapper\Console\Windows\Popup; @@ -33,6 +34,8 @@ class Application private Popup $popup; + private Filter $filter; + private Area $area; private bool $shouldDraw = true; @@ -83,6 +86,20 @@ private function init(): void { $this->window = $this->container->make(Main::class); $this->popup = $this->container->make(Popup::class); + $this->filter = $this->container->make(Filter::class); + + // Filter's own key bindings are non-global (see Filter.php), so it needs its + // isActive toggled explicitly — mirrors Main::afterInit()'s LogList/Details dance. + // Synchronous (no futureTick): typing a filter query can drain several char events + // from one stdin read, all dispatched within the same tick, and Filter needs to be + // active in time to catch them — see the matching note in Main::afterInit(). + $this->appState->observe('typingMode', function (bool $typing): void { + if ($typing) { + $this->filter->activate(); + } else { + $this->filter->deactivate(); + } + }); } private function startRendering(): void @@ -114,6 +131,10 @@ private function draw(Area $area): void $widgets[] = $this->popup->render($area); } + if ($this->appState->typingMode) { + $widgets[] = $this->filter->render($area); + } + $composite = CompositeWidget::fromWidgets(...$widgets); $this->display->draw($composite); @@ -126,13 +147,24 @@ private function startInputHandling(): void $this->eventParser->advance($data, false); foreach ($this->eventParser->drain() as $event) { + // Captured before handleEvent(): if this exact event is the one that just + // flipped typingMode on (e.g. the '/' that starts a filter), it shouldn't + // also be treated as typed filter input — that key press activates typing + // mode, it doesn't type into it. + $wasTyping = $this->appState->typingMode; + $this->handleEvent($event); - $this->handleEventInTypingMode($event, $data); + + if ($wasTyping) { + $this->handleEventInTypingMode($event); + } } // `stty raw` (enabled by Terminal::enableRawMode()) disables ISIG, so Ctrl+C // never reaches us as SIGINT — it arrives as byte 0x03 (ETX) on stdin instead. - if ($data === 'q' || $data === "\x03") { + // Ctrl+C always quits; 'q' only quits outside typing mode, since it's a normal + // character you'd type into the filter box otherwise. + if ($data === "\x03" || (! $this->appState->typingMode && $data === 'q')) { $this->close(); } }); @@ -151,7 +183,7 @@ private function handleEvent(Event $event): void } } - private function handleEventInTypingMode(Event $event, $data): void + private function handleEventInTypingMode(Event $event): void { if (! $this->appState->typingMode) { return; @@ -166,12 +198,15 @@ private function handleEventInTypingMode(Event $event, $data): void } if ($event instanceof CharKeyEvent) { - $this->eventBus->emit('input', ['data' => $data]); + // Emit the event's own char, not the raw stdin chunk — a single stdin read can + // drain multiple events (fast typing/paste), and they'd otherwise all share the + // same raw bytes, duplicating input. + $this->eventBus->emit('input', ['char' => $event->char]); return; } - if ($event->code === KeyCode::Esc) { + if ($event instanceof CodedKeyEvent && $event->code === KeyCode::Esc) { $this->appState->typingMode = false; return; diff --git a/src/Console/Components/Filter.php b/src/Console/Components/Filter.php new file mode 100644 index 0000000..e48075d --- /dev/null +++ b/src/Console/Components/Filter.php @@ -0,0 +1,77 @@ +appState->filter .= $data['char']; + } + + #[KeyPressed(KeyCode::Backspace)] + public function backspace(): void + { + $this->appState->filter = mb_substr($this->appState->filter, 0, -1); + } + + #[KeyPressed(KeyCode::Enter)] + public function confirm(): void + { + $this->appState->typingMode = false; + } + + #[KeyPressed(KeyCode::Esc)] + public function cancel(): void + { + $this->appState->filter = ''; + $this->appState->typingMode = false; + } + + protected function view(Area $area): Widget + { + $barArea = Area::fromScalars( + $area->position->x, + $area->position->y + $area->height - 1, + $area->width, + 1, + ); + + $prefix = Span::styled(' /', Style::default()->fg(RgbColor::fromHex(Palette::ACCENT))); + $text = Span::fromString($this->appState->filter); + + // ParagraphRenderer only writes cells under actual glyphs — it doesn't blank the + // rest of its area — so without explicit padding, Navigation's text (rendered into + // this same row a moment earlier) bleeds through past the end of the filter text. + $padding = Span::fromString(str_repeat(' ', max(0, $barArea->width - $prefix->width() - $text->width()))); + + return BufferWidget::new(function (BufferContext $context) use ($barArea, $prefix, $text, $padding): void { + $context->draw( + ParagraphWidget::fromSpans($prefix, $text, $padding), + $barArea, + ); + }); + } +} diff --git a/src/Console/Components/Header.php b/src/Console/Components/Header.php index a4ccc3f..13e25f4 100644 --- a/src/Console/Components/Header.php +++ b/src/Console/Components/Header.php @@ -53,6 +53,9 @@ protected function view(Area $area): Widget $unread ? Span::fromString(sprintf(' (↓%s)', $this->appState->unread))->yellow() : Span::fromString(''), Span::fromString(' | '), Span::fromString(sprintf('port: %s', $this->appState->port)), + $this->appState->filter !== '' && ! $this->appState->typingMode + ? Span::styled(sprintf(' | filter: %s', $this->appState->filter), Style::default()->fg(RgbColor::fromHex(Palette::ACCENT))) + : Span::fromString(''), $this->appState->errorNotice !== null ? Span::styled(' | '.$this->appState->errorNotice, Style::default()->fg(RgbColor::fromHex(Palette::ERROR))) : Span::fromString(''), diff --git a/src/Console/Components/LogItem.php b/src/Console/Components/LogItem.php index 29c5544..5f80055 100644 --- a/src/Console/Components/LogItem.php +++ b/src/Console/Components/LogItem.php @@ -30,9 +30,15 @@ class LogItem extends Component private ?LogItemState $log = null; - public function setData(?LogItemState $log): void + // Position of $log within the currently displayed (possibly filtered) list — NOT + // $log->id, which is a permanent identifier assigned when the log was received and + // only coincidentally matches list position when nothing is filtered out. + private ?int $index = null; + + public function setData(?LogItemState $log, ?int $index = null): void { $this->log = $log; + $this->index = $index; } #[Mouse(MouseEventKind::Down, global: true)] @@ -41,11 +47,11 @@ public function mouseMove(array $data): void /** @var MouseEvent $event */ $event = $data['event']; - if (! $this->log) { + if (! $this->log || $this->index === null) { return; } - $elementPosInView = ($this->log->id - $this->appState->offset); + $elementPosInView = ($this->index - $this->appState->offset); $itemPosition = ($elementPosInView * self::HEIGHT) + 1; if ($event->row > $itemPosition @@ -59,10 +65,14 @@ public function mouseMove(array $data): void public function click(): void { - if ($this->appState->cursor === $this->log->id) { + if ($this->index === null) { + return; + } + + if ($this->appState->cursor === $this->index) { $this->appState->previewLog = $this->log; } else { - $this->appState->cursor = $this->log->id; + $this->appState->cursor = $this->index; } } @@ -112,7 +122,7 @@ protected function view(Area $area): Widget $dt = DateTime::createFromFormat('U.u', sprintf('%.6f', $this->log->timestamp)); $time = $dt->format('H:i:s.u'); - $mark = $this->appState->cursor === $this->log->id; + $mark = $this->index !== null && $this->appState->cursor === $this->index; $darkGray = Style::default()->darkGray(); $markerColor = RgbColor::fromHex(Palette::SELECTION_BG); diff --git a/src/Console/Components/LogList.php b/src/Console/Components/LogList.php index fbce96a..6708592 100644 --- a/src/Console/Components/LogList.php +++ b/src/Console/Components/LogList.php @@ -38,6 +38,7 @@ public function beforeInit(): void $this->scroll = new Scroll($this->appState); $this->appState->observe('logs', fn (): null => $this->updateLogs()); + $this->appState->observe('filter', fn (): null => $this->updateFilter()); $this->appState->observe('cursor', function (int $cursor): void { $this->appState->live = $cursor >= $this->count - 1; }); @@ -108,10 +109,23 @@ public function pageDown(): void $this->scroll->jump($this->appState->cursor + $halfPage, $this->count, $this->visible); } + #[KeyPressed('/')] + public function startFilter(): void + { + // Only write if it's actually changing — __set() notifies unconditionally, and a + // no-op write here would still trigger updateFilter()'s jump-to-top before the user + // has typed anything. + if ($this->appState->filter !== '') { + $this->appState->filter = ''; + } + + $this->appState->typingMode = true; + } + #[KeyPressed(' ')] public function select(): void { - $log = $this->appState->logs()[$this->appState->cursor] ?? null; + $log = $this->appState->filteredLogs()[$this->appState->cursor] ?? null; if ($log === null) { return; @@ -139,11 +153,16 @@ public function clear(): void $this->appState->cursor = 0; $this->appState->offset = 0; $this->appState->unread = 0; + $this->appState->filter = ''; } #[KeyPressed(KeyCode::Esc)] public function backToLive(): void { + if ($this->appState->filter !== '') { + $this->appState->filter = ''; + } + $this->scroll->scrollToBottom($this->count, $this->visible); } @@ -153,13 +172,24 @@ private function updateLogs(): void $this->appState->unread++; } - $this->count = count($this->appState->logs()); + $this->refreshCount(); if ($this->appState->live) { $this->scroll->scrollToBottom($this->count, $this->visible); } } + private function updateFilter(): void + { + $this->refreshCount(); + $this->scroll->jump(0, $this->count, $this->visible); + } + + private function refreshCount(): void + { + $this->count = count($this->appState->filteredLogs()); + } + private function ensureVisible(): void { $visible = $this->visible; @@ -180,8 +210,8 @@ private function fill(): void { foreach ($this->listItems as $i => $component) { $logIndex = $this->appState->offset + $i; - $log = $this->appState->logs()[$logIndex] ?? null; - $component->setData($log); + $log = $this->appState->filteredLogs()[$logIndex] ?? null; + $component->setData($log, $logIndex); } } diff --git a/src/Console/State/AppState.php b/src/Console/State/AppState.php index 7b6380d..6bd7dc2 100644 --- a/src/Console/State/AppState.php +++ b/src/Console/State/AppState.php @@ -23,6 +23,7 @@ * @property bool $popupOpen * @property ?string $errorNotice * @property float $errorNoticeExpiresAt + * @property string $filter */ class AppState { @@ -54,6 +55,7 @@ public function __construct( private bool $popupOpen = false, private ?string $errorNotice = null, private float $errorNoticeExpiresAt = 0.0, + private string $filter = '', ) {} /** @@ -64,6 +66,21 @@ public function logs(): array return $this->logs; } + /** + * @return LogItem[] + */ + public function filteredLogs(): array + { + if ($this->filter === '') { + return $this->logs; + } + + return array_values(array_filter( + $this->logs, + fn (LogItem $log): bool => str_contains(strtolower($log->message), strtolower($this->filter)), + )); + } + public function setOnChange(callable $change): void { $this->change = $change; diff --git a/src/Console/Windows/Main.php b/src/Console/Windows/Main.php index 139da06..fc84120 100644 --- a/src/Console/Windows/Main.php +++ b/src/Console/Windows/Main.php @@ -75,6 +75,25 @@ public function afterInit(): void } }); }); + + // Deliberately synchronous, unlike the observers above: typing a filter query can + // drain several char events from a single stdin read (fast typing/paste), all + // dispatched within the same tick. Deferring LogList's deactivation to the next + // tick left it briefly still-active for those characters, alongside Application's + // Filter — a one-tick race that dropped/misrouted the first few keystrokes. + $this->appState->observe('typingMode', function (bool $typing) { + if ($typing) { + $this->componentInstances[LogList::class]->deactivate(); + + return; + } + + if ($this->appState->previewLog) { + $this->componentInstances[Details::class]->activate(); + } else { + $this->componentInstances[LogList::class]->activate(); + } + }); } protected function view(Area $area): Widget diff --git a/src/Console/Windows/Popup.php b/src/Console/Windows/Popup.php index 3b0ef1c..bf95c43 100644 --- a/src/Console/Windows/Popup.php +++ b/src/Console/Windows/Popup.php @@ -52,6 +52,8 @@ protected function view(Area $area): Widget ['enter', '', 'Continue a paused wait'], ['backspace', 'esc', 'Close details / back to live'], ['ctrl+l', '', 'Clear all logs'], + ['/', '', 'Filter logs'], + ['enter', 'esc', 'Confirm / cancel filter'], ['?', '', 'Toggle this help'], ['q', '', 'Quit'], ];