From 0e4bf0e61bd0d90800c5db75e98f8b8faea212a3 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Wed, 25 Mar 2026 21:25:01 +0100 Subject: [PATCH 01/22] [Tui] Add the component --- composer.json | 3 + src/Symfony/Component/Tui/.gitattributes | 5 + src/Symfony/Component/Tui/.gitignore | 5 + .../Component/Tui/Ansi/AnsiCodeTracker.php | 317 +++ src/Symfony/Component/Tui/Ansi/AnsiUtils.php | 629 +++++ .../Tui/Ansi/ScreenBufferHtmlRenderer.php | 261 ++ .../Component/Tui/Ansi/TextWrapper.php | 461 ++++ src/Symfony/Component/Tui/CHANGELOG | 7 + .../Component/Tui/Event/AbstractEvent.php | 39 + .../Component/Tui/Event/CancelEvent.php | 23 + .../Component/Tui/Event/ChangeEvent.php | 47 + .../Component/Tui/Event/FocusEvent.php | 40 + src/Symfony/Component/Tui/Event/QuitEvent.php | 23 + .../Component/Tui/Event/SelectEvent.php | 68 + .../Tui/Event/SelectionChangeEvent.php | 71 + .../Tui/Event/SettingChangeEvent.php | 71 + .../Component/Tui/Event/SubmitEvent.php | 47 + src/Symfony/Component/Tui/Event/TickEvent.php | 57 + .../Tui/Exception/ExceptionInterface.php | 23 + .../Exception/InvalidArgumentException.php | 21 + .../Tui/Exception/LogicException.php | 21 + .../Tui/Exception/RenderException.php | 48 + .../Tui/Exception/RuntimeException.php | 21 + .../Component/Tui/Focus/FocusManager.php | 236 ++ src/Symfony/Component/Tui/Input/Key.php | 92 + src/Symfony/Component/Tui/Input/KeyParser.php | 966 +++++++ .../Component/Tui/Input/Keybindings.php | 82 + .../Component/Tui/Input/StdinBuffer.php | 388 +++ src/Symfony/Component/Tui/LICENSE | 19 + .../Component/Tui/Loop/AdaptativeTicker.php | 115 + .../Tui/Loop/FixedStepAccumulator.php | 73 + src/Symfony/Component/Tui/Loop/LoopClock.php | 59 + .../Component/Tui/Loop/PeriodicStepper.php | 84 + .../Tui/Loop/TickRuntimeInterface.php | 26 + .../Component/Tui/Loop/TickScheduler.php | 103 + src/Symfony/Component/Tui/README.md | 19 + .../Component/Tui/Render/CellBuffer.php | 569 +++++ .../Component/Tui/Render/ChromeApplier.php | 225 ++ .../Component/Tui/Render/Compositor.php | 70 + src/Symfony/Component/Tui/Render/Layer.php | 77 + .../Component/Tui/Render/LayoutEngine.php | 476 ++++ .../Component/Tui/Render/PositionTracker.php | 171 ++ .../Component/Tui/Render/RenderContext.php | 102 + .../Tui/Render/RenderRequestorInterface.php | 43 + src/Symfony/Component/Tui/Render/Renderer.php | 337 +++ .../Component/Tui/Render/ScreenWriter.php | 546 ++++ .../Component/Tui/Render/WidgetRect.php | 79 + .../Tui/Render/WidgetRendererInterface.php | 48 + src/Symfony/Component/Tui/Style/Align.php | 29 + src/Symfony/Component/Tui/Style/Border.php | 238 ++ .../Component/Tui/Style/BorderPattern.php | 447 ++++ src/Symfony/Component/Tui/Style/Color.php | 387 +++ src/Symfony/Component/Tui/Style/ColorType.php | 31 + .../Component/Tui/Style/CursorShape.php | 32 + .../Component/Tui/Style/DefaultStyleSheet.php | 97 + src/Symfony/Component/Tui/Style/Direction.php | 25 + src/Symfony/Component/Tui/Style/Padding.php | 123 + src/Symfony/Component/Tui/Style/Style.php | 823 ++++++ .../Component/Tui/Style/StyleSheet.php | 464 ++++ .../Tui/Style/TailwindStylesheet.php | 469 ++++ src/Symfony/Component/Tui/Style/TextAlign.php | 26 + .../Component/Tui/Style/VerticalAlign.php | 29 + .../Component/Tui/Terminal/ScreenBuffer.php | 840 +++++++ .../Component/Tui/Terminal/TeeTerminal.php | 133 + .../Component/Tui/Terminal/Terminal.php | 342 +++ .../Tui/Terminal/TerminalInterface.php | 116 + .../Tui/Terminal/VirtualTerminal.php | 241 ++ .../Tui/Tests/Ansi/AnsiCodeTrackerTest.php | 432 ++++ .../Tui/Tests/Ansi/AnsiUtilsTest.php | 508 ++++ .../Ansi/ScreenBufferHtmlRendererTest.php | 262 ++ .../Tui/Tests/Ansi/TextWrapperTest.php | 279 ++ .../Tui/Tests/Event/TickEventTest.php | 58 + .../Tui/Tests/Focus/FocusManagerTest.php | 83 + .../Tui/Tests/Input/KeyParserTest.php | 231 ++ .../Tui/Tests/Input/StdinBufferTest.php | 419 +++ .../Component/Tui/Tests/KeySequenceParser.php | 88 + .../Tui/Tests/KeySequenceParserTest.php | 81 + .../Tui/Tests/Loop/AdaptativeTickerTest.php | 117 + .../Tests/Loop/FixedStepAccumulatorTest.php | 66 + .../Tui/Tests/Loop/LoopClockTest.php | 46 + .../Tui/Tests/Loop/PeriodicStepperTest.php | 57 + .../Tui/Tests/Loop/TickSchedulerTest.php | 88 + src/Symfony/Component/Tui/Tests/README.md | 16 + .../Component/Tui/Tests/Render/AlignTest.php | 261 ++ .../Tui/Tests/Render/CellBufferTest.php | 397 +++ .../Tui/Tests/Render/ChromeApplierTest.php | 367 +++ .../Tui/Tests/Render/LayoutEngineTest.php | 371 +++ .../Tui/Tests/Render/PositionTrackerTest.php | 157 ++ .../Tui/Tests/Render/RendererTest.php | 1087 ++++++++ .../Tui/Tests/Render/ScreenWriterTest.php | 623 +++++ .../Tui/Tests/Render/TextAlignTest.php | 162 ++ .../Tui/Tests/Render/WidgetRectTest.php | 56 + src/Symfony/Component/Tui/Tests/StateDiff.php | 555 ++++ .../Component/Tui/Tests/StateDiffTest.php | 145 ++ .../Tui/Tests/Style/BorderPatternTest.php | 57 + .../Component/Tui/Tests/Style/BorderTest.php | 302 +++ .../Component/Tui/Tests/Style/ColorTest.php | 306 +++ .../Component/Tui/Tests/Style/PaddingTest.php | 104 + .../Tui/Tests/Style/StyleSheetTest.php | 865 +++++++ .../Component/Tui/Tests/Style/StyleTest.php | 424 ++++ .../Tests/Style/TailwindStylesheetTest.php | 1099 ++++++++ .../Component/Tui/Tests/Terminal/BellTest.php | 39 + .../Tui/Tests/Terminal/ScreenBufferTest.php | 590 +++++ .../Tui/Tests/Terminal/TerminalTest.php | 53 + .../Tests/Terminal/VirtualTerminalTest.php | 126 + .../Tui/Tests/TuiCascadingStylesheetTest.php | 131 + src/Symfony/Component/Tui/Tests/TuiTest.php | 489 ++++ .../Tests/Widget/BracketedPasteTraitTest.php | 152 ++ .../Widget/CancellableLoaderWidgetTest.php | 170 ++ .../Tui/Tests/Widget/ContainerTest.php | 605 +++++ .../Widget/Editor/EditorDocumentTest.php | 531 ++++ .../Widget/Editor/EditorRendererTest.php | 167 ++ .../Widget/Editor/EditorViewportTest.php | 142 ++ .../Component/Tui/Tests/Widget/EditorTest.php | 1479 +++++++++++ .../Tests/Widget/Figlet/FigletFontTest.php | 97 + .../Widget/Figlet/FigletRendererTest.php | 194 ++ .../Tests/Widget/Figlet/FontRegistryTest.php | 102 + .../Component/Tui/Tests/Widget/FigletTest.php | 294 +++ .../Component/Tui/Tests/Widget/InputTest.php | 491 ++++ .../Component/Tui/Tests/Widget/LoaderTest.php | 217 ++ .../Tui/Tests/Widget/MarkdownTest.php | 207 ++ .../Tui/Tests/Widget/ProgressBarTest.php | 527 ++++ .../Tui/Tests/Widget/SelectListTest.php | 256 ++ .../Tui/Tests/Widget/SettingsListTest.php | 323 +++ .../Component/Tui/Tests/Widget/TextTest.php | 235 ++ .../Tui/Tests/Widget/Util/KillRingTest.php | 178 ++ .../Tui/Tests/Widget/Util/LineTest.php | 318 +++ .../Tui/Tests/Widget/Util/StringUtilsTest.php | 68 + .../Tests/Widget/Util/WordNavigatorTest.php | 99 + .../Tui/Tests/Widget/WidgetTreeTest.php | 112 + src/Symfony/Component/Tui/Tui.php | 586 +++++ .../Component/Tui/Widget/AbstractWidget.php | 455 ++++ .../Tui/Widget/BracketedPasteTrait.php | 78 + .../Tui/Widget/CancellableLoaderWidget.php | 90 + .../Tui/Widget/ContainerInterface.php | 42 + .../Component/Tui/Widget/ContainerWidget.php | 168 ++ .../Component/Tui/Widget/DirtyWidgetTrait.php | 34 + .../Tui/Widget/Editor/EditorDocument.php | 732 ++++++ .../Tui/Widget/Editor/EditorRenderer.php | 222 ++ .../Tui/Widget/Editor/EditorViewport.php | 161 ++ .../Component/Tui/Widget/EditorWidget.php | 599 +++++ .../Tui/Widget/Figlet/FigletFont.php | 205 ++ .../Tui/Widget/Figlet/FigletRenderer.php | 86 + .../Tui/Widget/Figlet/FontRegistry.php | 102 + .../Component/Tui/Widget/Figlet/fonts/big.flf | 2204 ++++++++++++++++ .../Tui/Widget/Figlet/fonts/mini.flf | 899 +++++++ .../Tui/Widget/Figlet/fonts/slant.flf | 1295 ++++++++++ .../Tui/Widget/Figlet/fonts/small.flf | 1097 ++++++++ .../Tui/Widget/Figlet/fonts/standard.flf | 2238 +++++++++++++++++ .../Tui/Widget/FocusableInterface.php | 82 + .../Component/Tui/Widget/FocusableTrait.php | 45 + .../Component/Tui/Widget/InputWidget.php | 450 ++++ .../Component/Tui/Widget/KeybindingsTrait.php | 90 + .../Component/Tui/Widget/LoaderWidget.php | 260 ++ .../Tui/Widget/Markdown/DarkTerminalTheme.php | 68 + .../Component/Tui/Widget/MarkdownWidget.php | 563 +++++ .../Component/Tui/Widget/ParentInterface.php | 32 + .../Tui/Widget/ProgressBarWidget.php | 598 +++++ .../Component/Tui/Widget/QuitableTrait.php | 64 + .../Tui/Widget/ScheduledTickTrait.php | 82 + .../Component/Tui/Widget/SelectListWidget.php | 355 +++ .../Component/Tui/Widget/SettingItem.php | 100 + .../Tui/Widget/SettingsListWidget.php | 417 +++ .../Component/Tui/Widget/TextWidget.php | 140 ++ .../Component/Tui/Widget/Util/KillRing.php | 141 ++ .../Component/Tui/Widget/Util/Line.php | 289 +++ .../Component/Tui/Widget/Util/StringUtils.php | 56 + .../Tui/Widget/Util/WordNavigator.php | 126 + .../Widget/VerticallyExpandableInterface.php | 39 + .../Component/Tui/Widget/WidgetContext.php | 137 + .../Component/Tui/Widget/WidgetTree.php | 94 + src/Symfony/Component/Tui/composer.json | 41 + src/Symfony/Component/Tui/phpstan.neon.dist | 4 + src/Symfony/Component/Tui/phpunit.xml.dist | 34 + 174 files changed, 46366 insertions(+) create mode 100644 src/Symfony/Component/Tui/.gitattributes create mode 100644 src/Symfony/Component/Tui/.gitignore create mode 100644 src/Symfony/Component/Tui/Ansi/AnsiCodeTracker.php create mode 100644 src/Symfony/Component/Tui/Ansi/AnsiUtils.php create mode 100644 src/Symfony/Component/Tui/Ansi/ScreenBufferHtmlRenderer.php create mode 100644 src/Symfony/Component/Tui/Ansi/TextWrapper.php create mode 100644 src/Symfony/Component/Tui/CHANGELOG create mode 100644 src/Symfony/Component/Tui/Event/AbstractEvent.php create mode 100644 src/Symfony/Component/Tui/Event/CancelEvent.php create mode 100644 src/Symfony/Component/Tui/Event/ChangeEvent.php create mode 100644 src/Symfony/Component/Tui/Event/FocusEvent.php create mode 100644 src/Symfony/Component/Tui/Event/QuitEvent.php create mode 100644 src/Symfony/Component/Tui/Event/SelectEvent.php create mode 100644 src/Symfony/Component/Tui/Event/SelectionChangeEvent.php create mode 100644 src/Symfony/Component/Tui/Event/SettingChangeEvent.php create mode 100644 src/Symfony/Component/Tui/Event/SubmitEvent.php create mode 100644 src/Symfony/Component/Tui/Event/TickEvent.php create mode 100644 src/Symfony/Component/Tui/Exception/ExceptionInterface.php create mode 100644 src/Symfony/Component/Tui/Exception/InvalidArgumentException.php create mode 100644 src/Symfony/Component/Tui/Exception/LogicException.php create mode 100644 src/Symfony/Component/Tui/Exception/RenderException.php create mode 100644 src/Symfony/Component/Tui/Exception/RuntimeException.php create mode 100644 src/Symfony/Component/Tui/Focus/FocusManager.php create mode 100644 src/Symfony/Component/Tui/Input/Key.php create mode 100644 src/Symfony/Component/Tui/Input/KeyParser.php create mode 100644 src/Symfony/Component/Tui/Input/Keybindings.php create mode 100644 src/Symfony/Component/Tui/Input/StdinBuffer.php create mode 100644 src/Symfony/Component/Tui/LICENSE create mode 100644 src/Symfony/Component/Tui/Loop/AdaptativeTicker.php create mode 100644 src/Symfony/Component/Tui/Loop/FixedStepAccumulator.php create mode 100644 src/Symfony/Component/Tui/Loop/LoopClock.php create mode 100644 src/Symfony/Component/Tui/Loop/PeriodicStepper.php create mode 100644 src/Symfony/Component/Tui/Loop/TickRuntimeInterface.php create mode 100644 src/Symfony/Component/Tui/Loop/TickScheduler.php create mode 100644 src/Symfony/Component/Tui/README.md create mode 100644 src/Symfony/Component/Tui/Render/CellBuffer.php create mode 100644 src/Symfony/Component/Tui/Render/ChromeApplier.php create mode 100644 src/Symfony/Component/Tui/Render/Compositor.php create mode 100644 src/Symfony/Component/Tui/Render/Layer.php create mode 100644 src/Symfony/Component/Tui/Render/LayoutEngine.php create mode 100644 src/Symfony/Component/Tui/Render/PositionTracker.php create mode 100644 src/Symfony/Component/Tui/Render/RenderContext.php create mode 100644 src/Symfony/Component/Tui/Render/RenderRequestorInterface.php create mode 100644 src/Symfony/Component/Tui/Render/Renderer.php create mode 100644 src/Symfony/Component/Tui/Render/ScreenWriter.php create mode 100644 src/Symfony/Component/Tui/Render/WidgetRect.php create mode 100644 src/Symfony/Component/Tui/Render/WidgetRendererInterface.php create mode 100644 src/Symfony/Component/Tui/Style/Align.php create mode 100644 src/Symfony/Component/Tui/Style/Border.php create mode 100644 src/Symfony/Component/Tui/Style/BorderPattern.php create mode 100644 src/Symfony/Component/Tui/Style/Color.php create mode 100644 src/Symfony/Component/Tui/Style/ColorType.php create mode 100644 src/Symfony/Component/Tui/Style/CursorShape.php create mode 100644 src/Symfony/Component/Tui/Style/DefaultStyleSheet.php create mode 100644 src/Symfony/Component/Tui/Style/Direction.php create mode 100644 src/Symfony/Component/Tui/Style/Padding.php create mode 100644 src/Symfony/Component/Tui/Style/Style.php create mode 100644 src/Symfony/Component/Tui/Style/StyleSheet.php create mode 100644 src/Symfony/Component/Tui/Style/TailwindStylesheet.php create mode 100644 src/Symfony/Component/Tui/Style/TextAlign.php create mode 100644 src/Symfony/Component/Tui/Style/VerticalAlign.php create mode 100644 src/Symfony/Component/Tui/Terminal/ScreenBuffer.php create mode 100644 src/Symfony/Component/Tui/Terminal/TeeTerminal.php create mode 100644 src/Symfony/Component/Tui/Terminal/Terminal.php create mode 100644 src/Symfony/Component/Tui/Terminal/TerminalInterface.php create mode 100644 src/Symfony/Component/Tui/Terminal/VirtualTerminal.php create mode 100644 src/Symfony/Component/Tui/Tests/Ansi/AnsiCodeTrackerTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Ansi/AnsiUtilsTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Ansi/ScreenBufferHtmlRendererTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Ansi/TextWrapperTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Event/TickEventTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Focus/FocusManagerTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Input/KeyParserTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Input/StdinBufferTest.php create mode 100644 src/Symfony/Component/Tui/Tests/KeySequenceParser.php create mode 100644 src/Symfony/Component/Tui/Tests/KeySequenceParserTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Loop/AdaptativeTickerTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Loop/FixedStepAccumulatorTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Loop/LoopClockTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Loop/PeriodicStepperTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Loop/TickSchedulerTest.php create mode 100644 src/Symfony/Component/Tui/Tests/README.md create mode 100644 src/Symfony/Component/Tui/Tests/Render/AlignTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Render/CellBufferTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Render/ChromeApplierTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Render/LayoutEngineTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Render/PositionTrackerTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Render/RendererTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Render/ScreenWriterTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Render/TextAlignTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Render/WidgetRectTest.php create mode 100644 src/Symfony/Component/Tui/Tests/StateDiff.php create mode 100644 src/Symfony/Component/Tui/Tests/StateDiffTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Style/BorderPatternTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Style/BorderTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Style/ColorTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Style/PaddingTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Style/StyleSheetTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Style/StyleTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Style/TailwindStylesheetTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Terminal/BellTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Terminal/ScreenBufferTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Terminal/TerminalTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Terminal/VirtualTerminalTest.php create mode 100644 src/Symfony/Component/Tui/Tests/TuiCascadingStylesheetTest.php create mode 100644 src/Symfony/Component/Tui/Tests/TuiTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Widget/BracketedPasteTraitTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Widget/CancellableLoaderWidgetTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Widget/ContainerTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Widget/Editor/EditorDocumentTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Widget/Editor/EditorRendererTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Widget/Editor/EditorViewportTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Widget/EditorTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Widget/Figlet/FigletFontTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Widget/Figlet/FigletRendererTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Widget/Figlet/FontRegistryTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Widget/FigletTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Widget/InputTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Widget/LoaderTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Widget/MarkdownTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Widget/ProgressBarTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Widget/SelectListTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Widget/SettingsListTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Widget/TextTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Widget/Util/KillRingTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Widget/Util/LineTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Widget/Util/StringUtilsTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Widget/Util/WordNavigatorTest.php create mode 100644 src/Symfony/Component/Tui/Tests/Widget/WidgetTreeTest.php create mode 100644 src/Symfony/Component/Tui/Tui.php create mode 100644 src/Symfony/Component/Tui/Widget/AbstractWidget.php create mode 100644 src/Symfony/Component/Tui/Widget/BracketedPasteTrait.php create mode 100644 src/Symfony/Component/Tui/Widget/CancellableLoaderWidget.php create mode 100644 src/Symfony/Component/Tui/Widget/ContainerInterface.php create mode 100644 src/Symfony/Component/Tui/Widget/ContainerWidget.php create mode 100644 src/Symfony/Component/Tui/Widget/DirtyWidgetTrait.php create mode 100644 src/Symfony/Component/Tui/Widget/Editor/EditorDocument.php create mode 100644 src/Symfony/Component/Tui/Widget/Editor/EditorRenderer.php create mode 100644 src/Symfony/Component/Tui/Widget/Editor/EditorViewport.php create mode 100644 src/Symfony/Component/Tui/Widget/EditorWidget.php create mode 100644 src/Symfony/Component/Tui/Widget/Figlet/FigletFont.php create mode 100644 src/Symfony/Component/Tui/Widget/Figlet/FigletRenderer.php create mode 100644 src/Symfony/Component/Tui/Widget/Figlet/FontRegistry.php create mode 100644 src/Symfony/Component/Tui/Widget/Figlet/fonts/big.flf create mode 100644 src/Symfony/Component/Tui/Widget/Figlet/fonts/mini.flf create mode 100644 src/Symfony/Component/Tui/Widget/Figlet/fonts/slant.flf create mode 100644 src/Symfony/Component/Tui/Widget/Figlet/fonts/small.flf create mode 100644 src/Symfony/Component/Tui/Widget/Figlet/fonts/standard.flf create mode 100644 src/Symfony/Component/Tui/Widget/FocusableInterface.php create mode 100644 src/Symfony/Component/Tui/Widget/FocusableTrait.php create mode 100644 src/Symfony/Component/Tui/Widget/InputWidget.php create mode 100644 src/Symfony/Component/Tui/Widget/KeybindingsTrait.php create mode 100644 src/Symfony/Component/Tui/Widget/LoaderWidget.php create mode 100644 src/Symfony/Component/Tui/Widget/Markdown/DarkTerminalTheme.php create mode 100644 src/Symfony/Component/Tui/Widget/MarkdownWidget.php create mode 100644 src/Symfony/Component/Tui/Widget/ParentInterface.php create mode 100644 src/Symfony/Component/Tui/Widget/ProgressBarWidget.php create mode 100644 src/Symfony/Component/Tui/Widget/QuitableTrait.php create mode 100644 src/Symfony/Component/Tui/Widget/ScheduledTickTrait.php create mode 100644 src/Symfony/Component/Tui/Widget/SelectListWidget.php create mode 100644 src/Symfony/Component/Tui/Widget/SettingItem.php create mode 100644 src/Symfony/Component/Tui/Widget/SettingsListWidget.php create mode 100644 src/Symfony/Component/Tui/Widget/TextWidget.php create mode 100644 src/Symfony/Component/Tui/Widget/Util/KillRing.php create mode 100644 src/Symfony/Component/Tui/Widget/Util/Line.php create mode 100644 src/Symfony/Component/Tui/Widget/Util/StringUtils.php create mode 100644 src/Symfony/Component/Tui/Widget/Util/WordNavigator.php create mode 100644 src/Symfony/Component/Tui/Widget/VerticallyExpandableInterface.php create mode 100644 src/Symfony/Component/Tui/Widget/WidgetContext.php create mode 100644 src/Symfony/Component/Tui/Widget/WidgetTree.php create mode 100644 src/Symfony/Component/Tui/composer.json create mode 100644 src/Symfony/Component/Tui/phpstan.neon.dist create mode 100644 src/Symfony/Component/Tui/phpunit.xml.dist diff --git a/composer.json b/composer.json index 9f4ad107c6410..2dc9ccc5e8ea2 100644 --- a/composer.json +++ b/composer.json @@ -111,6 +111,7 @@ "symfony/stopwatch": "self.version", "symfony/string": "self.version", "symfony/translation": "self.version", + "symfony/tui": "self.version", "symfony/twig-bridge": "self.version", "symfony/twig-bundle": "self.version", "symfony/type-info": "self.version", @@ -141,6 +142,7 @@ "guzzlehttp/guzzle": "^7.10", "jolicode/jolinotif": "^2.7.2|^3.0", "jsonpath-standard/jsonpath-compliance-test-suite": "*", + "league/commonmark": "^2.9", "nst/json-test-suite": "*", "league/html-to-markdown": "^5.0", "league/uri": "^6.5|^7.0", @@ -162,6 +164,7 @@ "symfony/runtime": "self.version", "symfony/security-acl": "^2.8|^3.0", "symfony/webpack-encore-bundle": "^1.0|^2.0", + "tempest/highlight": "^2.16", "twig/cssinliner-extra": "^3", "twig/inky-extra": "^3", "twig/markdown-extra": "^3", diff --git a/src/Symfony/Component/Tui/.gitattributes b/src/Symfony/Component/Tui/.gitattributes new file mode 100644 index 0000000000000..0e2985065a27f --- /dev/null +++ b/src/Symfony/Component/Tui/.gitattributes @@ -0,0 +1,5 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.github export-ignore diff --git a/src/Symfony/Component/Tui/.gitignore b/src/Symfony/Component/Tui/.gitignore new file mode 100644 index 0000000000000..bf84c7e7ad516 --- /dev/null +++ b/src/Symfony/Component/Tui/.gitignore @@ -0,0 +1,5 @@ +.php-cs-fixer.cache +.phpunit.result.cache +composer.lock +phpunit.xml +vendor/ diff --git a/src/Symfony/Component/Tui/Ansi/AnsiCodeTracker.php b/src/Symfony/Component/Tui/Ansi/AnsiCodeTracker.php new file mode 100644 index 0000000000000..490c115bf30c5 --- /dev/null +++ b/src/Symfony/Component/Tui/Ansi/AnsiCodeTracker.php @@ -0,0 +1,317 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Ansi; + +/** + * Tracks active ANSI SGR codes to preserve styling across line breaks. + * + * @experimental + * + * @author Fabien Potencier + */ +final class AnsiCodeTracker +{ + private bool $bold = false; + private bool $dim = false; + private bool $italic = false; + private bool $underline = false; + private bool $doubleUnderline = false; + private bool $blink = false; + private bool $inverse = false; + private bool $hidden = false; + private bool $strikethrough = false; + private ?string $fgColor = null; + private ?string $bgColor = null; + + /** + * Process an ANSI escape code and update tracking state. + */ + public function process(string $ansiCode): void + { + if (!str_ends_with($ansiCode, 'm')) { + return; + } + + // Fast direct parsing: skip regex, extract params between \x1b[ and m + $len = \strlen($ansiCode); + if ($len < 3 || "\x1b" !== $ansiCode[0] || '[' !== $ansiCode[1]) { + return; + } + + $params = substr($ansiCode, 2, $len - 3); + if ('' === $params || '0' === $params) { + $this->reset(); + + return; + } + + $parts = explode(';', $params); + $i = 0; + $count = \count($parts); + + while ($i < $count) { + $code = (int) $parts[$i]; + + // Handle 256-color and RGB codes which consume multiple parameters + if (38 === $code || 48 === $code) { + if (isset($parts[$i + 1]) && '5' === $parts[$i + 1]) { + if (isset($parts[$i + 2])) { + // 256 color: 38;5;N or 48;5;N + $colorCode = $parts[$i].';'.$parts[$i + 1].';'.$parts[$i + 2]; + if (38 === $code) { + $this->fgColor = $colorCode; + } else { + $this->bgColor = $colorCode; + } + $i += 3; + } else { + // Malformed: 38;5 or 48;5 without color number, skip both + $i += 2; + } + continue; + } elseif (isset($parts[$i + 1]) && '2' === $parts[$i + 1]) { + if (isset($parts[$i + 4])) { + // RGB color: 38;2;R;G;B or 48;2;R;G;B + $colorCode = $parts[$i].';'.$parts[$i + 1].';'.$parts[$i + 2].';'.$parts[$i + 3].';'.$parts[$i + 4]; + if (38 === $code) { + $this->fgColor = $colorCode; + } else { + $this->bgColor = $colorCode; + } + $i += 5; + } else { + // Malformed: 38;2 or 48;2 without enough RGB components, skip all remaining parts + $i = $count; + } + continue; + } + // 38/48 not followed by 5 or 2, ignore and move on + ++$i; + continue; + } + + // Standard SGR codes, including color ranges inline to avoid handleColorCode call + match ($code) { + 0 => $this->reset(), + 1 => $this->bold = true, + 2 => $this->dim = true, + 3 => $this->italic = true, + 4 => $this->underline = true, + 5 => $this->blink = true, + 7 => $this->inverse = true, + 8 => $this->hidden = true, + 9 => $this->strikethrough = true, + 21 => $this->doubleUnderline = true, + 22 => $this->bold = $this->dim = false, + 23 => $this->italic = false, + 24 => $this->underline = $this->doubleUnderline = false, + 25 => $this->blink = false, + 27 => $this->inverse = false, + 28 => $this->hidden = false, + 29 => $this->strikethrough = false, + 30, 31, 32, 33, 34, 35, 36, 37, 90, 91, 92, 93, 94, 95, 96, 97 => $this->fgColor = (string) $code, + 39 => $this->fgColor = null, + 40, 41, 42, 43, 44, 45, 46, 47, 100, 101, 102, 103, 104, 105, 106, 107 => $this->bgColor = (string) $code, + 49 => $this->bgColor = null, + default => null, + }; + + ++$i; + } + } + + /** + * Reset all tracking state. + */ + public function reset(): void + { + $this->bold = false; + $this->dim = false; + $this->italic = false; + $this->underline = false; + $this->doubleUnderline = false; + $this->blink = false; + $this->inverse = false; + $this->hidden = false; + $this->strikethrough = false; + $this->fgColor = null; + $this->bgColor = null; + } + + /** + * Get ANSI escape sequence to restore current active codes. + */ + public function getActiveCodes(): string + { + $codes = []; + + if ($this->bold) { + $codes[] = '1'; + } + if ($this->dim) { + $codes[] = '2'; + } + if ($this->italic) { + $codes[] = '3'; + } + if ($this->underline) { + $codes[] = '4'; + } + if ($this->doubleUnderline) { + $codes[] = '21'; + } + if ($this->blink) { + $codes[] = '5'; + } + if ($this->inverse) { + $codes[] = '7'; + } + if ($this->hidden) { + $codes[] = '8'; + } + if ($this->strikethrough) { + $codes[] = '9'; + } + if (null !== $this->fgColor) { + $codes[] = $this->fgColor; + } + if (null !== $this->bgColor) { + $codes[] = $this->bgColor; + } + + if ([] === $codes) { + return ''; + } + + return "\x1b[".implode(';', $codes).'m'; + } + + /** + * Check if any codes are currently active. + */ + public function hasActiveCodes(): bool + { + return $this->bold + || $this->dim + || $this->italic + || $this->underline + || $this->doubleUnderline + || $this->blink + || $this->inverse + || $this->hidden + || $this->strikethrough + || null !== $this->fgColor + || null !== $this->bgColor; + } + + /** + * Get reset codes for attributes that need to be turned off at line end. + * Specifically underline which bleeds into padding. + */ + public function getLineEndReset(): string + { + if ($this->underline || $this->doubleUnderline) { + return "\x1b[24m"; + } + + return ''; + } + + /** + * Update tracker state from all ANSI codes in a text string. + */ + public function processText(string $text): void + { + // Fast path: no escape sequences at all + if (!str_contains($text, "\x1b")) { + return; + } + + // Use preg_match_all to find all SGR sequences at once (C-level scan) + if (preg_match_all('/\x1b\[([\d;]*)m/', $text, $matches)) { + foreach ($matches[1] as $params) { + if ('' === $params || '0' === $params) { + $this->reset(); + continue; + } + + $parts = explode(';', $params); + $pi = 0; + $pc = \count($parts); + + while ($pi < $pc) { + $code = (int) $parts[$pi]; + + if (38 === $code || 48 === $code) { + if (isset($parts[$pi + 1]) && '5' === $parts[$pi + 1]) { + if (isset($parts[$pi + 2])) { + $colorCode = $parts[$pi].';'.$parts[$pi + 1].';'.$parts[$pi + 2]; + if (38 === $code) { + $this->fgColor = $colorCode; + } else { + $this->bgColor = $colorCode; + } + $pi += 3; + } else { + $pi += 2; + } + continue; + } + if (isset($parts[$pi + 1]) && '2' === $parts[$pi + 1]) { + if (isset($parts[$pi + 4])) { + $colorCode = $parts[$pi].';'.$parts[$pi + 1].';'.$parts[$pi + 2].';'.$parts[$pi + 3].';'.$parts[$pi + 4]; + if (38 === $code) { + $this->fgColor = $colorCode; + } else { + $this->bgColor = $colorCode; + } + $pi += 5; + } else { + $pi = $pc; + } + continue; + } + ++$pi; + continue; + } + + match ($code) { + 0 => $this->reset(), + 1 => $this->bold = true, + 2 => $this->dim = true, + 3 => $this->italic = true, + 4 => $this->underline = true, + 5 => $this->blink = true, + 7 => $this->inverse = true, + 8 => $this->hidden = true, + 9 => $this->strikethrough = true, + 21 => $this->doubleUnderline = true, + 22 => $this->bold = $this->dim = false, + 23 => $this->italic = false, + 24 => $this->underline = $this->doubleUnderline = false, + 25 => $this->blink = false, + 27 => $this->inverse = false, + 28 => $this->hidden = false, + 29 => $this->strikethrough = false, + 30, 31, 32, 33, 34, 35, 36, 37, 90, 91, 92, 93, 94, 95, 96, 97 => $this->fgColor = (string) $code, + 39 => $this->fgColor = null, + 40, 41, 42, 43, 44, 45, 46, 47, 100, 101, 102, 103, 104, 105, 106, 107 => $this->bgColor = (string) $code, + 49 => $this->bgColor = null, + default => null, + }; + + ++$pi; + } + } + } + } +} diff --git a/src/Symfony/Component/Tui/Ansi/AnsiUtils.php b/src/Symfony/Component/Tui/Ansi/AnsiUtils.php new file mode 100644 index 0000000000000..c15d2b11d592e --- /dev/null +++ b/src/Symfony/Component/Tui/Ansi/AnsiUtils.php @@ -0,0 +1,629 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Ansi; + +use Symfony\Component\String\UnicodeString; +use Symfony\Component\Tui\Style\CursorShape; + +/** + * ANSI escape code utilities for terminal rendering. + * + * @experimental + * + * @author Fabien Potencier + */ +final class AnsiUtils +{ + /** + * Cursor position marker prefix. + * + * Widgets emit an APC marker at the cursor position when focused. + * The full marker format is `ESC _ pi:c ; N BEL` where N is the + * DECSCUSR parameter (cursor shape). The ScreenWriter finds this + * marker, extracts the shape, positions the hardware cursor, and + * sets the cursor style via `ESC [ N SP q`. + * + * @see cursorMarker() + */ + public const CURSOR_MARKER_PREFIX = "\x1b_pi:c;"; + + /** + * Full SGR reset and OSC 8 reset sequence. + */ + public const SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07"; + + /** + * Combined pattern matching all ECMA-48 escape sequences in a single regex. + * Alternation order: CSI (most common), string sequences, nF, two-byte. + */ + private const ALL_ESC_PATTERN = '/\x1b(?:\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x7E]|[P\]_\^X][^\x07\x1b]*(?:\x07|\x1b\\\\)|[\x20-\x2F]+[\x30-\x7E]|[\x30-\x7E])/'; + + /** + * Character set for CSI parameter bytes (0x30-0x3F). + */ + private const CSI_PARAM_CHARS = '0123456789:;<=>?'; + + /** + * Character set for CSI intermediate bytes (0x20-0x2F). + */ + private const CSI_INTERMEDIATE_CHARS = " !\"#\$%&'()*+,-./"; + + /** + * Create a cursor marker embedding the given shape. + * + * The returned APC sequence is zero-width. The ScreenWriter strips + * it, positions the hardware cursor, and sets the cursor style. + */ + public static function cursorMarker(CursorShape $shape = CursorShape::Block): string + { + return self::CURSOR_MARKER_PREFIX.$shape->value."\x07"; + } + + /** + * Calculate the visible width of a string in terminal columns. + * ANSI escape codes are stripped before calculating width. + */ + public static function visibleWidth(string $str): int + { + if ('' === $str) { + return 0; + } + + $len = \strlen($str); + + // Ultra-fast path: pure printable ASCII (0x20-0x7E) with no ESC, no tabs, no non-ASCII + if (!str_contains($str, "\x1b") && !str_contains($str, "\t") && 1 === preg_match('/^[\x20-\x7E]*$/', $str)) { + return $len; + } + + // Fast path for ASCII + ANSI: jump between ESC sequences using strpos + // instead of scanning byte-by-byte with ord() + $fastWidth = 0; + $fastPath = true; + $i = 0; + + while ($i < $len) { + // Find next ESC byte + $escPos = strpos($str, "\x1b", $i); + $segEnd = false === $escPos ? $len : $escPos; + + // Process the text segment before the ESC (or end of string) + if ($segEnd > $i) { + $segLen = $segEnd - $i; + $segment = substr($str, $i, $segLen); + if (1 === preg_match('/^[\x20-\x7E]*$/', $segment)) { + // Pure printable ASCII (no tabs, no non-ASCII) + $fastWidth += $segLen; + } elseif (str_contains($segment, "\t")) { + // Has tabs + $tabCount = substr_count($segment, "\t"); + $fastWidth += $segLen - $tabCount + ($tabCount * 3); + $withoutTabs = str_replace("\t", '', $segment); + if ('' !== $withoutTabs && 1 !== preg_match('/^[\x20-\x7E]*$/', $withoutTabs)) { + $fastPath = false; + break; + } + } else { + $fastPath = false; + break; + } + } + + if (false === $escPos) { + break; + } + + // Skip the ANSI escape sequence, inline CSI fast path + if ($escPos + 1 < $len && '[' === $str[$escPos + 1]) { + $j = $escPos + 2 + strspn($str, self::CSI_PARAM_CHARS, $escPos + 2); + if ($j < $len && \ord($str[$j]) >= 0x40 && \ord($str[$j]) <= 0x7E) { + $i = $j + 1; + continue; + } + } + $ansi = self::extractAnsiCode($str, $escPos); + if (null === $ansi) { + $fastPath = false; + break; + } + $i = $escPos + $ansi['length']; + } + + if ($fastPath) { + return $fastWidth; + } + + $clean = $str; + + if (str_contains($clean, "\t")) { + $clean = str_replace("\t", ' ', $clean); + } + + if (str_contains($clean, "\x1b")) { + $clean = preg_replace(self::ALL_ESC_PATTERN, '', $clean) ?? $clean; + } + + if ('' === $clean) { + return 0; + } + + if (false === preg_match('//u', $clean)) { + $clean = @iconv('UTF-8', 'UTF-8//IGNORE', $clean) ?: ''; + } + + if ('' === $clean) { + return 0; + } + + return mb_strwidth($clean, 'UTF-8'); + } + + /** + * Strip all ANSI escape codes from a string. + */ + public static function stripAnsiCodes(string $str): string + { + if (!str_contains($str, "\x1b")) { + return $str; + } + + // Strip all ECMA-48 escape sequences using a single combined regex + return preg_replace(self::ALL_ESC_PATTERN, '', $str) ?? $str; + } + + /** + * Extract ANSI escape sequence at the given position. + * + * Handles all ECMA-48 sequence types: + * - CSI: ESC [ params intermediates final + * - String sequences: OSC (ESC ]), DCS (ESC P), APC (ESC _), PM (ESC ^), SOS (ESC X) + * - nF announced: ESC intermediates(0x20-0x2F)+ final(0x30-0x7E) + * - Fe/Fp/Fs two-byte: ESC + byte in 0x30-0x7E + * + * @return array{code: string, length: int}|null + */ + public static function extractAnsiCode(string $str, int $pos): ?array + { + $len = \strlen($str); + if ($pos >= $len || "\x1b" !== $str[$pos]) { + return null; + } + + if ($pos + 1 >= $len) { + return null; + } + + $next = $str[$pos + 1]; + + // CSI sequence: ESC [ * * + if ('[' === $next) { + // Use strspn for C-level scanning of parameter bytes + $j = $pos + 2 + strspn($str, self::CSI_PARAM_CHARS, $pos + 2); + // Check final byte, skip intermediate scan if already in final range (common case) + if ($j < $len && \ord($str[$j]) >= 0x40 && \ord($str[$j]) <= 0x7E) { + return ['code' => substr($str, $pos, $j + 1 - $pos), 'length' => $j + 1 - $pos]; + } + // Rare: scan intermediate bytes (0x20-0x2F) then check final byte + if ($j < $len && \ord($str[$j]) >= 0x20 && \ord($str[$j]) <= 0x2F) { + $j += strspn($str, self::CSI_INTERMEDIATE_CHARS, $j); + if ($j < $len && \ord($str[$j]) >= 0x40 && \ord($str[$j]) <= 0x7E) { + return ['code' => substr($str, $pos, $j + 1 - $pos), 'length' => $j + 1 - $pos]; + } + } + + return null; + } + + // String sequences: OSC (ESC ]), DCS (ESC P), APC (ESC _), PM (ESC ^), SOS (ESC X) + // All terminated by BEL (0x07) or ST (ESC \) + if (']' === $next || 'P' === $next || '_' === $next || '^' === $next || 'X' === $next) { + $j = $pos + 2; + while ($j < $len) { + // Skip ahead to next BEL or ESC using strcspn (C-level scan) + $j += strcspn($str, "\x07\x1b", $j); + if ($j >= $len) { + break; + } + if ("\x07" === $str[$j]) { + return ['code' => substr($str, $pos, $j + 1 - $pos), 'length' => $j + 1 - $pos]; + } + if ("\x1b" === $str[$j] && isset($str[$j + 1]) && '\\' === $str[$j + 1]) { + return ['code' => substr($str, $pos, $j + 2 - $pos), 'length' => $j + 2 - $pos]; + } + ++$j; + } + + return null; + } + + $nextOrd = \ord($next); + + // nF announced sequences: ESC + intermediate bytes (0x20-0x2F)+ + final byte (0x30-0x7E) + // e.g., ESC ( B = G0 charset designation + if ($nextOrd >= 0x20 && $nextOrd <= 0x2F) { + $j = $pos + 2 + strspn($str, self::CSI_INTERMEDIATE_CHARS, $pos + 2); + // Final byte must be in 0x30-0x7E + if ($j < $len && \ord($str[$j]) >= 0x30 && \ord($str[$j]) <= 0x7E) { + return ['code' => substr($str, $pos, $j + 1 - $pos), 'length' => $j + 1 - $pos]; + } + + return null; + } + + // Fe (0x40-0x5F), Fp (0x30-0x3F), Fs (0x60-0x7E) two-byte sequences + // e.g., ESC D = IND, ESC M = RI, ESC 7 = DECSC, ESC 8 = DECRC, ESC c = RIS + if ($nextOrd >= 0x30 && $nextOrd <= 0x7E) { + return ['code' => substr($str, $pos, 2), 'length' => 2]; + } + + return null; + } + + /** + * Truncate text to fit within a maximum visible width, adding ellipsis if needed. + * + * @param string $text Text to truncate (may contain ANSI codes) + * @param int $maxWidth Maximum visible width + * @param string $ellipsis Ellipsis string to append when truncating + * @param bool $pad If true, pad result with spaces to exactly maxWidth + */ + public static function truncateToWidth(string $text, int $maxWidth, string $ellipsis = '...', bool $pad = false): string + { + $textVisibleWidth = self::visibleWidth($text); + + if ($textVisibleWidth <= $maxWidth) { + return $pad ? $text.str_repeat(' ', $maxWidth - $textVisibleWidth) : $text; + } + + // Fast path: pure ASCII ellipsis width = strlen (avoids visibleWidth overhead) + $ellipsisWidth = '' !== $ellipsis && !str_contains($ellipsis, "\x1b") && 1 === preg_match('/^[\x20-\x7E]*$/', $ellipsis) + ? \strlen($ellipsis) + : self::visibleWidth($ellipsis); + $targetWidth = $maxWidth - $ellipsisWidth; + + if ($targetWidth <= 0) { + return substr($ellipsis, 0, $maxWidth); + } + + // Fast path: pure printable ASCII, direct substr avoids sliceByColumn overhead + if ($textVisibleWidth === \strlen($text) && 1 === preg_match('/^[\x20-\x7E]*$/', $text)) { + $truncated = substr($text, 0, $targetWidth).$ellipsis; + + if ($pad) { + return $truncated.str_repeat(' ', max(0, $maxWidth - $targetWidth - $ellipsisWidth)); + } + + return $truncated; + } + + $result = self::sliceByColumn($text, 0, $targetWidth); + + // Add reset code before ellipsis to prevent styling leaking into it + $truncated = $result."\x1b[0m".$ellipsis; + + if ($pad) { + $truncatedWidth = self::visibleWidth($truncated); + + return $truncated.str_repeat(' ', max(0, $maxWidth - $truncatedWidth)); + } + + return $truncated; + } + + /** + * Extract a range of visible columns from a line. + * Handles ANSI codes and wide characters. + * + * @param bool $strict If true, exclude wide chars at boundary that would extend past the range + */ + public static function sliceByColumn(string $line, int $startCol, int $length, bool $strict = false): string + { + // Optimized path for startCol=0 (prefix extraction), skip pendingAnsi tracking + if (0 === $startCol && !$strict) { + return self::slicePrefix($line, $length); + } + + return self::sliceWithWidth($line, $startCol, $length, $strict)['text']; + } + + /** + * Extract a range of visible columns from a line, also returning actual width. + * + * @return array{text: string, width: int} + */ + public static function sliceWithWidth(string $line, int $startCol, int $length, bool $strict = false): array + { + if ($length <= 0) { + return ['text' => '', 'width' => 0]; + } + + $endCol = $startCol + $length; + $result = ''; + $resultWidth = 0; + $currentCol = 0; + $i = 0; + $pendingAnsi = ''; + $lineLen = \strlen($line); + + while ($i < $lineLen) { + // Handle ANSI escape sequences + if ("\x1b" === $line[$i]) { + // Inline CSI fast path to avoid extractAnsiCode call overhead + if ($i + 1 < $lineLen && '[' === $line[$i + 1]) { + $j = $i + 2 + strspn($line, self::CSI_PARAM_CHARS, $i + 2); + if ($j < $lineLen && \ord($line[$j]) >= 0x40 && \ord($line[$j]) <= 0x7E) { + $code = substr($line, $i, $j + 1 - $i); + if ($currentCol >= $startCol && $currentCol < $endCol) { + $result .= $code; + } elseif ($currentCol < $startCol) { + $pendingAnsi .= $code; + } + $i = $j + 1; + continue; + } + } + $ansi = self::extractAnsiCode($line, $i); + if (null !== $ansi) { + if ($currentCol >= $startCol && $currentCol < $endCol) { + $result .= $ansi['code']; + } elseif ($currentCol < $startCol) { + $pendingAnsi .= $ansi['code']; + } + $i += $ansi['length']; + continue; + } + } + + // Find the next ESC byte or end of string + $textEnd = strpos($line, "\x1b", $i + 1); + if (false === $textEnd) { + $textEnd = $lineLen; + } + + // Process text segment between ANSI codes + // Fast path: check if segment is pure printable ASCII (0x20-0x7E) + $segLen = $textEnd - $i; + $segment = substr($line, $i, $segLen); + + if ('' === $segment || 1 === preg_match('/^[\x20-\x7E]*$/', $segment)) { + // ASCII fast path: each byte is exactly 1 column wide + // Use substr for bulk extraction when possible + $segEndCol = $currentCol + $segLen; + + if ($segEndCol <= $startCol) { + // Entire segment is before range, skip it + $currentCol = $segEndCol; + } elseif ($currentCol >= $startCol && $segEndCol <= $endCol) { + // Entire segment is within range, take it all + if ('' !== $pendingAnsi) { + $result .= $pendingAnsi; + $pendingAnsi = ''; + } + $result .= $segment; + $resultWidth += $segLen; + $currentCol = $segEndCol; + } else { + // Segment partially overlaps, extract the overlap + $skipChars = (int) max(0, $startCol - $currentCol); + $takeChars = (int) min($segLen - $skipChars, $endCol - max($currentCol, $startCol)); + + if ($takeChars > 0) { + if ('' !== $pendingAnsi) { + $result .= $pendingAnsi; + $pendingAnsi = ''; + } + $result .= substr($segment, $skipChars, $takeChars); + $resultWidth += $takeChars; + } + $currentCol = $segEndCol; + } + } else { + // Unicode path + $textPortion = substr($line, $i, $segLen); + + // Fast check: if the entire segment fits within range, use mb_strwidth + // to skip expensive grapheme_str_split + per-grapheme iteration. + // mb_strwidth may overcount for ZWJ sequences; conservative check. + $segWidth = mb_strwidth($textPortion, 'UTF-8'); + if ($currentCol >= $startCol && $currentCol + $segWidth <= $endCol) { + if ('' !== $pendingAnsi) { + $result .= $pendingAnsi; + $pendingAnsi = ''; + } + $result .= $textPortion; + $resultWidth += $segWidth; + $currentCol += $segWidth; + } else { + // Per-grapheme path for boundary-spanning segments + $graphemes = grapheme_str_split($textPortion) ?: []; + + foreach ($graphemes as $grapheme) { + $w = self::graphemeWidth($grapheme); + $inRange = $currentCol >= $startCol && $currentCol < $endCol; + $fits = !$strict || ($currentCol + $w <= $endCol); + + if ($inRange && $fits) { + if ('' !== $pendingAnsi) { + $result .= $pendingAnsi; + $pendingAnsi = ''; + } + $result .= $grapheme; + $resultWidth += $w; + } + $currentCol += $w; + + if ($currentCol >= $endCol) { + break; + } + } + } + } + + $i = $textEnd; + + if ($currentCol >= $endCol) { + break; + } + } + + /* @var int $resultWidth */ + return ['text' => $result, 'width' => $resultWidth]; + } + + /** + * Calculate the display width of a single grapheme in terminal columns. + * + * Uses mb_strwidth() for single-codepoint graphemes (fast C-level call), + * falling back to UnicodeString::width() for multi-codepoint graphemes + * (ZWJ emoji sequences, skin tone modifiers, decomposed combining chars) + * where mb_strwidth() overcounts by summing component widths. + */ + public static function graphemeWidth(string $grapheme): int + { + if (1 === mb_strlen($grapheme, 'UTF-8')) { + return mb_strwidth($grapheme, 'UTF-8'); + } + + return new UnicodeString($grapheme)->width(false); + } + + /** + * Check if a character is whitespace. + */ + public static function isWhitespace(string $char): bool + { + return 1 === preg_match('/\s/', $char); + } + + /** + * Check if a character is punctuation. + */ + public static function isPunctuation(string $char): bool + { + return 1 === preg_match('/[(){}[\]<>.,;:\'"!?+\-=*\/\\\\|&%^$#@~`]/', $char); + } + + /** + * Reapply a background SGR code after reset sequences. + */ + public static function reapplyBackgroundAfterResets(string $text, string $backgroundCode): string + { + // Fast path: no escape sequences at all + if (!str_contains($text, "\x1b")) { + return $text; + } + + return preg_replace_callback('/\x1b\[([\d;]*)m/', static function (array $m) use ($backgroundCode): string { + $params = $m[1]; + + // Fast path: common reset sequences + if ('' === $params || '0' === $params) { + return $m[0].$backgroundCode; + } + + // Check for '49' (background reset) or '0' in compound sequences + if (str_contains($params, '49') || str_contains($params, '0')) { + $parts = explode(';', $params); + if (\in_array('0', $parts, true) || \in_array('49', $parts, true)) { + return $m[0].$backgroundCode; + } + } + + return $m[0]; + }, $text) ?? $text; + } + + /** + * Check if a line contains image escape sequences. + */ + public static function containsImage(string $line): bool + { + return str_contains($line, "\x1b_G") || str_contains($line, "\x1b]1337;File="); + } + + /** + * Extract a prefix of visible columns from a line (startCol=0 specialization). + * Skips pendingAnsi tracking since all ANSI codes are in range from the start. + */ + private static function slicePrefix(string $line, int $length): string + { + if ($length <= 0) { + return ''; + } + + $result = ''; + $currentCol = 0; + $i = 0; + $lineLen = \strlen($line); + + while ($i < $lineLen && $currentCol < $length) { + if ("\x1b" === $line[$i]) { + // Inline CSI fast path + if ($i + 1 < $lineLen && '[' === $line[$i + 1]) { + $j = $i + 2 + strspn($line, self::CSI_PARAM_CHARS, $i + 2); + if ($j < $lineLen && \ord($line[$j]) >= 0x40 && \ord($line[$j]) <= 0x7E) { + $result .= substr($line, $i, $j + 1 - $i); + $i = $j + 1; + continue; + } + } + $ansi = self::extractAnsiCode($line, $i); + if (null !== $ansi) { + $result .= $ansi['code']; + $i += $ansi['length']; + continue; + } + } + + // Find next ESC or end of string + $textEnd = strpos($line, "\x1b", $i + 1); + if (false === $textEnd) { + $textEnd = $lineLen; + } + + $segLen = $textEnd - $i; + $segment = substr($line, $i, $segLen); + + if ('' === $segment || 1 === preg_match('/^[\x20-\x7E]*$/', $segment)) { + // ASCII: take up to remaining columns + $take = min($segLen, $length - $currentCol); + if ($take === $segLen) { + $result .= $segment; + } else { + $result .= substr($segment, 0, $take); + } + $currentCol += $take; + } else { + // Unicode path + $segWidth = mb_strwidth($segment, 'UTF-8'); + if ($currentCol + $segWidth <= $length) { + $result .= $segment; + $currentCol += $segWidth; + } else { + $graphemes = grapheme_str_split($segment) ?: []; + foreach ($graphemes as $grapheme) { + $w = self::graphemeWidth($grapheme); + if ($currentCol + $w > $length) { + break; + } + $result .= $grapheme; + $currentCol += $w; + } + } + } + + $i = $textEnd; + } + + return $result; + } +} diff --git a/src/Symfony/Component/Tui/Ansi/ScreenBufferHtmlRenderer.php b/src/Symfony/Component/Tui/Ansi/ScreenBufferHtmlRenderer.php new file mode 100644 index 0000000000000..d428ce400ad0d --- /dev/null +++ b/src/Symfony/Component/Tui/Ansi/ScreenBufferHtmlRenderer.php @@ -0,0 +1,261 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Ansi; + +use Symfony\Component\Tui\Style\Color; +use Symfony\Component\Tui\Terminal\ScreenBuffer; + +/** + * Renders a ScreenBuffer to HTML with inline CSS styles. + * + * @experimental + * + * @author Fabien Potencier + */ +final class ScreenBufferHtmlRenderer +{ + private Color $defaultForeground; + private Color $defaultBackground; + + public function __construct( + ?Color $defaultForeground = null, + ?Color $defaultBackground = null, + ) { + $this->defaultForeground = $defaultForeground ?? Color::hex('#d4d4d4'); + $this->defaultBackground = $defaultBackground ?? Color::hex('#1e1e1e'); + } + + /** + * Convert a ScreenBuffer to HTML with inline styles. + */ + public function convert(ScreenBuffer $screen): string + { + $cells = $screen->getCells(); + $height = $screen->getHeight(); + $result = []; + $lastNonEmpty = -1; + + for ($row = 0; $row < $height; ++$row) { + $line = $this->convertLine($cells[$row] ?? []); + $textOnly = $this->getLineText($cells[$row] ?? []); + if ('' !== rtrim($textOnly)) { + $lastNonEmpty = $row; + } + $result[] = $line; + } + + // Only include lines up to the last non-empty line + if ($lastNonEmpty >= 0) { + $result = array_slice($result, 0, $lastNonEmpty + 1); + } else { + $result = []; + } + + return implode("\n", $result); + } + + /** + * Convert a single line of cells to HTML. + * + * @param array $cells + */ + private function convertLine(array $cells): string + { + if ([] === $cells) { + return ''; + } + + $maxCol = max(array_keys($cells)); + + $html = ''; + $lastStyle = ''; + $inSpan = false; + + for ($col = 0; $col <= $maxCol; ++$col) { + $cell = $cells[$col] ?? ['char' => ' ', 'style' => '']; + $char = $cell['char']; + + // Skip wide character continuation cells (empty string placeholders) + if ('' === $char) { + continue; + } + + $style = $cell['style']; + + if ($style !== $lastStyle) { + if ($inSpan) { + $html .= ''; + $inSpan = false; + } + if ('' !== $style) { + $css = $this->ansiToCss($style); + if ('' !== $css) { + $html .= ''; + $inSpan = true; + } + } + $lastStyle = $style; + } + + $html .= htmlspecialchars($char, ENT_QUOTES | ENT_HTML5); + } + + if ($inSpan) { + $html .= ''; + } + + return $html; + } + + /** + * Get plain text from a line of cells. + * + * @param array $cells + */ + private function getLineText(array $cells): string + { + if ([] === $cells) { + return ''; + } + + $line = ''; + $maxCol = max(array_keys($cells)); + + for ($col = 0; $col <= $maxCol; ++$col) { + $char = $cells[$col]['char'] ?? ' '; + // Skip wide character continuation cells (empty string placeholders) + if ('' === $char) { + continue; + } + $line .= $char; + } + + return $line; + } + + /** + * Convert ANSI escape sequence to CSS style string. + */ + private function ansiToCss(string $ansi): string + { + // Parse SGR parameters from the style string + // Style is stored as the full escape sequence, e.g., "\x1b[1;32m" + if (!preg_match('/\x1b\[([0-9;]*)m/', $ansi, $matches)) { + return ''; + } + + $params = '' !== $matches[1] ? array_map('intval', explode(';', $matches[1])) : [0]; + $css = []; + + $i = 0; + $paramCount = \count($params); + while ($i < $paramCount) { + $code = $params[$i]; + + switch ($code) { + case 0: // Reset + $css = []; + break; + case 1: // Bold + $css['font-weight'] = 'bold'; + break; + case 2: // Dim + $css['opacity'] = '0.7'; + break; + case 3: // Italic + $css['font-style'] = 'italic'; + break; + case 4: // Underline + $css['--underline'] = true; + break; + case 7: // Reverse video - mark for fg/bg swap + $css['--reverse'] = true; + break; + case 27: // Reverse off + unset($css['--reverse']); + break; + case 9: // Strikethrough + $css['--strikethrough'] = true; + break; + + default: + // Foreground colors (30-37, 90-97) + if ($color = Color::fromSgrForeground($code)) { + $css['color'] = $color->toHex(); + break; + } + + // Background colors (40-47, 100-107) + if ($color = Color::fromSgrBackground($code)) { + $css['background-color'] = $color->toHex(); + break; + } + + // 256-color mode (38;5;N / 48;5;N) and RGB truecolor (38;2;R;G;B / 48;2;R;G;B) + if (38 === $code || 48 === $code || 58 === $code) { + $cssProp = match ($code) { + 38 => 'color', + 48 => 'background-color', + 58 => 'text-decoration-color', + }; + if (isset($params[$i + 1]) && 5 === $params[$i + 1] && isset($params[$i + 2])) { + $css[$cssProp] = Color::palette($params[$i + 2])->toHex(); + $i += 2; + } elseif (isset($params[$i + 1]) && 2 === $params[$i + 1] && isset($params[$i + 4])) { + $css[$cssProp] = Color::rgb($params[$i + 2], $params[$i + 3], $params[$i + 4])->toHex(); + $i += 4; + } + break; + } + + // Default underline color + if (59 === $code) { + unset($css['text-decoration-color']); + } + + break; + } + + ++$i; + } + + // Combine text-decoration from underline and strikethrough markers + $decorations = []; + if (isset($css['--underline'])) { + $decorations[] = 'underline'; + unset($css['--underline']); + } + if (isset($css['--strikethrough'])) { + $decorations[] = 'line-through'; + unset($css['--strikethrough']); + } + if ($decorations) { + $css['text-decoration'] = implode(' ', $decorations); + } + + // Handle reverse video: swap foreground and background colors + if (isset($css['--reverse'])) { + unset($css['--reverse']); + $fg = $css['color'] ?? $this->defaultForeground->toHex(); + $bg = $css['background-color'] ?? $this->defaultBackground->toHex(); + $css['color'] = $bg; + $css['background-color'] = $fg; + } + + $cssStr = ''; + foreach ($css as $prop => $value) { + $cssStr .= $prop.': '.$value.'; '; + } + + return rtrim($cssStr); + } +} diff --git a/src/Symfony/Component/Tui/Ansi/TextWrapper.php b/src/Symfony/Component/Tui/Ansi/TextWrapper.php new file mode 100644 index 0000000000000..f4c82beb158d2 --- /dev/null +++ b/src/Symfony/Component/Tui/Ansi/TextWrapper.php @@ -0,0 +1,461 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Ansi; + +/** + * Text wrapping with ANSI code preservation. + * + * @experimental + * + * @author Fabien Potencier + */ +final class TextWrapper +{ + /** + * Wrap a single line into chunks with position tracking. + * + * Unlike wrapTextWithAnsi(), each chunk carries its start/end position + * in the original line, allowing callers to map cursor positions + * accurately through word-wrap boundaries. + * + * The chunk text may include trailing whitespace; callers that need + * trimmed display text can rtrim() themselves. + * + * @param string $line A single line of text (no newlines) + * @param int $width Maximum visible width per chunk + * + * @return list + */ + public static function wrapLineIntoChunks(string $line, int $width): array + { + if ('' === $line) { + return [['text' => '', 'startIndex' => 0, 'endIndex' => 0]]; + } + + if ($width <= 0) { + return [['text' => $line, 'startIndex' => 0, 'endIndex' => \strlen($line)]]; + } + + $lineWidth = AnsiUtils::visibleWidth($line); + if ($lineWidth <= $width) { + return [['text' => $line, 'startIndex' => 0, 'endIndex' => \strlen($line)]]; + } + + $chunks = []; + $graphemes = grapheme_str_split($line); + if (false === $graphemes) { + return [['text' => $line, 'startIndex' => 0, 'endIndex' => \strlen($line)]]; + } + + $currentWidth = 0; + $chunkStart = 0; + + // Wrap opportunity: the byte position after the last whitespace + // before a non-whitespace grapheme (where a line break is allowed). + $wrapOppIndex = -1; + $wrapOppWidth = 0; + + $byteOffset = 0; + $count = \count($graphemes); + + for ($i = 0; $i < $count; ++$i) { + $grapheme = $graphemes[$i]; + $graphemeBytes = \strlen($grapheme); + $gWidth = AnsiUtils::visibleWidth($grapheme); + $isWs = ' ' === $grapheme || "\t" === $grapheme; + + // Overflow: current grapheme would exceed the width limit. + if ($currentWidth + $gWidth > $width) { + if ($wrapOppIndex >= 0) { + // Backtrack to last wrap opportunity (word boundary). + $chunks[] = [ + 'text' => substr($line, $chunkStart, $wrapOppIndex - $chunkStart), + 'startIndex' => $chunkStart, + 'endIndex' => $wrapOppIndex, + ]; + $chunkStart = $wrapOppIndex; + $currentWidth -= $wrapOppWidth; + } elseif ($chunkStart < $byteOffset) { + // No word boundary available: force-break at current position. + $chunks[] = [ + 'text' => substr($line, $chunkStart, $byteOffset - $chunkStart), + 'startIndex' => $chunkStart, + 'endIndex' => $byteOffset, + ]; + $chunkStart = $byteOffset; + $currentWidth = 0; + } + $wrapOppIndex = -1; + } + + // Advance past this grapheme. + $currentWidth += $gWidth; + $byteOffset += $graphemeBytes; + + // Record wrap opportunity: whitespace followed by non-whitespace. + // Multiple consecutive spaces group together; the break point is + // after the last space, at the start of the next word. + if ($isWs && $i + 1 < $count && ' ' !== $graphemes[$i + 1] && "\t" !== $graphemes[$i + 1]) { + $wrapOppIndex = $byteOffset; // byte position of the next grapheme + $wrapOppWidth = $currentWidth; + } + } + + // Push the final chunk. + $chunks[] = [ + 'text' => substr($line, $chunkStart), + 'startIndex' => $chunkStart, + 'endIndex' => \strlen($line), + ]; + + return $chunks; + } + + /** + * Wrap text with ANSI codes preserved. + * + * Only does word wrapping - no padding, no background colors. + * Returns lines where each line is <= width visible chars. + * Active ANSI codes are preserved across line breaks. + * + * @param string $text Text to wrap (may contain ANSI codes and newlines) + * @param int $width Maximum visible width per line + * + * @return string[] Array of wrapped lines (not padded to width) + */ + public static function wrapTextWithAnsi(string $text, int $width): array + { + if ('' === $text) { + return ['']; + } + + // Guard against invalid width - return text as-is split by newlines + if ($width <= 0) { + return explode("\n", $text); + } + + // Fast path: single line (no newlines), skip explode/tracker overhead + if (!str_contains($text, "\n")) { + return self::wrapSingleLine($text, $width); + } + + // Handle newlines by processing each line separately + // Track ANSI state across lines so styles carry over after literal newlines + $inputLines = explode("\n", $text); + $result = []; + $tracker = new AnsiCodeTracker(); + + foreach ($inputLines as $inputLine) { + // Prepend active ANSI codes from previous lines (except for first line) + $prefix = [] !== $result ? $tracker->getActiveCodes() : ''; + $wrapped = self::wrapSingleLine($prefix.$inputLine, $width); + array_push($result, ...$wrapped); + + // Update tracker with codes from this line for next iteration + // (skip the scan entirely when no escape sequences are present). + if (str_contains($inputLine, "\x1b")) { + $tracker->processText($inputLine); + } + } + + return [] !== $result ? $result : ['']; + } + + /** + * Wrap a single line of text. + * + * @return string[] + */ + private static function wrapSingleLine(string $line, int $width): array + { + if ('' === $line) { + return ['']; + } + + $visibleLength = AnsiUtils::visibleWidth($line); + if ($visibleLength <= $width) { + return [$line]; + } + + $wrapped = []; + $tracker = new AnsiCodeTracker(); + $tokens = self::splitIntoTokensWithAnsi($line); + + $currentLine = ''; + $currentVisibleLength = 0; + + foreach ($tokens as $token) { + $tokenText = $token['text']; + $tokenVisibleLength = $token['width']; + $isWhitespace = $token['isWhitespace']; + + // Token itself is too long - break it character by character + if ($tokenVisibleLength > $width && !$isWhitespace) { + if ('' !== $currentLine) { + $lineEndReset = $tracker->getLineEndReset(); + if ('' !== $lineEndReset) { + $currentLine .= $lineEndReset; + } + $wrapped[] = $currentLine; + $currentLine = ''; + $currentVisibleLength = 0; + } + + // Break long token + $broken = self::breakLongWord($tokenText, $width, $tracker); + $brokenLines = $broken['lines']; + $lastIndex = \count($brokenLines) - 1; + for ($i = 0; $i < $lastIndex; ++$i) { + $wrapped[] = $brokenLines[$i]; + } + $currentLine = $brokenLines[$lastIndex] ?? ''; + $currentVisibleLength = $broken['lastWidth']; + continue; + } + + // Check if adding this token would exceed width + $totalNeeded = $currentVisibleLength + $tokenVisibleLength; + + if ($totalNeeded > $width && $currentVisibleLength > 0) { + // Trim trailing whitespace, then add underline reset + $lineToWrap = rtrim($currentLine); + $lineEndReset = $tracker->getLineEndReset(); + if ('' !== $lineEndReset) { + $lineToWrap .= $lineEndReset; + } + $wrapped[] = $lineToWrap; + + if ($isWhitespace) { + // Don't start new line with whitespace + $currentLine = $tracker->getActiveCodes(); + $currentVisibleLength = 0; + } else { + $currentLine = $tracker->getActiveCodes().$tokenText; + $currentVisibleLength = $tokenVisibleLength; + } + } else { + // Add to current line + $currentLine .= $tokenText; + $currentVisibleLength += $tokenVisibleLength; + } + + if ($token['hasAnsi']) { + $tracker->processText($tokenText); + } + } + + if ('' !== $currentLine) { + $wrapped[] = $currentLine; + } + + // Trailing whitespace can cause lines to exceed the requested width + return [] !== $wrapped ? array_map('rtrim', $wrapped) : ['']; + } + + /** + * Split text into tokens (words and whitespace runs) while keeping ANSI codes attached. + * + * @return array + */ + private static function splitIntoTokensWithAnsi(string $text): array + { + $tokens = []; + $current = ''; + $pendingAnsi = ''; + $inWhitespace = false; + $currentWidth = 0; + $needsUnicodeWidth = false; + $currentHasAnsi = false; + $i = 0; + $len = \strlen($text); + + while ($i < $len) { + $char = $text[$i]; + + // Only check for ANSI codes when we see an ESC byte + if ("\x1b" === $char) { + // Inline CSI fast path with strspn + if ($i + 1 < $len && '[' === $text[$i + 1]) { + $j = $i + 2 + strspn($text, '0123456789:;<=>?', $i + 2); + if ($j < $len && \ord($text[$j]) >= 0x40 && \ord($text[$j]) <= 0x7E) { + $pendingAnsi .= substr($text, $i, $j + 1 - $i); + $i = $j + 1; + continue; + } + } + $ansi = AnsiUtils::extractAnsiCode($text, $i); + if (null !== $ansi) { + $pendingAnsi .= $ansi['code']; + $i += $ansi['length']; + continue; + } + } + + $charIsSpace = ' ' === $char || "\t" === $char; + + if ($charIsSpace !== $inWhitespace && '' !== $current) { + // Switching between whitespace and non-whitespace, push current token + /* @var int $currentWidth */ + $tokens[] = [ + 'text' => $current, + 'width' => $needsUnicodeWidth ? AnsiUtils::visibleWidth($current) : $currentWidth, + 'isWhitespace' => $inWhitespace, + 'hasAnsi' => $currentHasAnsi, + ]; + $current = ''; + $currentWidth = 0; + $needsUnicodeWidth = false; + $currentHasAnsi = false; + } + + // Attach any pending ANSI codes to this visible character + if ('' !== $pendingAnsi) { + $current .= $pendingAnsi; + $pendingAnsi = ''; + $currentHasAnsi = true; + } + + $inWhitespace = $charIsSpace; + + // Bulk-consume consecutive printable ASCII non-space chars or consecutive spaces + if (!$needsUnicodeWidth && $char >= '!' && $char <= '~') { + // Non-whitespace printable ASCII: scan ahead for a run + $runStart = $i; + ++$i; + while ($i < $len && $text[$i] >= '!' && $text[$i] <= '~') { + ++$i; + } + $run = substr($text, $runStart, $i - $runStart); + $current .= $run; + $currentWidth += $i - $runStart; + continue; + } + + $current .= $char; + + if ("\t" === $char) { + $currentWidth += 3; + } elseif ($char >= ' ' && $char <= '~') { + ++$currentWidth; + } else { + $needsUnicodeWidth = true; + } + + ++$i; + } + + // Handle any remaining pending ANSI codes (attach to last token) + if ('' !== $pendingAnsi) { + $current .= $pendingAnsi; + $currentHasAnsi = true; + } + + if ('' !== $current) { + /* @var int $currentWidth */ + $tokens[] = [ + 'text' => $current, + 'width' => $needsUnicodeWidth ? AnsiUtils::visibleWidth($current) : $currentWidth, + 'isWhitespace' => $inWhitespace, + 'hasAnsi' => $currentHasAnsi, + ]; + } + + return $tokens; + } + + /** + * Break a long word into multiple lines. + * + * @return array{lines: string[], lastWidth: int} + */ + private static function breakLongWord(string $word, int $width, AnsiCodeTracker $tracker): array + { + $lines = []; + $currentLine = $tracker->getActiveCodes(); + $currentWidth = 0; + + $i = 0; + $wordLen = \strlen($word); + $segments = []; + + // First, separate ANSI codes from visible content + while ($i < $wordLen) { + $byte = $word[$i]; + + // Only check for ANSI when we see an ESC byte + if ("\x1b" === $byte) { + $ansi = AnsiUtils::extractAnsiCode($word, $i); + if (null !== $ansi) { + $segments[] = ['type' => 'ansi', 'value' => $ansi['code']]; + $i += $ansi['length']; + continue; + } + } + + // Find the next ESC byte or end of string for the text portion + $end = strpos($word, "\x1b", $i + 1); + if (false === $end) { + $end = $wordLen; + } + + // Segment this non-ANSI portion into graphemes + $textPortion = substr($word, $i, $end - $i); + $graphemes = grapheme_str_split($textPortion); + if (false !== $graphemes) { + foreach ($graphemes as $grapheme) { + $segments[] = ['type' => 'grapheme', 'value' => $grapheme]; + } + } + $i = $end; + } + + // Process segments + foreach ($segments as $seg) { + if ('ansi' === $seg['type']) { + $currentLine .= $seg['value']; + $tracker->process($seg['value']); + continue; + } + + $grapheme = $seg['value']; + if ('' === $grapheme) { + continue; + } + + $graphemeWidth = AnsiUtils::graphemeWidth($grapheme); + + if ($currentWidth + $graphemeWidth > $width) { + // Add specific reset for underline only (preserves background) + $lineEndReset = $tracker->getLineEndReset(); + if ('' !== $lineEndReset) { + $currentLine .= $lineEndReset; + } + $lines[] = $currentLine; + $currentLine = $tracker->getActiveCodes(); + $currentWidth = 0; + } + + $currentLine .= $grapheme; + $currentWidth += $graphemeWidth; + } + + if ('' !== $currentLine) { + $lines[] = $currentLine; + } + + if ([] === $lines) { + return ['lines' => [''], 'lastWidth' => 0]; + } + + return ['lines' => $lines, 'lastWidth' => $currentWidth]; + } +} diff --git a/src/Symfony/Component/Tui/CHANGELOG b/src/Symfony/Component/Tui/CHANGELOG new file mode 100644 index 0000000000000..56cfb06ad85ff --- /dev/null +++ b/src/Symfony/Component/Tui/CHANGELOG @@ -0,0 +1,7 @@ +CHANGELOG +========= + +8.1 +--- + + * Introduce the component as experimental diff --git a/src/Symfony/Component/Tui/Event/AbstractEvent.php b/src/Symfony/Component/Tui/Event/AbstractEvent.php new file mode 100644 index 0000000000000..82498186ab9e4 --- /dev/null +++ b/src/Symfony/Component/Tui/Event/AbstractEvent.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Event; + +use Symfony\Component\Tui\Widget\AbstractWidget; +use Symfony\Contracts\EventDispatcher\Event as BaseEvent; + +/** + * Base class for all TUI widget events. + * + * Extends Symfony's Event so it can be dispatched through + * Symfony's EventDispatcher. Carries the target widget that + * originated the event. + * + * @experimental + * + * @author Fabien Potencier + */ +abstract class AbstractEvent extends BaseEvent +{ + public function __construct( + private readonly AbstractWidget $target, + ) { + } + + public function getTarget(): AbstractWidget + { + return $this->target; + } +} diff --git a/src/Symfony/Component/Tui/Event/CancelEvent.php b/src/Symfony/Component/Tui/Event/CancelEvent.php new file mode 100644 index 0000000000000..a2f4d9fa3cd73 --- /dev/null +++ b/src/Symfony/Component/Tui/Event/CancelEvent.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Event; + +/** + * Event dispatched when a widget action is cancelled (e.g., Escape pressed). + * + * @experimental + * + * @author Fabien Potencier + */ +class CancelEvent extends AbstractEvent +{ +} diff --git a/src/Symfony/Component/Tui/Event/ChangeEvent.php b/src/Symfony/Component/Tui/Event/ChangeEvent.php new file mode 100644 index 0000000000000..3b56b90151101 --- /dev/null +++ b/src/Symfony/Component/Tui/Event/ChangeEvent.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Event; + +use Symfony\Component\Tui\Widget\AbstractWidget; + +/** + * Event dispatched when a widget's value changes. + * + * @experimental + * + * @author Fabien Potencier + */ +class ChangeEvent extends AbstractEvent +{ + public function __construct( + AbstractWidget $target, + private readonly string $value, + ) { + parent::__construct($target); + } + + /** + * Get the current value. + */ + public function getValue(): string + { + return $this->value; + } + + /** + * Check if the current value is empty or contains only whitespace. + */ + public function isEmpty(): bool + { + return '' === trim($this->value); + } +} diff --git a/src/Symfony/Component/Tui/Event/FocusEvent.php b/src/Symfony/Component/Tui/Event/FocusEvent.php new file mode 100644 index 0000000000000..a69a691d88869 --- /dev/null +++ b/src/Symfony/Component/Tui/Event/FocusEvent.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Event; + +use Symfony\Component\Tui\Widget\AbstractWidget; +use Symfony\Component\Tui\Widget\FocusableInterface; + +/** + * Event dispatched when focus changes to a new widget. + * + * @experimental + * + * @author Fabien Potencier + */ +class FocusEvent extends AbstractEvent +{ + public function __construct( + AbstractWidget&FocusableInterface $target, + private readonly ?FocusableInterface $previous, + ) { + parent::__construct($target); + } + + /** + * Get the previously focused widget, if any. + */ + public function getPrevious(): ?FocusableInterface + { + return $this->previous; + } +} diff --git a/src/Symfony/Component/Tui/Event/QuitEvent.php b/src/Symfony/Component/Tui/Event/QuitEvent.php new file mode 100644 index 0000000000000..7b87052ae5200 --- /dev/null +++ b/src/Symfony/Component/Tui/Event/QuitEvent.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Event; + +/** + * Event dispatched when user requests to quit. + * + * @experimental + * + * @author Fabien Potencier + */ +class QuitEvent extends AbstractEvent +{ +} diff --git a/src/Symfony/Component/Tui/Event/SelectEvent.php b/src/Symfony/Component/Tui/Event/SelectEvent.php new file mode 100644 index 0000000000000..7efa66e4b6f29 --- /dev/null +++ b/src/Symfony/Component/Tui/Event/SelectEvent.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Event; + +use Symfony\Component\Tui\Widget\SelectListWidget; + +/** + * Event dispatched when an item is selected in a SelectList. + * + * @experimental + * + * @author Fabien Potencier + */ +class SelectEvent extends AbstractEvent +{ + /** + * @param array{value: string, label: string, description?: string} $item + */ + public function __construct( + SelectListWidget $target, + private readonly array $item, + ) { + parent::__construct($target); + } + + /** + * Get the full selected item array. + * + * @return array{value: string, label: string, description?: string} + */ + public function getItem(): array + { + return $this->item; + } + + /** + * Get the selected item's value. + */ + public function getValue(): string + { + return $this->item['value']; + } + + /** + * Get the selected item's label. + */ + public function getLabel(): string + { + return $this->item['label']; + } + + /** + * Get the selected item's description, if any. + */ + public function getDescription(): ?string + { + return $this->item['description'] ?? null; + } +} diff --git a/src/Symfony/Component/Tui/Event/SelectionChangeEvent.php b/src/Symfony/Component/Tui/Event/SelectionChangeEvent.php new file mode 100644 index 0000000000000..f179f7c7d24eb --- /dev/null +++ b/src/Symfony/Component/Tui/Event/SelectionChangeEvent.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Event; + +use Symfony\Component\Tui\Widget\SelectListWidget; + +/** + * Event dispatched when the highlighted item changes in a SelectList. + * + * This fires when the user moves the cursor (arrow keys, scroll), not + * when they confirm a selection (that's {@see SelectEvent}). + * + * @experimental + * + * @author Fabien Potencier + */ +class SelectionChangeEvent extends AbstractEvent +{ + /** + * @param array{value: string, label: string, description?: string} $item + */ + public function __construct( + SelectListWidget $target, + private readonly array $item, + ) { + parent::__construct($target); + } + + /** + * Get the full highlighted item array. + * + * @return array{value: string, label: string, description?: string} + */ + public function getItem(): array + { + return $this->item; + } + + /** + * Get the highlighted item's value. + */ + public function getValue(): string + { + return $this->item['value']; + } + + /** + * Get the highlighted item's label. + */ + public function getLabel(): string + { + return $this->item['label']; + } + + /** + * Get the highlighted item's description, if any. + */ + public function getDescription(): ?string + { + return $this->item['description'] ?? null; + } +} diff --git a/src/Symfony/Component/Tui/Event/SettingChangeEvent.php b/src/Symfony/Component/Tui/Event/SettingChangeEvent.php new file mode 100644 index 0000000000000..d88e5dbfa88d8 --- /dev/null +++ b/src/Symfony/Component/Tui/Event/SettingChangeEvent.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Event; + +use Symfony\Component\Tui\Widget\SettingsListWidget; + +/** + * Event dispatched when a setting value changes in SettingsList. + * + * @experimental + * + * @author Fabien Potencier + */ +class SettingChangeEvent extends AbstractEvent +{ + private const ENABLED_VALUES = ['on', 'true', 'yes', '1', 'enabled']; + private const DISABLED_VALUES = ['off', 'false', 'no', '0', 'disabled']; + + public function __construct( + SettingsListWidget $target, + private readonly string $id, + private readonly string $value, + ) { + parent::__construct($target); + } + + /** + * Get the setting identifier. + */ + public function getId(): string + { + return $this->id; + } + + /** + * Get the new setting value. + */ + public function getValue(): string + { + return $this->value; + } + + /** + * Check if the value represents an enabled/truthy state. + * + * Matches: on, true, yes, 1, enabled + */ + public function isEnabled(): bool + { + return \in_array(strtolower($this->value), self::ENABLED_VALUES, true); + } + + /** + * Check if the value represents a disabled/falsy state. + * + * Matches: off, false, no, 0, disabled + */ + public function isDisabled(): bool + { + return \in_array(strtolower($this->value), self::DISABLED_VALUES, true); + } +} diff --git a/src/Symfony/Component/Tui/Event/SubmitEvent.php b/src/Symfony/Component/Tui/Event/SubmitEvent.php new file mode 100644 index 0000000000000..64f184fa96617 --- /dev/null +++ b/src/Symfony/Component/Tui/Event/SubmitEvent.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Event; + +use Symfony\Component\Tui\Widget\AbstractWidget; + +/** + * Event dispatched when content is submitted (e.g., Enter pressed in Input/Editor). + * + * @experimental + * + * @author Fabien Potencier + */ +class SubmitEvent extends AbstractEvent +{ + public function __construct( + AbstractWidget $target, + private readonly string $value, + ) { + parent::__construct($target); + } + + /** + * Get the submitted value. + */ + public function getValue(): string + { + return $this->value; + } + + /** + * Check if the submitted value is empty or contains only whitespace. + */ + public function isEmpty(): bool + { + return '' === trim($this->value); + } +} diff --git a/src/Symfony/Component/Tui/Event/TickEvent.php b/src/Symfony/Component/Tui/Event/TickEvent.php new file mode 100644 index 0000000000000..a9de8e26c40f9 --- /dev/null +++ b/src/Symfony/Component/Tui/Event/TickEvent.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Event; + +/** + * Event dispatched on each tick of the main loop. + * + * Unlike widget events, tick is a global application event + * with no associated widget target. + * + * @experimental + * + * @author Fabien Potencier + */ +class TickEvent +{ + private bool $hasBusyHint = false; + private bool $busy = false; + + public function __construct( + private readonly float $deltaTime = 0.0, + ) { + } + + /** + * Time elapsed (in seconds) since the previous tick callback. + */ + public function getDeltaTime(): float + { + return $this->deltaTime; + } + + public function setBusy(bool $busy = true): void + { + $this->hasBusyHint = true; + $this->busy = $busy; + } + + public function hasBusyHint(): bool + { + return $this->hasBusyHint; + } + + public function isBusy(): bool + { + return $this->busy; + } +} diff --git a/src/Symfony/Component/Tui/Exception/ExceptionInterface.php b/src/Symfony/Component/Tui/Exception/ExceptionInterface.php new file mode 100644 index 0000000000000..0d2765ada172f --- /dev/null +++ b/src/Symfony/Component/Tui/Exception/ExceptionInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Exception; + +/** + * Exception interface for all exceptions thrown by the component. + * + * @experimental + * + * @author Fabien Potencier + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/Symfony/Component/Tui/Exception/InvalidArgumentException.php b/src/Symfony/Component/Tui/Exception/InvalidArgumentException.php new file mode 100644 index 0000000000000..57cd86dbc4dc0 --- /dev/null +++ b/src/Symfony/Component/Tui/Exception/InvalidArgumentException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Exception; + +/** + * @experimental + * + * @author Fabien Potencier + */ +class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Tui/Exception/LogicException.php b/src/Symfony/Component/Tui/Exception/LogicException.php new file mode 100644 index 0000000000000..b34e5170ec891 --- /dev/null +++ b/src/Symfony/Component/Tui/Exception/LogicException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Exception; + +/** + * @experimental + * + * @author Fabien Potencier + */ +class LogicException extends \LogicException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Tui/Exception/RenderException.php b/src/Symfony/Component/Tui/Exception/RenderException.php new file mode 100644 index 0000000000000..f23acf356b4c6 --- /dev/null +++ b/src/Symfony/Component/Tui/Exception/RenderException.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Exception; + +/** + * Exception thrown when rendering fails. + * + * Typically thrown when a component renders a line that exceeds the terminal width. + * + * @experimental + * + * @author Fabien Potencier + */ +class RenderException extends RuntimeException +{ + public function __construct( + string $message, + private readonly int $lineNumber = 0, + private readonly int $lineWidth = 0, + private readonly int $terminalWidth = 0, + ) { + parent::__construct($message); + } + + public function getLineNumber(): int + { + return $this->lineNumber; + } + + public function getLineWidth(): int + { + return $this->lineWidth; + } + + public function getTerminalWidth(): int + { + return $this->terminalWidth; + } +} diff --git a/src/Symfony/Component/Tui/Exception/RuntimeException.php b/src/Symfony/Component/Tui/Exception/RuntimeException.php new file mode 100644 index 0000000000000..e7263c136ba02 --- /dev/null +++ b/src/Symfony/Component/Tui/Exception/RuntimeException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Exception; + +/** + * @experimental + * + * @author Fabien Potencier + */ +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Tui/Focus/FocusManager.php b/src/Symfony/Component/Tui/Focus/FocusManager.php new file mode 100644 index 0000000000000..ade900c821338 --- /dev/null +++ b/src/Symfony/Component/Tui/Focus/FocusManager.php @@ -0,0 +1,236 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Focus; + +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Tui\Event\FocusEvent; +use Symfony\Component\Tui\Input\Key; +use Symfony\Component\Tui\Input\Keybindings; +use Symfony\Component\Tui\Input\KeyParser; +use Symfony\Component\Tui\Render\RenderRequestorInterface; +use Symfony\Component\Tui\Widget\AbstractWidget; +use Symfony\Component\Tui\Widget\FocusableInterface; + +/** + * Owns the focused-widget state and handles focus navigation. + * + * Default bindings: F6 (next) and Shift+F6 (previous). + * + * @experimental + * + * @author Fabien Potencier + */ +class FocusManager +{ + private const DEFAULT_BINDINGS = [ + 'focus_next' => [Key::F6], + 'focus_previous' => ['shift+f6'], + ]; + + private ?AbstractWidget $focused = null; + + /** @var array */ + private array $focusables = []; + + private Keybindings $keybindings; + + public function __construct( + private readonly RenderRequestorInterface $renderRequestor, + ?Keybindings $keybindings = null, + ?KeyParser $parser = null, + private ?EventDispatcherInterface $eventDispatcher = null, + ) { + $this->keybindings = $keybindings ?? new Keybindings(self::DEFAULT_BINDINGS, $parser); + } + + /** + * Set the focused widget. + * + * Clears the focused flag on the previous widget, sets it on the + * new one, and fires the onFocusChanged callback. + */ + public function setFocus(?AbstractWidget $widget): void + { + if ($this->focused === $widget) { + return; + } + + $previous = $this->focused; + + if ($this->focused instanceof FocusableInterface) { + $this->focused->setFocused(false); + } + + $this->focused = $widget; + + if ($widget instanceof FocusableInterface) { + $widget->setFocused(true); + } + + $this->notifyFocusChanged($widget, $previous); + $this->renderRequestor->requestRender(); + } + + /** + * Get the currently focused widget. + */ + public function getFocus(): ?AbstractWidget + { + return $this->focused; + } + + /** + * @return $this + */ + public function add(FocusableInterface&AbstractWidget $widget): self + { + if (!\in_array($widget, $this->focusables, true)) { + $this->focusables[] = $widget; + + if (null === $this->focused) { + $this->setFocus($widget); + } + } + + return $this; + } + + /** + * @return $this + */ + public function remove(FocusableInterface&AbstractWidget $widget): self + { + $index = array_search($widget, $this->focusables, true); + if (false !== $index) { + array_splice($this->focusables, (int) $index, 1); + } + + if ($this->focused === $widget) { + $next = $this->focusables[0] ?? null; + $this->setFocus($next); + } + + return $this; + } + + /** + * @return $this + */ + public function clear(): self + { + $this->focusables = []; + + return $this; + } + + /** + * @return FocusableInterface[] + */ + public function all(): array + { + return $this->focusables; + } + + /** + * Register a listener for focus change events. + * + * @param callable(FocusEvent): void $callback + * + * @return $this + */ + public function onFocusChanged(callable $callback): self + { + $this->eventDispatcher?->addListener(FocusEvent::class, $callback); + + return $this; + } + + public function handleInput(string $data): bool + { + // Only handle focus navigation when there are multiple focusables + if (\count($this->focusables) <= 1) { + return false; + } + + if ($this->keybindings->matches($data, 'focus_next')) { + $this->focusNext(); + + return true; + } + + if ($this->keybindings->matches($data, 'focus_previous')) { + $this->focusPrevious(); + + return true; + } + + return false; + } + + public function focusNext(): ?FocusableInterface + { + $count = \count($this->focusables); + if (0 === $count) { + return null; + } + + $index = array_search($this->focused, $this->focusables, true); + if (false === $index) { + $index = -1; + } else { + $index = (int) $index; + } + + $nextIndex = ($index + 1) % $count; + $next = $this->focusables[$nextIndex]; + $this->setFocus($next); + + return $next; + } + + public function focusPrevious(): ?FocusableInterface + { + $count = \count($this->focusables); + if (0 === $count) { + return null; + } + + $index = array_search($this->focused, $this->focusables, true); + if (false === $index) { + $index = 0; + } else { + $index = (int) $index; + } + + $previousIndex = ($index - 1 + $count) % $count; + $previous = $this->focusables[$previousIndex]; + $this->setFocus($previous); + + return $previous; + } + + private function notifyFocusChanged(?AbstractWidget $focused, ?AbstractWidget $previous): void + { + if (null === $focused || $focused === $previous) { + return; + } + + if (!$focused instanceof FocusableInterface) { + return; + } + + $this->eventDispatcher?->dispatch(new FocusEvent( + $focused, + $previous instanceof FocusableInterface ? $previous : null, + )); + } +} diff --git a/src/Symfony/Component/Tui/Input/Key.php b/src/Symfony/Component/Tui/Input/Key.php new file mode 100644 index 0000000000000..0c0d6ad36c14e --- /dev/null +++ b/src/Symfony/Component/Tui/Input/Key.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Input; + +/** + * Helper class for creating key identifiers. + * + * Provides constants and factory methods for keyboard input matching. + * + * @experimental + * + * @author Fabien Potencier + */ +final class Key +{ + // Special keys + public const ESCAPE = 'escape'; + public const ENTER = 'enter'; + public const TAB = 'tab'; + public const SPACE = 'space'; + public const BACKSPACE = 'backspace'; + public const DELETE = 'delete'; + public const INSERT = 'insert'; + public const HOME = 'home'; + public const END = 'end'; + public const PAGE_UP = 'pageUp'; + public const PAGE_DOWN = 'pageDown'; + + // Arrow keys + public const UP = 'up'; + public const DOWN = 'down'; + public const LEFT = 'left'; + public const RIGHT = 'right'; + + // Function keys + public const F1 = 'f1'; + public const F2 = 'f2'; + public const F3 = 'f3'; + public const F4 = 'f4'; + public const F5 = 'f5'; + public const F6 = 'f6'; + public const F7 = 'f7'; + public const F8 = 'f8'; + public const F9 = 'f9'; + public const F10 = 'f10'; + public const F11 = 'f11'; + public const F12 = 'f12'; + + public static function ctrl(string $key): string + { + return 'ctrl+'.strtolower($key); + } + + public static function shift(string $key): string + { + return 'shift+'.strtolower($key); + } + + public static function alt(string $key): string + { + return 'alt+'.strtolower($key); + } + + public static function ctrlShift(string $key): string + { + return 'ctrl+shift+'.strtolower($key); + } + + public static function ctrlAlt(string $key): string + { + return 'ctrl+alt+'.strtolower($key); + } + + public static function shiftAlt(string $key): string + { + return 'shift+alt+'.strtolower($key); + } + + public static function ctrlShiftAlt(string $key): string + { + return 'ctrl+shift+alt+'.strtolower($key); + } +} diff --git a/src/Symfony/Component/Tui/Input/KeyParser.php b/src/Symfony/Component/Tui/Input/KeyParser.php new file mode 100644 index 0000000000000..bb6bac6ecedea --- /dev/null +++ b/src/Symfony/Component/Tui/Input/KeyParser.php @@ -0,0 +1,966 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Input; + +/** + * Parses raw terminal input into key identifiers. + * + * Supports both legacy terminal sequences and Kitty keyboard protocol. + * + * @experimental + * + * @author Fabien Potencier + */ +final class KeyParser +{ + private const MOD_SHIFT = 1; + private const MOD_ALT = 2; + private const MOD_CTRL = 4; + private const LOCK_MASK = 192; // Caps Lock + Num Lock + + private const EVENT_PRESS = 1; + private const EVENT_REPEAT = 2; + private const EVENT_RELEASE = 3; + + private const CODEPOINTS = [ + 'escape' => 27, + 'tab' => 9, + 'enter' => 13, + 'space' => 32, + 'backspace' => 127, + 'kpEnter' => 57414, + ]; + + private const ARROW_CODEPOINTS = [ + 'up' => -1, + 'down' => -2, + 'right' => -3, + 'left' => -4, + ]; + + private const FUNCTIONAL_CODEPOINTS = [ + 'delete' => -10, + 'insert' => -11, + 'pageUp' => -12, + 'pageDown' => -13, + 'home' => -14, + 'end' => -15, + ]; + + private const LEGACY_KEY_SEQUENCES = [ + 'up' => ["\x1b[A", "\x1bOA"], + 'down' => ["\x1b[B", "\x1bOB"], + 'right' => ["\x1b[C", "\x1bOC"], + 'left' => ["\x1b[D", "\x1bOD"], + 'home' => ["\x1b[H", "\x1bOH", "\x1b[1~", "\x1b[7~"], + 'end' => ["\x1b[F", "\x1bOF", "\x1b[4~", "\x1b[8~"], + 'insert' => ["\x1b[2~"], + 'delete' => ["\x1b[3~"], + 'pageUp' => ["\x1b[5~", "\x1b[[5~"], + 'pageDown' => ["\x1b[6~", "\x1b[[6~"], + 'clear' => ["\x1b[E", "\x1bOE"], + 'f1' => ["\x1bOP", "\x1b[11~", "\x1b[[A"], + 'f2' => ["\x1bOQ", "\x1b[12~", "\x1b[[B"], + 'f3' => ["\x1bOR", "\x1b[13~", "\x1b[[C"], + 'f4' => ["\x1bOS", "\x1b[14~", "\x1b[[D"], + 'f5' => ["\x1b[15~", "\x1b[[E"], + 'f6' => ["\x1b[17~"], + 'f7' => ["\x1b[18~"], + 'f8' => ["\x1b[19~"], + 'f9' => ["\x1b[20~"], + 'f10' => ["\x1b[21~"], + 'f11' => ["\x1b[23~"], + 'f12' => ["\x1b[24~"], + ]; + + private const LEGACY_FUNCTION_KEY_CODES = [ + 'f1' => 11, + 'f2' => 12, + 'f3' => 13, + 'f4' => 14, + 'f5' => 15, + 'f6' => 17, + 'f7' => 18, + 'f8' => 19, + 'f9' => 20, + 'f10' => 21, + 'f11' => 23, + 'f12' => 24, + ]; + + private const LEGACY_FUNCTION_KEY_LETTERS = [ + 'f1' => 'P', + 'f2' => 'Q', + 'f3' => 'R', + 'f4' => 'S', + ]; + + private const LEGACY_SHIFT_SEQUENCES = [ + 'up' => ["\x1b[a"], + 'down' => ["\x1b[b"], + 'right' => ["\x1b[c"], + 'left' => ["\x1b[d"], + 'clear' => ["\x1b[e"], + 'insert' => ["\x1b[2$"], + 'delete' => ["\x1b[3$"], + 'pageUp' => ["\x1b[5$"], + 'pageDown' => ["\x1b[6$"], + 'home' => ["\x1b[7$"], + 'end' => ["\x1b[8$"], + ]; + + private const LEGACY_CTRL_SEQUENCES = [ + 'up' => ["\x1bOa"], + 'down' => ["\x1bOb"], + 'right' => ["\x1bOc"], + 'left' => ["\x1bOd"], + 'clear' => ["\x1bOe"], + 'insert' => ["\x1b[2^"], + 'delete' => ["\x1b[3^"], + 'pageUp' => ["\x1b[5^"], + 'pageDown' => ["\x1b[6^"], + 'home' => ["\x1b[7^"], + 'end' => ["\x1b[8^"], + ]; + + private const LEGACY_SEQUENCE_KEY_IDS = [ + "\x1bOA" => 'up', + "\x1bOB" => 'down', + "\x1bOC" => 'right', + "\x1bOD" => 'left', + "\x1bOH" => 'home', + "\x1bOF" => 'end', + "\x1b[E" => 'clear', + "\x1bOE" => 'clear', + "\x1bOe" => 'ctrl+clear', + "\x1b[e" => 'shift+clear', + "\x1b[2~" => 'insert', + "\x1b[2$" => 'shift+insert', + "\x1b[2^" => 'ctrl+insert', + "\x1b[3$" => 'shift+delete', + "\x1b[3^" => 'ctrl+delete', + "\x1b[[5~" => 'pageUp', + "\x1b[[6~" => 'pageDown', + "\x1b[a" => 'shift+up', + "\x1b[b" => 'shift+down', + "\x1b[c" => 'shift+right', + "\x1b[d" => 'shift+left', + "\x1bOa" => 'ctrl+up', + "\x1bOb" => 'ctrl+down', + "\x1bOc" => 'ctrl+right', + "\x1bOd" => 'ctrl+left', + "\x1b[5$" => 'shift+pageUp', + "\x1b[6$" => 'shift+pageDown', + "\x1b[7$" => 'shift+home', + "\x1b[8$" => 'shift+end', + "\x1b[5^" => 'ctrl+pageUp', + "\x1b[6^" => 'ctrl+pageDown', + "\x1b[7^" => 'ctrl+home', + "\x1b[8^" => 'ctrl+end', + "\x1bOP" => 'f1', + "\x1bOQ" => 'f2', + "\x1bOR" => 'f3', + "\x1bOS" => 'f4', + "\x1b[11~" => 'f1', + "\x1b[12~" => 'f2', + "\x1b[13~" => 'f3', + "\x1b[14~" => 'f4', + "\x1b[[A" => 'f1', + "\x1b[[B" => 'f2', + "\x1b[[C" => 'f3', + "\x1b[[D" => 'f4', + "\x1b[[E" => 'f5', + "\x1b[15~" => 'f5', + "\x1b[17~" => 'f6', + "\x1b[18~" => 'f7', + "\x1b[19~" => 'f8', + "\x1b[20~" => 'f9', + "\x1b[21~" => 'f10', + "\x1b[23~" => 'f11', + "\x1b[24~" => 'f12', + "\x1bb" => 'alt+left', + "\x1bf" => 'alt+right', + "\x1bp" => 'alt+up', + "\x1bn" => 'alt+down', + ]; + + private const SYMBOL_KEYS = [ + '`', '-', '=', '[', ']', '\\', ';', "'", ',', '.', '/', + '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '_', '+', + '|', '~', '{', '}', ':', '<', '>', '?', + ]; + + private bool $kittyProtocolActive = false; + + public function setKittyProtocolActive(bool $active): void + { + $this->kittyProtocolActive = $active; + } + + public function isKittyProtocolActive(): bool + { + return $this->kittyProtocolActive; + } + + /** + * Parse raw input and return the key identifier. + * + * @return array{key: string, modifiers: array, eventType: int}|null + */ + public function parse(string $data): ?array + { + $parsed = $this->parseKey($data); + if (null === $parsed) { + return null; + } + + $key = $parsed['key']; + $modifiers = []; + + if (str_contains($key, '+')) { + $parts = explode('+', $key); + $keyPart = array_pop($parts); + $modifiers = $parts; + $key = $parts ? implode('+', $parts).'+'.$keyPart : $keyPart; + } + + return [ + 'key' => $key, + 'modifiers' => $modifiers, + 'eventType' => $parsed['eventType'], + ]; + } + + /** + * Check if input matches a key identifier. + */ + public function matches(string $data, string $keyId): bool + { + if ($this->isKeyRelease($data)) { + return false; + } + + return $this->matchesKey($data, $keyId); + } + + public function isKeyRelease(string $data): bool + { + if (str_contains($data, "\x1b[200~")) { + return false; + } + + return (bool) preg_match('/:3[u~ABCDHF]$/', $data); + } + + public function isKeyRepeat(string $data): bool + { + if (str_contains($data, "\x1b[200~")) { + return false; + } + + return (bool) preg_match('/:2[u~ABCDHF]$/', $data); + } + + /** + * Parse input data into a key identifier and event type. + * + * @return array{key: string, eventType: int}|null + */ + private function parseKey(string $data): ?array + { + if ('' === $data) { + return null; + } + + if ( + $this->kittyProtocolActive + || (str_starts_with($data, "\x1b[") && (str_ends_with($data, 'u') || str_contains($data, ':'))) + ) { + $kitty = $this->parseKittySequence($data); + if (null !== $kitty) { + $keyName = $this->keyNameFromCodepoint($kitty['codepoint']); + if (null !== $keyName) { + $mods = $this->modsFromFlags($kitty['modifier']); + $key = [] !== $mods ? implode('+', $mods).'+'.$keyName : $keyName; + + return ['key' => $key, 'eventType' => $kitty['eventType']]; + } + } + } + + if ($this->kittyProtocolActive) { + if ("\x1b\r" === $data || "\n" === $data) { + return ['key' => 'shift+enter', 'eventType' => self::EVENT_PRESS]; + } + } + + if (isset(self::LEGACY_SEQUENCE_KEY_IDS[$data])) { + return ['key' => self::LEGACY_SEQUENCE_KEY_IDS[$data], 'eventType' => self::EVENT_PRESS]; + } + + $press = static fn (string $key): array => ['key' => $key, 'eventType' => self::EVENT_PRESS]; + + $matched = match ($data) { + "\x1b" => $press('escape'), + "\x1c" => $press('ctrl+\\'), + "\x1d" => $press('ctrl+]'), + "\x1f" => $press('ctrl+-'), + "\x1b\x1b" => $press('ctrl+alt+['), + "\x1b\x1c" => $press('ctrl+alt+\\'), + "\x1b\x1d" => $press('ctrl+alt+]'), + "\x1b\x1f" => $press('ctrl+alt+-'), + "\t" => $press('tab'), + "\r", "\x1bOM" => $press('enter'), + "\x00" => $press('ctrl+space'), + ' ' => $press('space'), + "\x7f", "\x08" => $press('backspace'), + "\x1b[Z" => $press('shift+tab'), + "\x1b\x7f", "\x1b\x08" => $press('alt+backspace'), + "\x1b[A" => $press('up'), + "\x1b[B" => $press('down'), + "\x1b[C" => $press('right'), + "\x1b[D" => $press('left'), + "\x1b[H" => $press('home'), + "\x1b[F" => $press('end'), + "\x1b[3~" => $press('delete'), + "\x1b[5~" => $press('pageUp'), + "\x1b[6~" => $press('pageDown'), + default => null, + }; + if (null !== $matched) { + return $matched; + } + + if (!$this->kittyProtocolActive && "\n" === $data) { + return $press('enter'); + } + if (!$this->kittyProtocolActive) { + $matched = match ($data) { + "\x1b\r" => $press('alt+enter'), + "\x1b " => $press('alt+space'), + "\x1bB" => $press('alt+left'), + "\x1bF" => $press('alt+right'), + default => null, + }; + if (null !== $matched) { + return $matched; + } + + if (2 === \strlen($data) && "\x1b" === $data[0]) { + $code = \ord($data[1]); + if ($code >= 1 && $code <= 26) { + return $press('ctrl+alt+'.\chr($code + 96)); + } + if (($code >= 48 && $code <= 57) || ($code >= 97 && $code <= 122)) { + return $press('alt+'.\chr($code)); + } + } + } + + if (1 === \strlen($data)) { + $code = \ord($data); + if ($code >= 1 && $code <= 26) { + return $press('ctrl+'.\chr($code + 96)); + } + if ($code >= 32 && $code <= 126) { + return $press($data); + } + } + + if (\strlen($data) > 1 && !str_starts_with($data, "\x1b")) { + return $press($data); + } + + return null; + } + + /** + * @return array{codepoint: int, modifier: int, eventType: int}|null + */ + private function parseKittySequence(string $data): ?array + { + // Format: ESC [ codepoint[:shifted_key[:base_layout_key]] [;modifiers[:event_type]] u + // We parse the full syntax but only use the codepoint (logical key) + // for key resolution. The base_layout_key (US QWERTY physical position) + // is intentionally ignored; keybindings must follow the logical layout + // so that e.g. Ctrl+W means Ctrl+W on every keyboard layout. + if (preg_match('/^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$/', $data, $match)) { + $codepoint = (int) $match[1]; + $modifierValue = isset($match[4]) && '' !== $match[4] ? (int) $match[4] : 1; + $eventType = $this->parseEventType($match[5] ?? null); + + return [ + 'codepoint' => $codepoint, + 'modifier' => $modifierValue - 1, + 'eventType' => $eventType, + ]; + } + + if (preg_match('/^\x1b\[1;(\d+)(?::(\d+))?([ABCD])$/', $data, $match)) { + $modifierValue = (int) $match[1]; + $eventType = $this->parseEventType('' !== $match[2] ? $match[2] : null); + $arrowCodes = [ + 'A' => self::ARROW_CODEPOINTS['up'], + 'B' => self::ARROW_CODEPOINTS['down'], + 'C' => self::ARROW_CODEPOINTS['right'], + 'D' => self::ARROW_CODEPOINTS['left'], + ]; + + return [ + 'codepoint' => $arrowCodes[$match[3]], + 'modifier' => $modifierValue - 1, + 'eventType' => $eventType, + ]; + } + + if (preg_match('/^\x1b\[(\d+)(?:;(\d+))?(?::(\d+))?~$/', $data, $match)) { + $keyNum = (int) $match[1]; + $modifierValue = isset($match[2]) && '' !== $match[2] ? (int) $match[2] : 1; + $eventType = $this->parseEventType($match[3] ?? null); + $funcCodes = [ + 2 => self::FUNCTIONAL_CODEPOINTS['insert'], + 3 => self::FUNCTIONAL_CODEPOINTS['delete'], + 5 => self::FUNCTIONAL_CODEPOINTS['pageUp'], + 6 => self::FUNCTIONAL_CODEPOINTS['pageDown'], + 7 => self::FUNCTIONAL_CODEPOINTS['home'], + 8 => self::FUNCTIONAL_CODEPOINTS['end'], + ]; + + if (isset($funcCodes[$keyNum])) { + return [ + 'codepoint' => $funcCodes[$keyNum], + 'modifier' => $modifierValue - 1, + 'eventType' => $eventType, + ]; + } + } + + if (preg_match('/^\x1b\[1;(\d+)(?::(\d+))?([HF])$/', $data, $match)) { + $modifierValue = (int) $match[1]; + $eventType = $this->parseEventType('' !== $match[2] ? $match[2] : null); + $codepoint = 'H' === $match[3] + ? self::FUNCTIONAL_CODEPOINTS['home'] + : self::FUNCTIONAL_CODEPOINTS['end']; + + return [ + 'codepoint' => $codepoint, + 'modifier' => $modifierValue - 1, + 'eventType' => $eventType, + ]; + } + + return null; + } + + private function parseEventType(?string $eventTypeStr): int + { + if (null === $eventTypeStr || '' === $eventTypeStr) { + return self::EVENT_PRESS; + } + + return match ((int) $eventTypeStr) { + self::EVENT_REPEAT => self::EVENT_REPEAT, + self::EVENT_RELEASE => self::EVENT_RELEASE, + default => self::EVENT_PRESS, + }; + } + + private function keyNameFromCodepoint(int $codepoint): ?string + { + return match ($codepoint) { + self::CODEPOINTS['escape'] => 'escape', + self::CODEPOINTS['tab'] => 'tab', + self::CODEPOINTS['enter'], self::CODEPOINTS['kpEnter'] => 'enter', + self::CODEPOINTS['space'] => 'space', + self::CODEPOINTS['backspace'] => 'backspace', + self::FUNCTIONAL_CODEPOINTS['delete'] => 'delete', + self::FUNCTIONAL_CODEPOINTS['insert'] => 'insert', + self::FUNCTIONAL_CODEPOINTS['home'] => 'home', + self::FUNCTIONAL_CODEPOINTS['end'] => 'end', + self::FUNCTIONAL_CODEPOINTS['pageUp'] => 'pageUp', + self::FUNCTIONAL_CODEPOINTS['pageDown'] => 'pageDown', + self::ARROW_CODEPOINTS['up'] => 'up', + self::ARROW_CODEPOINTS['down'] => 'down', + self::ARROW_CODEPOINTS['left'] => 'left', + self::ARROW_CODEPOINTS['right'] => 'right', + default => $this->keyNameFromChar($codepoint), + }; + } + + private function keyNameFromChar(int $codepoint): ?string + { + if (($codepoint >= 48 && $codepoint <= 57) || ($codepoint >= 97 && $codepoint <= 122)) { + return \chr($codepoint); + } + + if ($codepoint < 0 || $codepoint > 255) { + return null; + } + + $char = \chr($codepoint); + + return \in_array($char, self::SYMBOL_KEYS, true) ? $char : null; + } + + /** + * @return string[] + */ + private function modsFromFlags(int $modifier): array + { + $mods = []; + $effective = $modifier & ~self::LOCK_MASK; + if ($effective & self::MOD_SHIFT) { + $mods[] = 'shift'; + } + if ($effective & self::MOD_CTRL) { + $mods[] = 'ctrl'; + } + if ($effective & self::MOD_ALT) { + $mods[] = 'alt'; + } + + return $mods; + } + + private function matchesKey(string $data, string $keyId): bool + { + $parsed = $this->parseKeyId($keyId); + if (null === $parsed) { + return false; + } + + $key = $parsed['key']; + $ctrl = $parsed['ctrl']; + $shift = $parsed['shift']; + $alt = $parsed['alt']; + + $modifier = 0; + if ($shift) { + $modifier |= self::MOD_SHIFT; + } + if ($alt) { + $modifier |= self::MOD_ALT; + } + if ($ctrl) { + $modifier |= self::MOD_CTRL; + } + + switch ($key) { + case 'escape': + case 'esc': + if (0 !== $modifier) { + return false; + } + + return "\x1b" === $data || $this->matchesKittySequence($data, self::CODEPOINTS['escape'], 0); + + case 'space': + if (!$this->kittyProtocolActive) { + if ($ctrl && !$alt && !$shift && "\x00" === $data) { + return true; + } + if ($alt && !$ctrl && !$shift && "\x1b " === $data) { + return true; + } + } + if (0 === $modifier) { + return ' ' === $data || $this->matchesKittySequence($data, self::CODEPOINTS['space'], 0); + } + + return $this->matchesKittySequence($data, self::CODEPOINTS['space'], $modifier); + + case 'tab': + if ($shift && !$ctrl && !$alt) { + return "\x1b[Z" === $data || $this->matchesKittySequence($data, self::CODEPOINTS['tab'], self::MOD_SHIFT); + } + if (0 === $modifier) { + return "\t" === $data || $this->matchesKittySequence($data, self::CODEPOINTS['tab'], 0); + } + + return $this->matchesKittySequence($data, self::CODEPOINTS['tab'], $modifier); + + case 'enter': + case 'return': + if ($shift && !$ctrl && !$alt) { + if ( + $this->matchesKittySequence($data, self::CODEPOINTS['enter'], self::MOD_SHIFT) + || $this->matchesKittySequence($data, self::CODEPOINTS['kpEnter'], self::MOD_SHIFT) + ) { + return true; + } + if ($this->matchesModifyOtherKeys($data, self::CODEPOINTS['enter'], self::MOD_SHIFT)) { + return true; + } + if ($this->kittyProtocolActive) { + return "\x1b\r" === $data || "\n" === $data; + } + + return false; + } + if ($alt && !$ctrl && !$shift) { + if ( + $this->matchesKittySequence($data, self::CODEPOINTS['enter'], self::MOD_ALT) + || $this->matchesKittySequence($data, self::CODEPOINTS['kpEnter'], self::MOD_ALT) + ) { + return true; + } + if ($this->matchesModifyOtherKeys($data, self::CODEPOINTS['enter'], self::MOD_ALT)) { + return true; + } + if (!$this->kittyProtocolActive) { + return "\x1b\r" === $data; + } + + return false; + } + if (0 === $modifier) { + return "\r" === $data + || (!$this->kittyProtocolActive && "\n" === $data) + || "\x1bOM" === $data + || $this->matchesKittySequence($data, self::CODEPOINTS['enter'], 0) + || $this->matchesKittySequence($data, self::CODEPOINTS['kpEnter'], 0); + } + + return $this->matchesKittySequence($data, self::CODEPOINTS['enter'], $modifier) + || $this->matchesKittySequence($data, self::CODEPOINTS['kpEnter'], $modifier); + + case 'backspace': + if ($alt && !$ctrl && !$shift) { + if ("\x1b\x7f" === $data || "\x1b\x08" === $data) { + return true; + } + + return $this->matchesKittySequence($data, self::CODEPOINTS['backspace'], self::MOD_ALT); + } + if (0 === $modifier) { + return "\x7f" === $data || "\x08" === $data || $this->matchesKittySequence($data, self::CODEPOINTS['backspace'], 0); + } + + return $this->matchesKittySequence($data, self::CODEPOINTS['backspace'], $modifier); + + case 'insert': + if (0 === $modifier) { + return $this->matchesLegacySequence($data, self::LEGACY_KEY_SEQUENCES['insert']) + || $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['insert'], 0); + } + if ($this->matchesLegacyModifierSequence($data, 'insert', $modifier)) { + return true; + } + + return $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['insert'], $modifier); + + case 'delete': + if (0 === $modifier) { + return $this->matchesLegacySequence($data, self::LEGACY_KEY_SEQUENCES['delete']) + || $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['delete'], 0); + } + if ($this->matchesLegacyModifierSequence($data, 'delete', $modifier)) { + return true; + } + + return $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['delete'], $modifier); + + case 'clear': + if (0 === $modifier) { + return $this->matchesLegacySequence($data, self::LEGACY_KEY_SEQUENCES['clear']); + } + + return $this->matchesLegacyModifierSequence($data, 'clear', $modifier); + + case 'home': + if (0 === $modifier) { + return $this->matchesLegacySequence($data, self::LEGACY_KEY_SEQUENCES['home']) + || $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['home'], 0); + } + if ($this->matchesLegacyModifierSequence($data, 'home', $modifier)) { + return true; + } + + return $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['home'], $modifier); + + case 'end': + if (0 === $modifier) { + return $this->matchesLegacySequence($data, self::LEGACY_KEY_SEQUENCES['end']) + || $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['end'], 0); + } + if ($this->matchesLegacyModifierSequence($data, 'end', $modifier)) { + return true; + } + + return $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['end'], $modifier); + + case 'pageup': + if (0 === $modifier) { + return $this->matchesLegacySequence($data, self::LEGACY_KEY_SEQUENCES['pageUp']) + || $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['pageUp'], 0); + } + if ($this->matchesLegacyModifierSequence($data, 'pageUp', $modifier)) { + return true; + } + + return $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['pageUp'], $modifier); + + case 'pagedown': + if (0 === $modifier) { + return $this->matchesLegacySequence($data, self::LEGACY_KEY_SEQUENCES['pageDown']) + || $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['pageDown'], 0); + } + if ($this->matchesLegacyModifierSequence($data, 'pageDown', $modifier)) { + return true; + } + + return $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['pageDown'], $modifier); + + case 'up': + if ($alt && !$ctrl && !$shift) { + return "\x1bp" === $data || $this->matchesKittySequence($data, self::ARROW_CODEPOINTS['up'], self::MOD_ALT); + } + if (0 === $modifier) { + return $this->matchesLegacySequence($data, self::LEGACY_KEY_SEQUENCES['up']) + || $this->matchesKittySequence($data, self::ARROW_CODEPOINTS['up'], 0); + } + if ($this->matchesLegacyModifierSequence($data, 'up', $modifier)) { + return true; + } + + return $this->matchesKittySequence($data, self::ARROW_CODEPOINTS['up'], $modifier); + + case 'down': + if ($alt && !$ctrl && !$shift) { + return "\x1bn" === $data || $this->matchesKittySequence($data, self::ARROW_CODEPOINTS['down'], self::MOD_ALT); + } + if (0 === $modifier) { + return $this->matchesLegacySequence($data, self::LEGACY_KEY_SEQUENCES['down']) + || $this->matchesKittySequence($data, self::ARROW_CODEPOINTS['down'], 0); + } + if ($this->matchesLegacyModifierSequence($data, 'down', $modifier)) { + return true; + } + + return $this->matchesKittySequence($data, self::ARROW_CODEPOINTS['down'], $modifier); + + case 'left': + if ($alt && !$ctrl && !$shift) { + return "\x1b[1;3D" === $data + || (!$this->kittyProtocolActive && "\x1bB" === $data) + || "\x1bb" === $data + || $this->matchesKittySequence($data, self::ARROW_CODEPOINTS['left'], self::MOD_ALT); + } + if ($ctrl && !$alt && !$shift) { + return "\x1b[1;5D" === $data + || $this->matchesLegacyModifierSequence($data, 'left', self::MOD_CTRL) + || $this->matchesKittySequence($data, self::ARROW_CODEPOINTS['left'], self::MOD_CTRL); + } + if (0 === $modifier) { + return $this->matchesLegacySequence($data, self::LEGACY_KEY_SEQUENCES['left']) + || $this->matchesKittySequence($data, self::ARROW_CODEPOINTS['left'], 0); + } + if ($this->matchesLegacyModifierSequence($data, 'left', $modifier)) { + return true; + } + + return $this->matchesKittySequence($data, self::ARROW_CODEPOINTS['left'], $modifier); + + case 'right': + if ($alt && !$ctrl && !$shift) { + return "\x1b[1;3C" === $data + || (!$this->kittyProtocolActive && "\x1bF" === $data) + || "\x1bf" === $data + || $this->matchesKittySequence($data, self::ARROW_CODEPOINTS['right'], self::MOD_ALT); + } + if ($ctrl && !$alt && !$shift) { + return "\x1b[1;5C" === $data + || $this->matchesLegacyModifierSequence($data, 'right', self::MOD_CTRL) + || $this->matchesKittySequence($data, self::ARROW_CODEPOINTS['right'], self::MOD_CTRL); + } + if (0 === $modifier) { + return $this->matchesLegacySequence($data, self::LEGACY_KEY_SEQUENCES['right']) + || $this->matchesKittySequence($data, self::ARROW_CODEPOINTS['right'], 0); + } + if ($this->matchesLegacyModifierSequence($data, 'right', $modifier)) { + return true; + } + + return $this->matchesKittySequence($data, self::ARROW_CODEPOINTS['right'], $modifier); + + case 'f1': + case 'f2': + case 'f3': + case 'f4': + case 'f5': + case 'f6': + case 'f7': + case 'f8': + case 'f9': + case 'f10': + case 'f11': + case 'f12': + if (0 !== $modifier) { + return $this->matchesLegacyFunctionKeyModifierSequence($data, $key, $modifier); + } + + return $this->matchesLegacySequence($data, self::LEGACY_KEY_SEQUENCES[$key]); + } + + $isDigit = 1 === \strlen($key) && $key >= '0' && $key <= '9'; + if (1 === \strlen($key) && (($key >= 'a' && $key <= 'z') || $isDigit || \in_array($key, self::SYMBOL_KEYS, true))) { + $codepoint = \ord($key); + $rawCtrl = $this->rawCtrlChar($key); + + if ($ctrl && $alt && !$shift && !$this->kittyProtocolActive && null !== $rawCtrl) { + return "\x1b".$rawCtrl === $data; + } + + if ($alt && !$ctrl && !$shift && !$this->kittyProtocolActive && (($key >= 'a' && $key <= 'z') || $isDigit)) { + if ("\x1b".$key === $data) { + return true; + } + } + + if ($ctrl && !$shift && !$alt) { + if (null !== $rawCtrl && $rawCtrl === $data) { + return true; + } + + return $this->matchesKittySequence($data, $codepoint, self::MOD_CTRL); + } + + if ($ctrl && $shift && !$alt) { + return $this->matchesKittySequence($data, $codepoint, self::MOD_SHIFT + self::MOD_CTRL); + } + + if ($shift && !$ctrl && !$alt) { + if (strtoupper($key) === $data) { + return true; + } + + return $this->matchesKittySequence($data, $codepoint, self::MOD_SHIFT); + } + + if (0 !== $modifier) { + return $this->matchesKittySequence($data, $codepoint, $modifier); + } + + return $data === $key || $this->matchesKittySequence($data, $codepoint, 0); + } + + return false; + } + + /** + * @param string[] $sequences + */ + private function matchesLegacySequence(string $data, array $sequences): bool + { + return \in_array($data, $sequences, true); + } + + private function matchesLegacyModifierSequence(string $data, string $key, int $modifier): bool + { + return match ($modifier) { + self::MOD_SHIFT => $this->matchesLegacySequence($data, self::LEGACY_SHIFT_SEQUENCES[$key] ?? []), + self::MOD_CTRL => $this->matchesLegacySequence($data, self::LEGACY_CTRL_SEQUENCES[$key] ?? []), + default => false, + }; + } + + private function matchesLegacyFunctionKeyModifierSequence(string $data, string $key, int $modifier): bool + { + $modValue = $modifier + 1; + + if (isset(self::LEGACY_FUNCTION_KEY_LETTERS[$key])) { + $letter = self::LEGACY_FUNCTION_KEY_LETTERS[$key]; + if ("\x1b[1;{$modValue}{$letter}" === $data) { + return true; + } + } + + if (isset(self::LEGACY_FUNCTION_KEY_CODES[$key])) { + $code = self::LEGACY_FUNCTION_KEY_CODES[$key]; + if ("\x1b[{$code};{$modValue}~" === $data) { + return true; + } + } + + return false; + } + + private function matchesKittySequence(string $data, int $expectedCodepoint, int $expectedModifier): bool + { + $parsed = $this->parseKittySequence($data); + if (null === $parsed) { + return false; + } + + $actualMod = $parsed['modifier'] & ~self::LOCK_MASK; + $expectedMod = $expectedModifier & ~self::LOCK_MASK; + + if ($actualMod !== $expectedMod) { + return false; + } + + return $parsed['codepoint'] === $expectedCodepoint; + } + + private function matchesModifyOtherKeys(string $data, int $expectedKeycode, int $expectedModifier): bool + { + if (!preg_match('/^\x1b\[27;(\d+);(\d+)~$/', $data, $match)) { + return false; + } + + $modValue = (int) $match[1]; + $keycode = (int) $match[2]; + $actualMod = $modValue - 1; + + return $keycode === $expectedKeycode && $actualMod === $expectedModifier; + } + + private function rawCtrlChar(string $key): ?string + { + $char = strtolower($key); + $code = \ord($char); + + if (($code >= 97 && $code <= 122) || '[' === $char || '\\' === $char || ']' === $char || '_' === $char) { + return \chr($code & 0x1F); + } + + if ('-' === $char) { + return \chr(31); + } + + return null; + } + + /** + * @return array{key: string, ctrl: bool, shift: bool, alt: bool}|null + */ + private function parseKeyId(string $keyId): ?array + { + // Special case: the '+' key itself + if ('+' === $keyId) { + return ['key' => '+', 'ctrl' => false, 'shift' => false, 'alt' => false]; + } + + $parts = explode('+', strtolower($keyId)); + $key = $parts[\count($parts) - 1] ?? ''; + if ('' === $key) { + return null; + } + + return [ + 'key' => $key, + 'ctrl' => \in_array('ctrl', $parts, true), + 'shift' => \in_array('shift', $parts, true), + 'alt' => \in_array('alt', $parts, true), + ]; + } +} diff --git a/src/Symfony/Component/Tui/Input/Keybindings.php b/src/Symfony/Component/Tui/Input/Keybindings.php new file mode 100644 index 0000000000000..12318356a137f --- /dev/null +++ b/src/Symfony/Component/Tui/Input/Keybindings.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Input; + +/** + * Configurable keybindings manager. + * + * Maps action names to key identifiers, allowing customizable keybindings. + * + * @experimental + * + * @author Fabien Potencier + */ +final class Keybindings +{ + /** @var array */ + private array $bindings; + + private KeyParser $parser; + + /** + * @param array $bindings + */ + public function __construct(array $bindings = [], ?KeyParser $parser = null) + { + $this->bindings = $bindings; + $this->parser = $parser ?? new KeyParser(); + } + + public function matches(string $data, string $action): bool + { + if (!isset($this->bindings[$action])) { + return false; + } + + foreach ($this->bindings[$action] as $keyId) { + if ($this->parser->matches($data, $keyId)) { + return true; + } + } + + return false; + } + + /** + * @return string[] + */ + public function getBindings(string $action): array + { + return $this->bindings[$action] ?? []; + } + + /** + * @return array + */ + public function all(): array + { + return $this->bindings; + } + + public function setKittyProtocolActive(bool $active): void + { + $this->parser->setKittyProtocolActive($active); + } + + /** + * @internal + */ + public function getParser(): KeyParser + { + return $this->parser; + } +} diff --git a/src/Symfony/Component/Tui/Input/StdinBuffer.php b/src/Symfony/Component/Tui/Input/StdinBuffer.php new file mode 100644 index 0000000000000..aadf6382df7c1 --- /dev/null +++ b/src/Symfony/Component/Tui/Input/StdinBuffer.php @@ -0,0 +1,388 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Input; + +/** + * Buffers and splits batched stdin input into individual sequences. + * + * This ensures components receive single key events, making key parsing work correctly. + * Also handles bracketed paste mode. + * + * @experimental + * + * @author Fabien Potencier + */ +final class StdinBuffer +{ + private string $buffer = ''; + + /** @var callable(string): void|null */ + private $onData; + + /** @var callable(string): void|null */ + private $onPaste; + + private bool $inPaste = false; + private string $pasteBuffer = ''; + + public function __construct() + { + } + + /** + * Set callback for individual key sequences. + * + * @param callable(string): void $callback + */ + public function onData(callable $callback): void + { + $this->onData = $callback; + } + + /** + * Set callback for paste content. + * + * @param callable(string): void $callback + */ + public function onPaste(callable $callback): void + { + $this->onPaste = $callback; + } + + /** + * Process incoming data and emit individual sequences. + */ + public function process(string $data): void + { + // Handle high-byte meta encoding: some terminals (e.g. macOS Terminal.app + // with "Use Option as Meta key") send Alt+key as a single byte with the + // high bit set (byte | 0x80) instead of the standard ESC + key sequence. + // Convert single high bytes to ESC + (byte & 0x7F) to normalize input. + // This matches the Pi reference implementation. + if (1 === \strlen($data) && \ord($data) > 127) { + $data = "\x1b".\chr(\ord($data) - 128); + } + + $this->buffer .= $data; + + while ('' !== $this->buffer) { + // Check for bracketed paste start + if (str_starts_with($this->buffer, "\x1b[200~")) { + $this->inPaste = true; + $this->pasteBuffer = ''; + $this->buffer = substr($this->buffer, 6); + continue; + } + + // If in paste mode, accumulate until end marker + if ($this->inPaste) { + $endPos = strpos($this->buffer, "\x1b[201~"); + if (false !== $endPos) { + $this->pasteBuffer .= substr($this->buffer, 0, $endPos); + $this->buffer = substr($this->buffer, $endPos + 6); + $this->inPaste = false; + + if (null !== $this->onPaste) { + ($this->onPaste)($this->pasteBuffer); + } + $this->pasteBuffer = ''; + } else { + // Still waiting for end marker + $this->pasteBuffer .= $this->buffer; + $this->buffer = ''; + } + continue; + } + + // Try to extract a complete sequence + $sequence = $this->extractSequence(); + + if (null === $sequence) { + // Buffer might contain incomplete sequence, wait for more data + break; + } + + if (null !== $this->onData) { + ($this->onData)($sequence); + } + } + } + + /** + * Get any remaining buffered data. + */ + public function getBuffer(): string + { + return $this->buffer; + } + + /** + * Clear the buffer. + */ + public function clear(): void + { + $this->buffer = ''; + $this->pasteBuffer = ''; + $this->inPaste = false; + } + + /** + * Flush any pending data in the buffer. + * + * This is used when no more input is expected (e.g., end of test input). + * A standalone ESC that was waiting for more characters will be emitted. + */ + public function flush(): void + { + // If we have a single ESC waiting, emit it as a standalone Escape key + if ("\x1b" === $this->buffer && null !== $this->onData) { + ($this->onData)("\x1b"); + $this->buffer = ''; + } + } + + /** + * Extract a complete sequence from the buffer. + */ + private function extractSequence(): ?string + { + if ('' === $this->buffer) { + return null; + } + + $first = $this->buffer[0]; + + // Regular printable ASCII character + if ("\x1b" !== $first) { + // Check for multi-byte UTF-8 + $ord = \ord($first); + if ($ord >= 0x80) { + $len = $this->getUtf8CharLength($ord); + if (\strlen($this->buffer) >= $len) { + $sequence = substr($this->buffer, 0, $len); + $this->buffer = substr($this->buffer, $len); + + return $sequence; + } + + // Incomplete UTF-8 sequence + return null; + } + + $this->buffer = substr($this->buffer, 1); + + return $first; + } + + // ESC sequence + if (\strlen($this->buffer) < 2) { + // Might be incomplete, or just ESC key + return null; + } + + $second = $this->buffer[1]; + + // ESC ESC (double escape) - need to look ahead to determine behavior + if ("\x1b" === $second) { + // Need at least 3 chars to decide + if (\strlen($this->buffer) < 3) { + return null; // Wait for more data + } + + $third = $this->buffer[2]; + + // If third char starts a CSI or SS3 sequence, emit first ESC and continue + // This handles: ESC ESC [ ... or ESC ESC O ... + if ('[' === $third || 'O' === $third) { + $this->buffer = substr($this->buffer, 1); + + return "\x1b"; + } + + // Otherwise it's a double-escape followed by something else + $this->buffer = substr($this->buffer, 2); + + return "\x1b\x1b"; + } + + // Sequence type dispatch based on second byte: + // CSI (ESC [), SS3 (ESC O), OSC (ESC ]), DCS (ESC P), APC (ESC _) + return match ($second) { + '[' => $this->extractCsiSequence(), + 'O' => $this->extractSs3Sequence(), + ']' => $this->extractOscSequence(), + 'P' => $this->extractDcsSequence(), + '_' => $this->extractApcSequence(), + default => $this->extractAltKey($second), + }; + } + + /** + * Extract Alt+key or Ctrl+Alt+key: ESC followed by any non-ESC byte that + * isn't a sequence initiator. This covers Alt+letter, + * Alt+Backspace (\x1b\x7f), Alt+Space (\x1b\x20), Alt+Enter + * (\x1b\r), Ctrl+Alt+] (\x1b\x1d), etc. + */ + private function extractAltKey(string $second): string + { + $this->buffer = substr($this->buffer, 2); + + return "\x1b".$second; + } + + /** + * Extract CSI sequence (ESC [ ... terminator). + */ + private function extractCsiSequence(): ?string + { + $len = \strlen($this->buffer); + + // Old-style mouse sequence: ESC [ M + 3 bytes + if ($len >= 3 && 'M' === $this->buffer[2]) { + if ($len < 6) { + return null; + } + + $sequence = substr($this->buffer, 0, 6); + $this->buffer = substr($this->buffer, 6); + + return $sequence; + } + + for ($i = 2; $i < $len; ++$i) { + $char = $this->buffer[$i]; + + // CSI terminators: @ through ~ + if ($char >= '@' && $char <= '~') { + $sequence = substr($this->buffer, 0, $i + 1); + $payload = substr($this->buffer, 2, $i - 1); + + // Special handling for SGR mouse sequences ESC[buffer = substr($this->buffer, $i + 1); + + return $sequence; + } + + // Invalid character in CSI sequence + if ($char < ' ' || $char > '?') { + // Malformed sequence, just return what we have + $this->buffer = substr($this->buffer, 1); + + return "\x1b"; + } + } + + // Incomplete sequence + return null; + } + + /** + * Extract SS3 sequence (ESC O letter). + */ + private function extractSs3Sequence(): ?string + { + if (\strlen($this->buffer) < 3) { + return null; + } + + $sequence = substr($this->buffer, 0, 3); + $this->buffer = substr($this->buffer, 3); + + return $sequence; + } + + /** + * Extract OSC sequence (ESC ] ... BEL or ESC ] ... ST). + */ + private function extractOscSequence(): ?string + { + $len = \strlen($this->buffer); + + for ($i = 2; $i < $len; ++$i) { + // BEL terminator + if ("\x07" === $this->buffer[$i]) { + $sequence = substr($this->buffer, 0, $i + 1); + $this->buffer = substr($this->buffer, $i + 1); + + return $sequence; + } + + // ST terminator (ESC \) + if ("\x1b" === $this->buffer[$i] && isset($this->buffer[$i + 1]) && '\\' === $this->buffer[$i + 1]) { + $sequence = substr($this->buffer, 0, $i + 2); + $this->buffer = substr($this->buffer, $i + 2); + + return $sequence; + } + } + + // Incomplete sequence + return null; + } + + /** + * Extract DCS sequence (ESC P ... ST). + */ + private function extractDcsSequence(): ?string + { + $len = \strlen($this->buffer); + + for ($i = 2; $i < $len; ++$i) { + if ("\x1b" === $this->buffer[$i] && isset($this->buffer[$i + 1]) && '\\' === $this->buffer[$i + 1]) { + $sequence = substr($this->buffer, 0, $i + 2); + $this->buffer = substr($this->buffer, $i + 2); + + return $sequence; + } + } + + return null; + } + + /** + * Extract APC sequence (ESC _ ... BEL or ESC _ ... ST). + */ + private function extractApcSequence(): ?string + { + return $this->extractOscSequence(); // Same terminator rules + } + + /** + * Get the expected length of a UTF-8 character from its first byte. + */ + private function getUtf8CharLength(int $ord): int + { + if ($ord < 0x80) { + return 1; + } + if ($ord < 0xC0) { + return 1; + } // Invalid, treat as single byte + if ($ord < 0xE0) { + return 2; + } + if ($ord < 0xF0) { + return 3; + } + if ($ord < 0xF8) { + return 4; + } + + return 1; // Invalid, treat as single byte + } +} diff --git a/src/Symfony/Component/Tui/LICENSE b/src/Symfony/Component/Tui/LICENSE new file mode 100644 index 0000000000000..36d6fdc38645d --- /dev/null +++ b/src/Symfony/Component/Tui/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2026-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/Tui/Loop/AdaptativeTicker.php b/src/Symfony/Component/Tui/Loop/AdaptativeTicker.php new file mode 100644 index 0000000000000..26fa0e1c93add --- /dev/null +++ b/src/Symfony/Component/Tui/Loop/AdaptativeTicker.php @@ -0,0 +1,115 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Loop; + +use Revolt\EventLoop; + +/** + * Drives the main TUI tick interval using adaptive scheduling. + * + * @experimental + * + * @author Fabien Potencier + */ +final class AdaptativeTicker +{ + private const float MIN_INTERVAL = 0.001; + + private ?string $callbackId = null; + private ?float $interval = null; + + public function __construct( + private readonly TickRuntimeInterface $runtime, + private readonly float $activeTickInterval = 0.01, + private readonly float $idleTickInterval = 0.25, + ) { + } + + public function refresh(bool $running, bool $renderRequested, ?float $nextScheduledDelay, bool $hasTickCallback, ?bool $lastTickBusyHint): void + { + $this->setInterval($this->computeDesiredInterval($running, $renderRequested, $nextScheduledDelay, $hasTickCallback, $lastTickBusyHint)); + } + + public function stop(): void + { + $this->setInterval(null); + } + + private function computeDesiredInterval(bool $running, bool $renderRequested, ?float $nextScheduledDelay, bool $hasTickCallback, ?bool $lastTickBusyHint): ?float + { + if (!$running) { + return null; + } + + $intervals = []; + + if ($renderRequested) { + $intervals[] = $this->activeTickInterval; + } + + if (null !== $nextScheduledDelay) { + $intervals[] = $nextScheduledDelay; + } + + if ($hasTickCallback) { + if (true === $lastTickBusyHint) { + $intervals[] = $this->activeTickInterval; + } elseif (null === $lastTickBusyHint) { + $intervals[] = $this->idleTickInterval; + } + } + + if ([] === $intervals) { + return null; + } + + return max(self::MIN_INTERVAL, min($intervals)); + } + + private function setInterval(?float $interval): void + { + if (null === $interval) { + if (null !== $this->callbackId) { + EventLoop::cancel($this->callbackId); + $this->callbackId = null; + } + $this->interval = null; + + return; + } + + if (null !== $this->interval && abs($this->interval - $interval) < 0.0001) { + return; + } + + if (null !== $this->callbackId) { + EventLoop::cancel($this->callbackId); + $this->callbackId = null; + } + + $this->interval = $interval; + $this->callbackId = EventLoop::repeat($interval, function (string $callbackId): void { + if (!$this->runtime->isRunning()) { + EventLoop::cancel($callbackId); + + if ($this->callbackId === $callbackId) { + $this->callbackId = null; + $this->interval = null; + } + + return; + } + + $this->runtime->tick(); + }); + } +} diff --git a/src/Symfony/Component/Tui/Loop/FixedStepAccumulator.php b/src/Symfony/Component/Tui/Loop/FixedStepAccumulator.php new file mode 100644 index 0000000000000..261cb4d150a19 --- /dev/null +++ b/src/Symfony/Component/Tui/Loop/FixedStepAccumulator.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Loop; + +use Symfony\Component\Tui\Exception\InvalidArgumentException; + +/** + * Converts elapsed time into bounded fixed-step counts. + * + * @experimental + * + * @author Fabien Potencier + */ +final class FixedStepAccumulator +{ + private float $accumulator = 0.0; + + public function __construct( + private float $stepsPerSecond, + private int $maxStepsPerUpdate = 5, + ) { + if ($stepsPerSecond <= 0.0) { + throw new InvalidArgumentException(\sprintf('Steps per second must be greater than 0, got %s.', $stepsPerSecond)); + } + + if ($maxStepsPerUpdate < 1) { + throw new InvalidArgumentException(\sprintf('Max steps per update must be greater than 0, got %d.', $maxStepsPerUpdate)); + } + } + + /** + * @return int Number of fixed logic steps to execute for this update + */ + public function computeSteps(?float $deltaTime): int + { + // Preserve legacy "one update call = one logic step" behavior. + if (null === $deltaTime) { + return 1; + } + + $this->accumulator += max(0.0, $deltaTime) * $this->stepsPerSecond; + $steps = min($this->maxStepsPerUpdate, (int) floor($this->accumulator)); + + if ($steps > 0) { + $this->accumulator -= $steps; + } + + return $steps; + } + + public function setStepsPerSecond(float $stepsPerSecond): void + { + if ($stepsPerSecond <= 0.0) { + throw new InvalidArgumentException(\sprintf('Steps per second must be greater than 0, got %s.', $stepsPerSecond)); + } + + $this->stepsPerSecond = $stepsPerSecond; + } + + public function reset(): void + { + $this->accumulator = 0.0; + } +} diff --git a/src/Symfony/Component/Tui/Loop/LoopClock.php b/src/Symfony/Component/Tui/Loop/LoopClock.php new file mode 100644 index 0000000000000..7cfd2cb6c0699 --- /dev/null +++ b/src/Symfony/Component/Tui/Loop/LoopClock.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Loop; + +/** + * Small monotonic-ish clock abstraction for game and animation loops. + * + * @experimental + * + * @author Fabien Potencier + */ +final class LoopClock +{ + private float $time; + + public function __construct( + ?float $time = null, + ) { + $this->time = $time ?? microtime(true); + } + + /** + * Advance clock state and return elapsed seconds since previous advance. + */ + public function advance(?float $deltaTime = null): float + { + if (null === $deltaTime) { + $now = microtime(true); + $elapsed = max(0.0, $now - $this->time); + $this->time = $now; + + return $elapsed; + } + + $elapsed = max(0.0, $deltaTime); + $this->time += $elapsed; + + return $elapsed; + } + + public function now(): float + { + return $this->time; + } + + public function reset(?float $time = null): void + { + $this->time = $time ?? microtime(true); + } +} diff --git a/src/Symfony/Component/Tui/Loop/PeriodicStepper.php b/src/Symfony/Component/Tui/Loop/PeriodicStepper.php new file mode 100644 index 0000000000000..fb723e771a304 --- /dev/null +++ b/src/Symfony/Component/Tui/Loop/PeriodicStepper.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Loop; + +use Symfony\Component\Tui\Exception\InvalidArgumentException; + +/** + * Converts elapsed time into periodic fixed-step counts. + * + * @experimental + * + * @author Fabien Potencier + */ +final class PeriodicStepper +{ + private FixedStepAccumulator $accumulator; + private LoopClock $clock; + + public function __construct( + private float $intervalSeconds, + int $maxStepsPerUpdate = 8, + ) { + if ($intervalSeconds <= 0.0) { + throw new InvalidArgumentException(\sprintf('Interval must be greater than 0, got %s.', $intervalSeconds)); + } + + $this->accumulator = new FixedStepAccumulator(1.0 / $intervalSeconds, $maxStepsPerUpdate); + $this->clock = new LoopClock(); + } + + public static function everyMs(int $intervalMs, int $maxStepsPerUpdate = 8): self + { + if ($intervalMs <= 0) { + throw new InvalidArgumentException(\sprintf('Interval must be greater than 0, got %d.', $intervalMs)); + } + + return new self($intervalMs / 1000, $maxStepsPerUpdate); + } + + public function advance(?float $deltaTime = null): int + { + return $this->accumulator->computeSteps($this->clock->advance($deltaTime)); + } + + public function reset(): void + { + $this->accumulator->reset(); + $this->clock->reset(); + } + + public function setIntervalSeconds(float $intervalSeconds): void + { + if ($intervalSeconds <= 0.0) { + throw new InvalidArgumentException(\sprintf('Interval must be greater than 0, got %s.', $intervalSeconds)); + } + + $this->intervalSeconds = $intervalSeconds; + $this->accumulator->setStepsPerSecond(1.0 / $intervalSeconds); + $this->reset(); + } + + public function setIntervalMs(int $intervalMs): void + { + if ($intervalMs <= 0) { + throw new InvalidArgumentException(\sprintf('Interval must be greater than 0, got %d.', $intervalMs)); + } + + $this->setIntervalSeconds($intervalMs / 1000); + } + + public function getIntervalSeconds(): float + { + return $this->intervalSeconds; + } +} diff --git a/src/Symfony/Component/Tui/Loop/TickRuntimeInterface.php b/src/Symfony/Component/Tui/Loop/TickRuntimeInterface.php new file mode 100644 index 0000000000000..56901f423b6aa --- /dev/null +++ b/src/Symfony/Component/Tui/Loop/TickRuntimeInterface.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Loop; + +/** + * Runtime contract used by the adaptive ticker driver. + * + * @experimental + * + * @author Fabien Potencier + */ +interface TickRuntimeInterface +{ + public function tick(): void; + + public function isRunning(): bool; +} diff --git a/src/Symfony/Component/Tui/Loop/TickScheduler.php b/src/Symfony/Component/Tui/Loop/TickScheduler.php new file mode 100644 index 0000000000000..00530b16b090f --- /dev/null +++ b/src/Symfony/Component/Tui/Loop/TickScheduler.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Loop; + +use Symfony\Component\Tui\Exception\InvalidArgumentException; + +/** + * Internal scheduler for repeat callbacks executed from the TUI tick. + * + * @experimental + * + * @author Fabien Potencier + */ +final class TickScheduler +{ + private int $counter = 0; + + /** + * @var array + */ + private array $intervals = []; + + /** + * @param callable(): void $callback + */ + public function schedule(callable $callback, float $intervalSeconds): string + { + if ($intervalSeconds <= 0) { + throw new InvalidArgumentException(\sprintf('Interval must be greater than 0, got %s.', $intervalSeconds)); + } + + $id = 'interval-'.(++$this->counter); + $this->intervals[$id] = [ + 'callback' => $callback, + 'interval' => $intervalSeconds, + 'nextRunAt' => microtime(true) + $intervalSeconds, + ]; + + return $id; + } + + public function cancel(string $id): void + { + unset($this->intervals[$id]); + } + + public function clear(): void + { + $this->intervals = []; + } + + public function runDue(?float $now = null): void + { + if ([] === $this->intervals) { + return; + } + + $now ??= microtime(true); + $intervals = $this->intervals; + + foreach ($intervals as $id => $interval) { + if (!isset($this->intervals[$id])) { + continue; + } + + if ($interval['nextRunAt'] > $now) { + continue; + } + + $this->intervals[$id]['nextRunAt'] = $now + $interval['interval']; + ($interval['callback'])(); + } + } + + public function getNextDelay(?float $now = null): ?float + { + if ([] === $this->intervals) { + return null; + } + + $now ??= microtime(true); + $nextAt = null; + + foreach ($this->intervals as $interval) { + $nextAt = null === $nextAt ? $interval['nextRunAt'] : min($nextAt, $interval['nextRunAt']); + } + + return max(0.001, $nextAt - $now); + } +} diff --git a/src/Symfony/Component/Tui/README.md b/src/Symfony/Component/Tui/README.md new file mode 100644 index 0000000000000..b98e5b5f0c470 --- /dev/null +++ b/src/Symfony/Component/Tui/README.md @@ -0,0 +1,19 @@ +TUI Component +============= + +The TUI component provides a terminal UI framework for building rich, +interactive CLI applications in PHP. + +**This Component is experimental**. +[Experimental features](https://symfony.com/doc/current/contributing/code/experimental.html) +are not covered by Symfony's +[Backward Compatibility Promise](https://symfony.com/doc/current/contributing/code/bc.html). + +Resources +--------- + + * [Documentation](https://symfony.com/doc/current/tui.html) + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Tui/Render/CellBuffer.php b/src/Symfony/Component/Tui/Render/CellBuffer.php new file mode 100644 index 0000000000000..304317b567983 --- /dev/null +++ b/src/Symfony/Component/Tui/Render/CellBuffer.php @@ -0,0 +1,569 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Render; + +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Exception\InvalidArgumentException; + +/** + * A 2D grid of terminal cells for efficient compositing and rendering. + * + * Uses flat parallel arrays (not objects) for memory efficiency. + * Each cell stores: character (grapheme), display width, foreground color, + * background color, and text attributes (bold, italic, etc.). + * + * ## Usage + * + * Create a buffer, write ANSI-styled lines into regions, then serialize + * back to ANSI strings. + * + * @experimental + * + * @author Fabien Potencier + */ +final class CellBuffer +{ + // Attribute bitmask constants + public const ATTR_BOLD = 1; + public const ATTR_DIM = 2; + public const ATTR_ITALIC = 4; + public const ATTR_UNDERLINE = 8; + public const ATTR_BLINK = 16; + public const ATTR_REVERSE = 32; + public const ATTR_STRIKETHROUGH = 64; + + /** + * Flat arrays indexed by (row * width + col). + * + * @var string[] Character/grapheme at each cell + */ + private array $chars; + + /** @var int[] Display width of each cell (1 for normal, 2 for CJK, 0 for continuation) */ + private array $widths; + + /** @var string[] Foreground color code (e.g., "38;2;255;0;0") or "" for default */ + private array $fg; + + /** @var string[] Background color code (e.g., "48;2;30;30;46") or "" for default */ + private array $bg; + + /** @var int[] Attribute bitmask (bold|dim|italic|underline|blink|reverse|strikethrough) */ + private array $attrs; + + /* Row of the cursor marker, or null if not found */ + private ?int $cursorRow = null; + + /* Column (cell index) of the cursor marker, or null if not found */ + private ?int $cursorCol = null; + + public function __construct( + private readonly int $width, + private readonly int $height, + ) { + if ($width < 1 || $height < 1) { + throw new InvalidArgumentException(\sprintf('CellBuffer dimensions must be at least 1x1, got %dx%d', $width, $height)); + } + + $size = $width * $height; + $this->chars = array_fill(0, $size, ' '); + $this->widths = array_fill(0, $size, 1); + $this->fg = array_fill(0, $size, ''); + $this->bg = array_fill(0, $size, ''); + $this->attrs = array_fill(0, $size, 0); + } + + public function getWidth(): int + { + return $this->width; + } + + public function getHeight(): int + { + return $this->height; + } + + /** + * Write ANSI-formatted lines into the buffer at the given position. + * + * Lines are parsed: ANSI escape codes are interpreted and stored as + * cell attributes; visible characters are placed into the grid. + * + * @param string[] $lines ANSI-formatted lines + * @param int $startRow Row offset to start writing + * @param int $startCol Column offset to start writing + * @param bool $transparent When true, cells with no explicit background preserve + * the existing buffer background (transparency). Cells that + * are plain spaces with default fg/bg/attrs are fully transparent + * and leave the buffer cell entirely unchanged. + */ + public function writeAnsiLines(array $lines, int $startRow = 0, int $startCol = 0, bool $transparent = false): void + { + $width = $this->width; + $height = $this->height; + $startCol = max(0, $startCol); + + foreach ($lines as $lineIndex => $line) { + $row = $startRow + $lineIndex; + if ($row < 0 || $row >= $height) { + continue; + } + + // Reset SGR state at the start of each line. + // Widget render methods produce independent lines, each with their + // own SGR codes; state must not leak between lines. + $fgState = ''; + $bgState = ''; + $attrState = 0; + + $col = $startCol; + $i = 0; + $len = \strlen($line); + $rowOffset = $row * $width; + + while ($i < $len && $col < $width) { + $ord = \ord($line[$i]); + + // Fast path: ASCII printable (0x20-0x7E), most common case + if ($ord >= 0x20 && $ord <= 0x7E) { + // In transparent mode, skip fully unstyled spaces (fully transparent cell) + if ($transparent && ' ' === $line[$i] && '' === $fgState && '' === $bgState && 0 === $attrState) { + ++$col; + ++$i; + continue; + } + $idx = $rowOffset + $col; + $this->chars[$idx] = $line[$i]; + $this->widths[$idx] = 1; + $this->fg[$idx] = $fgState; + $this->bg[$idx] = $transparent && '' === $bgState ? $this->bg[$idx] : $bgState; + $this->attrs[$idx] = $attrState; + ++$col; + ++$i; + continue; + } + + // Escape sequence + if (0x1B === $ord) { + // Inline escape sequence parsing (avoids AnsiUtils::extractAnsiCode overhead) + $next = $line[$i + 1] ?? ''; + + if ('[' === $next) { + // CSI sequence: ESC [ * * + $j = $i + 2; + while ($j < $len && \ord($line[$j]) >= 0x30 && \ord($line[$j]) <= 0x3F) { + ++$j; + } + while ($j < $len && \ord($line[$j]) >= 0x20 && \ord($line[$j]) <= 0x2F) { + ++$j; + } + if ($j >= $len || \ord($line[$j]) < 0x40 || \ord($line[$j]) > 0x7E) { + // Malformed CSI, skip ESC and [ entirely + $i = $j; + continue; + } + $seqEnd = $j + 1; + // Only parse SGR (ends with 'm') + if ('m' === $line[$j]) { + $this->parseSgrInline($line, $i + 2, $j, $fgState, $bgState, $attrState); + } + $i = $seqEnd; + continue; + } + + if ('_' === $next) { + // APC sequence: ESC _ ... BEL or ESC _ ... ST + $j = $i + 2; + $apcEnd = null; + while ($j < $len) { + if ("\x07" === $line[$j]) { + $apcEnd = $j + 1; + break; + } + if ("\x1b" === $line[$j] && isset($line[$j + 1]) && '\\' === $line[$j + 1]) { + $apcEnd = $j + 2; + break; + } + ++$j; + } + if (null === $apcEnd) { + ++$i; + continue; + } + // Check for cursor marker: ESC _ p i : c + if ($i + 5 < $len && 'p' === $line[$i + 2] && 'i' === $line[$i + 3] && ':' === $line[$i + 4] && 'c' === $line[$i + 5]) { + $this->cursorRow = $row; + $this->cursorCol = $col; + } + $i = $apcEnd; + continue; + } + + // String sequences: OSC (ESC ]), DCS (ESC P), PM (ESC ^), SOS (ESC X) + if (']' === $next || 'P' === $next || '^' === $next || 'X' === $next) { + $j = $i + 2; + while ($j < $len) { + if ("\x07" === $line[$j]) { + $i = $j + 1; + break; + } + if ("\x1b" === $line[$j] && isset($line[$j + 1]) && '\\' === $line[$j + 1]) { + $i = $j + 2; + break; + } + ++$j; + } + if ($j >= $len) { + ++$i; + } + continue; + } + + if ('' === $next) { + ++$i; + continue; + } + + $nextOrd = \ord($next); + + // nF announced sequences: ESC + intermediate bytes (0x20-0x2F)+ + final byte (0x30-0x7E) + if ($nextOrd >= 0x20 && $nextOrd <= 0x2F) { + $j = $i + 2; + while ($j < $len && \ord($line[$j]) >= 0x20 && \ord($line[$j]) <= 0x2F) { + ++$j; + } + if ($j < $len && \ord($line[$j]) >= 0x30 && \ord($line[$j]) <= 0x7E) { + $i = $j + 1; + } else { + ++$i; + } + continue; + } + + // Fe (0x40-0x5F), Fp (0x30-0x3F), Fs (0x60-0x7E) two-byte sequences + if ($nextOrd >= 0x30 && $nextOrd <= 0x7E) { + $i += 2; + continue; + } + + // Unknown escape, skip ESC byte + ++$i; + continue; + } + + // Tab + if (0x09 === $ord) { + $spaces = 3; // Match AnsiUtils tab width + for ($s = 0; $s < $spaces && $col < $width; ++$s) { + if ($transparent && '' === $fgState && '' === $bgState && 0 === $attrState) { + ++$col; + continue; + } + $idx = $rowOffset + $col; + $this->chars[$idx] = ' '; + $this->widths[$idx] = 1; + $this->fg[$idx] = $fgState; + $this->bg[$idx] = $transparent && '' === $bgState ? $this->bg[$idx] : $bgState; + $this->attrs[$idx] = $attrState; + ++$col; + } + ++$i; + continue; + } + + // Other control characters, skip + if ($ord < 0x20) { + ++$i; + continue; + } + + // Multi-byte / Unicode: use grapheme_extract for correctness + $grapheme = grapheme_extract($line, 1, \GRAPHEME_EXTR_COUNT, $i, $nextPos); + if (false === $grapheme || '' === $grapheme) { + ++$i; + continue; + } + + // Calculate display width + $charWidth = AnsiUtils::graphemeWidth($grapheme); + + // Check if it fits + if ($col + $charWidth > $width) { + while ($col < $width) { + $idx = $rowOffset + $col; + $this->chars[$idx] = ' '; + $this->widths[$idx] = 1; + $this->fg[$idx] = $fgState; + $this->bg[$idx] = $transparent && '' === $bgState ? $this->bg[$idx] : $bgState; + $this->attrs[$idx] = $attrState; + ++$col; + } + $i = $nextPos; + continue; + } + + // Place the character + $idx = $rowOffset + $col; + $this->chars[$idx] = $grapheme; + $this->widths[$idx] = $charWidth; + $this->fg[$idx] = $fgState; + $this->bg[$idx] = $transparent && '' === $bgState ? $this->bg[$idx] : $bgState; + $this->attrs[$idx] = $attrState; + + // For wide characters, mark continuation cell(s) + for ($w = 1; $w < $charWidth; ++$w) { + if ($col + $w < $width) { + $contIdx = $rowOffset + $col + $w; + $this->chars[$contIdx] = ''; + $this->widths[$contIdx] = 0; + $this->fg[$contIdx] = $fgState; + $this->bg[$contIdx] = $transparent && '' === $bgState ? $this->bg[$contIdx] : $bgState; + $this->attrs[$contIdx] = $attrState; + } + } + + $col += $charWidth; + $i = $nextPos; + } + } + } + + /** + * Get the cursor position found during parsing, if any. + * + * @return array{row: int, col: int}|null + */ + public function getCursorPosition(): ?array + { + if (null === $this->cursorRow || null === $this->cursorCol) { + return null; + } + + return ['row' => $this->cursorRow, 'col' => $this->cursorCol]; + } + + /** + * Clear the cursor position. + */ + public function clearCursorPosition(): void + { + $this->cursorRow = null; + $this->cursorCol = null; + } + + /** + * Serialize the buffer back to ANSI-formatted strings. + * + * Produces optimized output: only emits SGR changes when the style + * actually changes between cells. + * + * @return string[] + */ + public function toLines(): array + { + $lines = []; + $width = $this->width; + $chars = $this->chars; + $widths = $this->widths; + $fg = $this->fg; + $bg = $this->bg; + $attrs = $this->attrs; + + for ($row = 0; $row < $this->height; ++$row) { + $line = ''; + $currentFg = ''; + $currentBg = ''; + $currentAttrs = 0; + $rowOffset = $row * $width; + + for ($col = 0; $col < $width; ++$col) { + $idx = $rowOffset + $col; + + // Skip continuation cells (part of a wide character) + if (0 === $widths[$idx]) { + continue; + } + + $cellFg = $fg[$idx]; + $cellBg = $bg[$idx]; + $cellAttrs = $attrs[$idx]; + + // Emit SGR change if needed + if ($cellFg !== $currentFg || $cellBg !== $currentBg || $cellAttrs !== $currentAttrs) { + $line .= $this->buildSgr($cellFg, $cellBg, $cellAttrs); + $currentFg = $cellFg; + $currentBg = $cellBg; + $currentAttrs = $cellAttrs; + } + + $line .= $chars[$idx]; + } + + // Reset at end of line + if ('' !== $currentFg || '' !== $currentBg || 0 !== $currentAttrs) { + $line .= "\x1b[0m"; + } + + $lines[] = $line; + } + + return $lines; + } + + /** + * Build an SGR escape sequence from cell attributes. + * + * Always emits a full reset + set to avoid state accumulation issues. + */ + private function buildSgr(string $fg, string $bg, int $attrs): string + { + // Fast path: reset to default (no style) + if ('' === $fg && '' === $bg && 0 === $attrs) { + return "\x1b[0m"; + } + + $sgr = "\x1b[0"; + + if ($attrs & self::ATTR_BOLD) { + $sgr .= ';1'; + } + if ($attrs & self::ATTR_DIM) { + $sgr .= ';2'; + } + if ($attrs & self::ATTR_ITALIC) { + $sgr .= ';3'; + } + if ($attrs & self::ATTR_UNDERLINE) { + $sgr .= ';4'; + } + if ($attrs & self::ATTR_BLINK) { + $sgr .= ';5'; + } + if ($attrs & self::ATTR_REVERSE) { + $sgr .= ';7'; + } + if ($attrs & self::ATTR_STRIKETHROUGH) { + $sgr .= ';9'; + } + if ('' !== $fg) { + $sgr .= ';'.$fg; + } + if ('' !== $bg) { + $sgr .= ';'.$bg; + } + + return $sgr.'m'; + } + + /** + * Parse SGR parameters directly from the string (avoids regex, explode, array_map). + * + * @param string $line The full line string + * @param int $start Start of parameter chars (after "\x1b[") + * @param int $end Position of the 'm' terminator + */ + private function parseSgrInline(string $line, int $start, int $end, string &$fg, string &$bg, int &$attrs): void + { + // Fast path: \x1b[0m or \x1b[m, pure reset + if ($start === $end || (1 === $end - $start && '0' === $line[$start])) { + $fg = ''; + $bg = ''; + $attrs = 0; + + return; + } + + // Parse semicolon-delimited integers directly from the string + $num = 0; + $hasNum = false; + /** @var int[] $codes */ + $codes = []; + + for ($p = $start; $p <= $end; ++$p) { + $ch = $line[$p] ?? 'm'; + if ($ch >= '0' && $ch <= '9') { + $num = $num * 10 + \ord($ch) - 48; + $hasNum = true; + } elseif (';' === $ch || 'm' === $ch) { + $codes[] = $hasNum ? $num : 0; + $num = 0; + $hasNum = false; + } + } + + $i = 0; + $count = \count($codes); + + while ($i < $count) { + $c = $codes[$i]; + + if (0 === $c) { + $fg = ''; + $bg = ''; + $attrs = 0; + } elseif ($c >= 1 && $c <= 9) { + // Attributes + $attrs |= match ($c) { + 1 => self::ATTR_BOLD, + 2 => self::ATTR_DIM, + 3 => self::ATTR_ITALIC, + 4 => self::ATTR_UNDERLINE, + 5 => self::ATTR_BLINK, + 7 => self::ATTR_REVERSE, + 9 => self::ATTR_STRIKETHROUGH, + default => 0, + }; + } elseif ($c >= 22 && $c <= 29) { + // Attribute off + $attrs &= match ($c) { + 22 => ~(self::ATTR_BOLD | self::ATTR_DIM), + 23 => ~self::ATTR_ITALIC, + 24 => ~self::ATTR_UNDERLINE, + 25 => ~self::ATTR_BLINK, + 27 => ~self::ATTR_REVERSE, + 29 => ~self::ATTR_STRIKETHROUGH, + default => ~0, + }; + } elseif ($c >= 30 && $c <= 37) { + $fg = (string) $c; + } elseif (39 === $c) { + $fg = ''; + } elseif ($c >= 40 && $c <= 47) { + $bg = (string) $c; + } elseif (49 === $c) { + $bg = ''; + } elseif ($c >= 90 && $c <= 97) { + $fg = (string) $c; + } elseif ($c >= 100 && $c <= 107) { + $bg = (string) $c; + } elseif (38 === $c && $i + 1 < $count) { + if (5 === $codes[$i + 1] && $i + 2 < $count) { + $fg = '38;5;'.$codes[$i + 2]; + $i += 2; + } elseif (2 === $codes[$i + 1] && $i + 4 < $count) { + $fg = '38;2;'.$codes[$i + 2].';'.$codes[$i + 3].';'.$codes[$i + 4]; + $i += 4; + } + } elseif (48 === $c && $i + 1 < $count) { + if (5 === $codes[$i + 1] && $i + 2 < $count) { + $bg = '48;5;'.$codes[$i + 2]; + $i += 2; + } elseif (2 === $codes[$i + 1] && $i + 4 < $count) { + $bg = '48;2;'.$codes[$i + 2].';'.$codes[$i + 3].';'.$codes[$i + 4]; + $i += 4; + } + } + + ++$i; + } + } +} diff --git a/src/Symfony/Component/Tui/Render/ChromeApplier.php b/src/Symfony/Component/Tui/Render/ChromeApplier.php new file mode 100644 index 0000000000000..7de261c72fad8 --- /dev/null +++ b/src/Symfony/Component/Tui/Render/ChromeApplier.php @@ -0,0 +1,225 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Render; + +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Style\Style; +use Symfony\Component\Tui\Style\TextAlign; +use Symfony\Component\Tui\Widget\AbstractWidget; + +/** + * Applies chrome (padding, border, background) around widget content. + * + * Chrome is the visual frame around a widget's rendered lines: + * padding adds space inside, borders draw a box, and background colors + * fill the area. The result is cached for performance. + * + * @experimental + * + * @author Fabien Potencier + */ +final class ChromeApplier +{ + private WidgetRendererInterface $widgetRenderer; + + public function setWidgetRenderer(WidgetRendererInterface $widgetRenderer): void + { + $this->widgetRenderer = $widgetRenderer; + } + + /** + * Apply chrome (padding, border, background) to rendered lines. + * + * @param string[] $lines + * + * @return string[] + */ + public function apply(array $lines, int $width, Style $style, AbstractWidget $widget): array + { + $border = $style->getBorder(); + $padding = $style->getPadding(); + + $borderLeft = null !== $border ? $border->getLeft() : 0; + $borderRight = null !== $border ? $border->getRight() : 0; + $borderTop = null !== $border ? $border->getTop() : 0; + $borderBottom = null !== $border ? $border->getBottom() : 0; + $paddingLeft = null !== $padding ? $padding->getLeft() : 0; + $paddingRight = null !== $padding ? $padding->getRight() : 0; + $paddingTop = null !== $padding ? $padding->getTop() : 0; + $paddingBottom = null !== $padding ? $padding->getBottom() : 0; + + $hasVerticalPadding = 0 !== $paddingTop || 0 !== $paddingBottom; + $hasHorizontalPadding = 0 !== $paddingLeft || 0 !== $paddingRight; + $hasBorder = 0 !== $borderTop || 0 !== $borderBottom || 0 !== $borderLeft || 0 !== $borderRight; + + if (!$hasBorder && !$hasHorizontalPadding && !$hasVerticalPadding && $style->isPlain() && null === $style->getTextAlign()) { + return $lines; + } + + if ([] === $lines && !$hasVerticalPadding && 0 === $borderTop && 0 === $borderBottom) { + return []; + } + + $outerStyle = $this->resolveOuterStyle($widget); + + $innerWidth = max(1, $width - $borderLeft - $borderRight); + + // Clamp padding so it fits within the inner width, preserving + // at least 1 column for content. Without this, excessive padding + // (e.g. padding=50 in a 10-column container) would overflow. + $maxHorizontalPadding = max(0, $innerWidth - 1); + if ($paddingLeft + $paddingRight > $maxHorizontalPadding) { + $paddingLeft = min($paddingLeft, $maxHorizontalPadding); + $paddingRight = min($paddingRight, max(0, $maxHorizontalPadding - $paddingLeft)); + } + + $contentWidth = max(1, $innerWidth - $paddingLeft - $paddingRight); + + $processedLines = []; + foreach ($lines as $line) { + $processedLines[] = AnsiUtils::truncateToWidth($line, $contentWidth); + } + + // If no content and no padding/border, return empty + if ([] === $processedLines && 0 === $paddingTop && 0 === $paddingBottom + && 0 === $borderTop && 0 === $borderBottom) { + return []; + } + + $styledEmptyLine = $style->apply(str_repeat(' ', $innerWidth)); + $topPadding = $paddingTop > 0 ? array_fill(0, $paddingTop, $styledEmptyLine) : []; + $bottomPadding = $paddingBottom > 0 ? array_fill(0, $paddingBottom, $styledEmptyLine) : []; + $textAlign = $style->getTextAlign() ?? TextAlign::Left; + + // For center/right alignment, compute offset from the widest line + // so all lines shift uniformly (preserving internal alignment of + // multi-line content like FIGlet). + $alignPadLeft = 0; + if (TextAlign::Left !== $textAlign) { + $maxContentWidth = 0; + foreach ($processedLines as $line) { + $maxContentWidth = max($maxContentWidth, AnsiUtils::visibleWidth($line)); + } + $availableSpace = max(0, $contentWidth - $maxContentWidth); + $alignPadLeft = match ($textAlign) { + TextAlign::Center => (int) floor($availableSpace / 2), + TextAlign::Right => $availableSpace, + }; + } + + $contentLines = []; + foreach ($processedLines as $line) { + $lineWithPad = str_repeat(' ', $paddingLeft + $alignPadLeft).$line; + $visibleWidth = AnsiUtils::visibleWidth($lineWithPad); + $rightPad = str_repeat(' ', max(0, $innerWidth - $visibleWidth)); + $contentLines[] = $style->apply($lineWithPad.$rightPad); + } + + $innerLines = [...$topPadding, ...$contentLines, ...$bottomPadding]; + + if (null !== $border) { + $innerLines = $border->wrapLines( + $innerLines, + $innerWidth, + $style, + $outerStyle, + ); + } + + return $innerLines; + } + + /** + * Compute inner dimensions (content area after border/padding). + * + * @return array{int, int} [innerColumns, innerRows] + */ + public function computeInnerDimensions(int $columns, int $rows, Style $style): array + { + $border = $style->getBorder(); + $padding = $style->getPadding(); + + $hChrome = (null !== $border ? $border->getLeft() + $border->getRight() : 0) + + (null !== $padding ? $padding->getLeft() + $padding->getRight() : 0); + $vChrome = (null !== $border ? $border->getTop() + $border->getBottom() : 0) + + (null !== $padding ? $padding->getTop() + $padding->getBottom() : 0); + + return [ + max(1, $columns - $hChrome), + max(1, $rows - $vChrome), + ]; + } + + /** + * Compute the top-left chrome offset (border + padding) for a style. + * + * @return array{int, int} [topOffset, leftOffset] + */ + public function computeChromeOffset(Style $style): array + { + $border = $style->getBorder(); + $padding = $style->getPadding(); + + $top = (null !== $border ? $border->getTop() : 0) + (null !== $padding ? $padding->getTop() : 0); + $left = (null !== $border ? $border->getLeft() : 0) + (null !== $padding ? $padding->getLeft() : 0); + + return [$top, $left]; + } + + /** + * Compute a RenderContext with inner dimensions (content area after border/padding). + * + * Widgets receive this context so they render into the content area without + * needing to account for their own chrome. + */ + public function computeInnerContext(RenderContext $context, Style $style): RenderContext + { + [$innerColumns, $innerRows] = $this->computeInnerDimensions($context->getColumns(), $context->getRows(), $style); + + // Strip layout properties from the style so leaf widgets only see + // visual formatting (color, bold, etc.). The Renderer owns layout + // (padding, border, gap, direction, hidden, cursorShape, textAlign, align, verticalAlign); widgets own content. + return new RenderContext($innerColumns, $innerRows, $context->getStyle()->withoutLayoutProperties(), $context->getFontRegistry()); + } + + /** + * Resolve the outer style for a widget by accumulating resolved + * ancestor styles from root to immediate parent. + * + * This ensures that visual properties (color, background) set on + * a grandparent propagate through intermediate containers that + * don't override them. + */ + private function resolveOuterStyle(AbstractWidget $widget): ?Style + { + // Collect ancestors from immediate parent to root + $ancestors = []; + $parent = $widget->getParent(); + while (null !== $parent) { + $ancestors[] = $parent; + $parent = $parent->getParent(); + } + + if ([] === $ancestors) { + return null; + } + + // Resolve each ancestor's style from root (last) to immediate + // parent (first) so closer ancestors override more distant ones + $resolvedStyles = []; + for ($i = \count($ancestors) - 1; $i >= 0; --$i) { + $resolvedStyles[] = $this->widgetRenderer->resolveStyle($ancestors[$i]); + } + + return Style::mergeAll($resolvedStyles); + } +} diff --git a/src/Symfony/Component/Tui/Render/Compositor.php b/src/Symfony/Component/Tui/Render/Compositor.php new file mode 100644 index 0000000000000..86e429913f98d --- /dev/null +++ b/src/Symfony/Component/Tui/Render/Compositor.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Render; + +use Symfony\Component\Tui\Ansi\AnsiUtils; + +/** + * Composites multiple layers into a single set of ANSI-formatted lines. + * + * Layers are applied in order: layer 0 is the base (typically opaque), + * subsequent layers are painted on top. Transparent layers let the + * content below show through where no explicit background is set. + * + * The canvas dimensions are derived from the first (base) layer: + * height is the number of lines, width is the visible width of the + * first line. + * + * Usage: + * + * $lines = Compositor::composite( + * new Layer($backgroundLines), + * new Layer($foregroundLines, transparent: true), + * ); + * + * @experimental + * + * @author Fabien Potencier + */ +final class Compositor +{ + /** + * Composite multiple layers into ANSI-formatted output lines. + * + * The first layer defines the canvas dimensions. + * + * @return string[] + */ + public static function composite(Layer ...$layers): array + { + if ([] === $layers) { + return []; + } + + $base = $layers[0]; + $height = $base->getHeight() ?? \count($base->getLines()); + $width = $base->getWidth() ?? ([] === $base->getLines() ? 0 : AnsiUtils::visibleWidth($base->getLines()[0])); + + $buffer = new CellBuffer($width, $height); + + foreach ($layers as $layer) { + $buffer->writeAnsiLines( + $layer->getLines(), + $layer->getRow(), + $layer->getCol(), + $layer->isTransparent(), + ); + } + + return $buffer->toLines(); + } +} diff --git a/src/Symfony/Component/Tui/Render/Layer.php b/src/Symfony/Component/Tui/Render/Layer.php new file mode 100644 index 0000000000000..1769d45ac0d98 --- /dev/null +++ b/src/Symfony/Component/Tui/Render/Layer.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Render; + +/** + * A compositing layer: content lines at a position, optionally transparent. + * + * When transparent, cells with no explicit background preserve the + * background from the layer below. Fully unstyled spaces are completely + * transparent (the entire cell below shows through). + * + * @experimental + * + * @author Fabien Potencier + */ +final class Layer +{ + /** + * @param string[] $lines ANSI-formatted content lines + * @param int $row Vertical offset in the composite + * @param int $col Horizontal offset in the composite + * @param bool $transparent Whether cells with default background inherit from layers below + * @param int|null $width Explicit canvas width (used by the base layer to define the canvas size) + * @param int|null $height Explicit canvas height (used by the base layer to define the canvas size) + */ + public function __construct( + private readonly array $lines, + private readonly int $row = 0, + private readonly int $col = 0, + private readonly bool $transparent = false, + private readonly ?int $width = null, + private readonly ?int $height = null, + ) { + } + + /** + * @return string[] + */ + public function getLines(): array + { + return $this->lines; + } + + public function getRow(): int + { + return $this->row; + } + + public function getCol(): int + { + return $this->col; + } + + public function isTransparent(): bool + { + return $this->transparent; + } + + public function getWidth(): ?int + { + return $this->width; + } + + public function getHeight(): ?int + { + return $this->height; + } +} diff --git a/src/Symfony/Component/Tui/Render/LayoutEngine.php b/src/Symfony/Component/Tui/Render/LayoutEngine.php new file mode 100644 index 0000000000000..c602d941164f2 --- /dev/null +++ b/src/Symfony/Component/Tui/Render/LayoutEngine.php @@ -0,0 +1,476 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Render; + +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Style\Align; +use Symfony\Component\Tui\Style\Direction; +use Symfony\Component\Tui\Style\VerticalAlign; +use Symfony\Component\Tui\Widget\AbstractWidget; +use Symfony\Component\Tui\Widget\Figlet\FontRegistry; +use Symfony\Component\Tui\Widget\ParentInterface; +use Symfony\Component\Tui\Widget\VerticallyExpandableInterface; + +/** + * Lays out children vertically or horizontally with gap, fill, and alignment. + * + * The layout engine distributes available space among children, handles + * fill-expanding children, and applies horizontal/vertical alignment. + * + * @experimental + * + * @author Fabien Potencier + */ +final class LayoutEngine +{ + private WidgetRendererInterface $widgetRenderer; + + public function __construct( + private readonly PositionTracker $positionTracker, + private readonly FontRegistry $fontRegistry, + ) { + } + + public function setWidgetRenderer(WidgetRendererInterface $widgetRenderer): void + { + $this->widgetRenderer = $widgetRenderer; + } + + /** + * Layout children based on direction. + * + * @param AbstractWidget[] $children + * + * @return string[] + */ + public function layout( + array $children, + int $columns, + int $rows, + int $gap, + Direction $direction, + ?string $gapLine = null, + ): array { + if (Direction::Horizontal === $direction) { + return $this->layoutHorizontal($children, $columns, $rows, $gap); + } + + return $this->layoutVertical($children, $columns, $rows, $gap, $gapLine); + } + + /** + * Compute the horizontal offset needed to align content within the available width. + * + * @param string[] $lines + */ + public function computeAlignOffset(array $lines, int $columns, Align $align): int + { + if ([] === $lines) { + return 0; + } + + $maxWidth = 0; + foreach ($lines as $line) { + $maxWidth = max($maxWidth, AnsiUtils::visibleWidth($line)); + } + + $availableSpace = max(0, $columns - $maxWidth); + + return match ($align) { + Align::Center => (int) floor($availableSpace / 2), + Align::Right => $availableSpace, + Align::Left => 0, + }; + } + + /** + * Compute the vertical offset (number of top-padding rows) for alignment. + */ + public function computeVerticalAlignOffset(int $contentRows, int $availableRows, VerticalAlign $verticalAlign): int + { + $space = max(0, $availableRows - $contentRows); + + return match ($verticalAlign) { + VerticalAlign::Top => 0, + VerticalAlign::Center => (int) floor($space / 2), + VerticalAlign::Bottom => $space, + }; + } + + /** + * Shift all lines by prepending spaces. + * + * @param string[] $lines + * + * @return string[] + */ + public function shiftLines(array $lines, int $offset): array + { + $prefix = str_repeat(' ', $offset); + $result = []; + foreach ($lines as $line) { + $result[] = $prefix.$line; + } + + return $result; + } + + /** + * Layout children vertically with gap and fill support. + * + * @param AbstractWidget[] $children + * + * @return string[] + */ + private function layoutVertical(array $children, int $columns, int $rows, int $gap, ?string $gapLine = null): array + { + if ([] === $children) { + return []; + } + + $lines = []; + $gapLine ??= str_repeat(' ', max(1, $columns)); + $gapLines = $gap > 0 ? array_fill(0, $gap, $gapLine) : []; + $first = true; + + // Calculate total gap rows + $totalGapRows = $gap * max(0, \count($children) - 1); + $remainingRows = $rows - $totalGapRows; + + // First pass: identify fill children and measure non-fill children. + // During this pass, suppress position tracking for descendants since + // we don't yet know each child's final absolute row offset. + $fillChildren = []; + $nonFillRenders = []; + $nonFillNeedsDescendantTracking = []; + $savedStack = $this->positionTracker->suppressStack(); + + foreach ($children as $index => $child) { + if ($child instanceof VerticallyExpandableInterface && $child->isVerticallyExpanded()) { + $fillChildren[$index] = $child; + } else { + // Suppress descendant position tracking during measurement. + // Children that can have descendants must be re-rendered later + // with the final absolute offset to populate descendant rects. + $context = new RenderContext($columns, $rows, null, $this->fontRegistry); + $childLines = $this->widgetRenderer->renderWidget($child, $context); + $nonFillRenders[$index] = $childLines; + $nonFillNeedsDescendantTracking[$index] = $child instanceof ParentInterface; + + $remainingRows -= \count($childLines); + } + } + + $this->positionTracker->restoreStack($savedStack); + + // Calculate rows for fill children + $fillCount = \count($fillChildren); + $baseFillRows = $fillCount > 0 ? max(1, intdiv(max(0, $remainingRows), $fillCount)) : 0; + $extraRows = $fillCount > 0 ? max(0, $remainingRows) % $fillCount : 0; + + // Second pass: render all children in order with correct position tracking. + // At this point we know the accumulated line count for each child's offset. + $fillIndex = 0; + $hasPositionStack = $this->positionTracker->isActive(); + foreach ($children as $index => $child) { + if (isset($fillChildren[$index])) { + // Fill child gets calculated rows, distributing remainder to first children + $childFillRows = $baseFillRows + ($fillIndex < $extraRows ? 1 : 0); + ++$fillIndex; + + // Add gap before this child (so line count is correct for position) + if (!$first && $gapLines) { + array_push($lines, ...$gapLines); + } + + $context = new RenderContext($columns, $childFillRows, null, $this->fontRegistry); + + // Push correct absolute position so descendants get proper coordinates + if ($hasPositionStack) { + [$parentAbsRow, $parentAbsCol] = $this->positionTracker->currentOffset(); + $this->positionTracker->push($parentAbsRow + \count($lines), $parentAbsCol); + } + $childLines = $this->widgetRenderer->renderWidget($child, $context); + if ($hasPositionStack) { + $this->positionTracker->pop(); + } + + // Pad fill children to their allocated rows so they actually fill the space + while (\count($childLines) < $childFillRows) { + $childLines[] = ''; + } + } else { + $childLines = $nonFillRenders[$index] ?? $this->widgetRenderer->renderWidget($child, new RenderContext($columns, $rows, null, $this->fontRegistry)); + + // Skip gap for children that render nothing + if ([] === $childLines) { + continue; + } + + if (!$first && $gapLines) { + array_push($lines, ...$gapLines); + } + } + + // Track widget position + if ($hasPositionStack) { + [$parentAbsRow, $parentAbsCol] = $this->positionTracker->currentOffset(); + $childAbsRow = $parentAbsRow + \count($lines); + $childAbsCol = $parentAbsCol; + + $this->positionTracker->setWidgetRect($child, new WidgetRect( + $childAbsRow, + $childAbsCol, + $columns, + \count($childLines), + )); + + // For non-fill parent widgets rendered during measurement, + // re-render to track descendant positions with the correct + // absolute offset. Leaf widgets don't need this extra pass. + // Clear the render cache first so the re-render walks the + // subtree instead of returning the cached measurement output. + if (($nonFillNeedsDescendantTracking[$index] ?? false) && !isset($fillChildren[$index])) { + $child->clearRenderCache(); + $this->positionTracker->push($childAbsRow, $childAbsCol); + $this->widgetRenderer->renderWidget($child, new RenderContext($columns, $rows, null, $this->fontRegistry)); + $this->positionTracker->pop(); + } + } + + array_push($lines, ...$childLines); + $first = false; + } + + return $lines; + } + + /** + * Layout children horizontally with gap and flex-based column distribution. + * + * Flex modes: + * - No child has flex set: equal distribution (backward compatible) + * - flex: 0: intrinsic width (render to measure, then use actual width, capped by maxColumns) + * - flex: N (N > 0): proportional weight (remaining space after fixed children is distributed by weight) + * + * @param AbstractWidget[] $children + * + * @return string[] + */ + private function layoutHorizontal(array $children, int $columns, int $rows, int $gap): array + { + $count = \count($children); + if (0 === $count) { + return []; + } + + // When there are more children than available columns (accounting + // for gap), only the first N that fit are rendered. Each child + // needs at least 1 column, and each gap between children takes + // $gap columns: maxChildren = floor((columns + gap) / (1 + gap)). + $maxChildren = (int) floor(($columns + $gap) / (1 + $gap)); + if ($maxChildren < 1) { + $maxChildren = 1; + } + if ($count > $maxChildren) { + $children = \array_slice($children, 0, $maxChildren); + $count = $maxChildren; + } + + $gapColumns = $gap * max(0, $count - 1); + $availableColumns = max(1, $columns - $gapColumns); + + // Resolve flex values for each child + $flexValues = []; + $anyFlexSet = false; + foreach ($children as $index => $child) { + $childStyle = $this->widgetRenderer->resolveStyle($child); + $flexValues[$index] = $childStyle->getFlex(); + if (null !== $childStyle->getFlex()) { + $anyFlexSet = true; + } + } + + // Compute column widths based on flex values + $childColumnCounts = $this->computeFlexColumnWidths( + $children, + $flexValues, + $anyFlexSet, + $availableColumns, + $rows, + ); + + $childRenders = []; + $maxRows = 0; + $hasPositionStack = $this->positionTracker->isActive(); + + $colOffset = 0; + foreach ($children as $index => $child) { + $childColumns = $childColumnCounts[$index]; + + // Push correct absolute position for this horizontal child + // so descendants get proper coordinates during rendering + if ($hasPositionStack) { + [$absRow, $absCol] = $this->positionTracker->currentOffset(); + $this->positionTracker->push($absRow, $absCol + $colOffset); + } + + $context = new RenderContext($childColumns, $rows, null, $this->fontRegistry); + $childLines = $this->widgetRenderer->renderWidget($child, $context); + $childRenders[$index] = $childLines; + $maxRows = max($maxRows, \count($childLines)); + + if ($hasPositionStack) { + $this->positionTracker->pop(); + } + + $colOffset += $childColumns + $gap; + } + + if (0 === $maxRows) { + return []; + } + + // Track widget positions for horizontal children + if ($hasPositionStack) { + [$absRow, $absCol] = $this->positionTracker->currentOffset(); + $colOffset = 0; + foreach ($children as $index => $child) { + $this->positionTracker->setWidgetRect($child, new WidgetRect( + $absRow, + $absCol + $colOffset, + $childColumnCounts[$index], + \count($childRenders[$index]), + )); + $colOffset += $childColumnCounts[$index] + $gap; + } + } + + $gapSpaces = $gap > 0 ? str_repeat(' ', $gap) : ''; + $lines = []; + + for ($row = 0; $row < $maxRows; ++$row) { + $lineParts = []; + foreach ($children as $index => $child) { + $line = $childRenders[$index][$row] ?? ''; + $visibleLen = AnsiUtils::visibleWidth($line); + $cols = $childColumnCounts[$index]; + + if ($visibleLen > $cols) { + $line = AnsiUtils::truncateToWidth($line, $cols, ''); + } elseif ($visibleLen < $cols) { + $line .= str_repeat(' ', $cols - $visibleLen); + } + + $lineParts[] = $line; + } + + $lines[] = implode($gapSpaces, $lineParts); + } + + return $lines; + } + + /** + * Compute column widths for horizontal children based on flex values. + * + * When no child has flex set, falls back to equal distribution. + * flex: 0 children get their intrinsic width (measured by rendering). + * flex: N children share remaining space proportionally. + * + * @param AbstractWidget[] $children + * @param array $flexValues + * + * @return array + */ + private function computeFlexColumnWidths( + array $children, + array $flexValues, + bool $anyFlexSet, + int $availableColumns, + int $rows, + ): array { + $count = \count($children); + + // No flex set: equal distribution (backward compatible) + if (!$anyFlexSet) { + $baseColumns = intdiv($availableColumns, $count); + $extra = $availableColumns % $count; + $result = []; + foreach ($children as $index => $child) { + $result[$index] = max(1, $baseColumns + ($index < $extra ? 1 : 0)); + } + + return $result; + } + + // First pass: measure intrinsic-width children (flex: 0) and collect flex weights. + // Suppress position tracking during measurement (same pattern as vertical fill). + $intrinsicWidths = []; + $flexWeights = []; + $totalFlexWeight = 0; + $usedColumns = 0; + $savedStack = $this->positionTracker->suppressStack(); + + foreach ($children as $index => $child) { + $flex = $flexValues[$index]; + + if (0 === $flex) { + // Intrinsic width: measure the child's natural content width + // plus chrome (border/padding). This uses measureIntrinsicWidth() + // instead of renderWidget() because renderWidget() pads lines + // to the full allocated width via ChromeApplier. + $width = $this->widgetRenderer->measureIntrinsicWidth($child, $availableColumns, $rows); + $intrinsicWidths[$index] = $width; + $usedColumns += $width; + } elseif (null !== $flex && $flex > 0) { + $flexWeights[$index] = $flex; + $totalFlexWeight += $flex; + } else { + // null flex when other siblings have flex set: treat as flex: 1 + $flexWeights[$index] = 1; + ++$totalFlexWeight; + } + } + + $this->positionTracker->restoreStack($savedStack); + + // Second pass: distribute remaining space among flex children + $remainingColumns = max(0, $availableColumns - $usedColumns); + $result = []; + $flexAllocated = 0; + $flexColumnsUsed = 0; + $flexCount = \count($flexWeights); + + foreach ($children as $index => $child) { + if (isset($intrinsicWidths[$index])) { + $result[$index] = $intrinsicWidths[$index]; + } elseif (isset($flexWeights[$index])) { + if ($totalFlexWeight > 0 && $remainingColumns > 0) { + // Last flex child gets whatever is left to avoid rounding errors + ++$flexAllocated; + if ($flexAllocated === $flexCount) { + $allocated = $remainingColumns - $flexColumnsUsed; + } else { + $allocated = (int) floor($remainingColumns * $flexWeights[$index] / $totalFlexWeight); + } + } else { + $allocated = 0; + } + $result[$index] = max(1, $allocated); + $flexColumnsUsed += $result[$index]; + } + } + + return $result; + } +} diff --git a/src/Symfony/Component/Tui/Render/PositionTracker.php b/src/Symfony/Component/Tui/Render/PositionTracker.php new file mode 100644 index 0000000000000..5ae6caf71e8a6 --- /dev/null +++ b/src/Symfony/Component/Tui/Render/PositionTracker.php @@ -0,0 +1,171 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Render; + +use Symfony\Component\Tui\Widget\AbstractWidget; + +/** + * Tracks absolute positions of rendered widgets on screen. + * + * Maintains a stack of absolute [row, col] offsets for the current rendering + * context and records each widget's final position as a WidgetRect. + * + * @experimental + * + * @author Fabien Potencier + */ +final class PositionTracker +{ + /** @var \WeakMap */ + private \WeakMap $widgetPositions; + + /** + * Stack of absolute [row, col] offsets for the current rendering context. + * + * @var list + */ + private array $positionStack = []; + + public function __construct() + { + $this->widgetPositions = new \WeakMap(); + } + + /** + * Reset the position stack for a new render pass. + * + * Previous widget positions are preserved so that cached subtrees + * (which skip re-rendering) keep their tracked rects. Any widget + * whose parent is re-rendered gets a fresh rect, replacing the old + * entry. Removed widgets are garbage-collected by the WeakMap. + */ + public function reset(): void + { + $this->positionStack = [[0, 0]]; + } + + /** + * Get the tracked position of a widget from the last render pass. + */ + public function getWidgetRect(AbstractWidget $widget): ?WidgetRect + { + return $this->widgetPositions[$widget] ?? null; + } + + /** + * Record a widget's absolute position. + */ + public function setWidgetRect(AbstractWidget $widget, WidgetRect $rect): void + { + $this->widgetPositions[$widget] = $rect; + } + + /** + * Whether position tracking is active (has a non-empty stack). + */ + public function isActive(): bool + { + return [] !== $this->positionStack; + } + + /** + * Get the current absolute [row, col] offset from the top of the stack. + * + * @return array{int, int} + */ + public function currentOffset(): array + { + return $this->positionStack[\count($this->positionStack) - 1]; + } + + /** + * Push a new absolute [row, col] offset onto the stack. + */ + public function push(int $row, int $col): void + { + $this->positionStack[] = [$row, $col]; + } + + /** + * Pop the top offset from the stack. + */ + public function pop(): void + { + if (\count($this->positionStack) > 1) { + array_pop($this->positionStack); + } + } + + /** + * Save the position stack and replace it with an empty one. + * + * Used to suppress position tracking during measurement passes. + * + * @return list + */ + public function suppressStack(): array + { + $saved = $this->positionStack; + $this->positionStack = []; + + return $saved; + } + + /** + * Restore a previously saved position stack. + * + * @param list $stack + */ + public function restoreStack(array $stack): void + { + $this->positionStack = $stack; + } + + /** + * Snapshot the set of widgets currently tracked. + * + * @return \SplObjectStorage + */ + public function snapshotKeys(): \SplObjectStorage + { + /** @var \SplObjectStorage $snapshot */ + $snapshot = new \SplObjectStorage(); + foreach ($this->widgetPositions as $widget => $_) { + $snapshot[$widget] = true; + } + + return $snapshot; + } + + /** + * Shift positions for all widgets added since the snapshot. + * + * @param \SplObjectStorage|null $before + */ + public function shiftDescendantPositions(?\SplObjectStorage $before, int $colOffset, int $rowOffset = 0): void + { + if (null === $before) { + return; + } + + foreach ($this->widgetPositions as $widget => $rect) { + if (!$before->offsetExists($widget)) { + $this->widgetPositions[$widget] = new WidgetRect( + $rect->getRow() + $rowOffset, + $rect->getCol() + $colOffset, + $rect->getColumns(), + $rect->getRows(), + ); + } + } + } +} diff --git a/src/Symfony/Component/Tui/Render/RenderContext.php b/src/Symfony/Component/Tui/Render/RenderContext.php new file mode 100644 index 0000000000000..a30b6026c8be0 --- /dev/null +++ b/src/Symfony/Component/Tui/Render/RenderContext.php @@ -0,0 +1,102 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Render; + +use Symfony\Component\Tui\Style\Style; +use Symfony\Component\Tui\Widget\Figlet\FontRegistry; + +/** + * Context passed to widgets during rendering. + * + * Contains the available dimensions for rendering in terminal character cells, + * the resolved style for the widget, and the font registry for FIGlet rendering. + * + * This is an immutable value object - use with*() methods to create modified copies. + * + * ## Terminology: columns and rows + * + * This class uses `columns` and `rows` to match terminal conventions: + * - Terminals measure size in character cells (columns × rows), not pixels + * - The TerminalInterface uses `getColumns()` and `getRows()` + * - Standard tools like `stty`, `tput`, and env vars `COLUMNS`/`LINES` use this terminology + * + * @experimental + * + * @author Fabien Potencier + */ +final class RenderContext +{ + private readonly Style $style; + private readonly FontRegistry $fontRegistry; + + public function __construct( + private readonly int $columns, + private readonly int $rows, + ?Style $style = null, + ?FontRegistry $fontRegistry = null, + ) { + $this->style = $style ?? new Style(); + $this->fontRegistry = $fontRegistry ?? new FontRegistry(); + } + + public function getColumns(): int + { + return $this->columns; + } + + public function getRows(): int + { + return $this->rows; + } + + public function getStyle(): Style + { + return $this->style; + } + + public function getFontRegistry(): FontRegistry + { + return $this->fontRegistry; + } + + /** + * Create a new context with a different column count. + */ + public function withColumns(int $columns): self + { + return new self($columns, $this->rows, $this->style, $this->fontRegistry); + } + + /** + * Create a new context with a different row count. + */ + public function withRows(int $rows): self + { + return new self($this->columns, $rows, $this->style, $this->fontRegistry); + } + + /** + * Create a new context with different dimensions. + */ + public function withSize(int $columns, int $rows): self + { + return new self($columns, $rows, $this->style, $this->fontRegistry); + } + + /** + * Create a new context with a resolved style. + */ + public function withStyle(Style $style): self + { + return new self($this->columns, $this->rows, $style, $this->fontRegistry); + } +} diff --git a/src/Symfony/Component/Tui/Render/RenderRequestorInterface.php b/src/Symfony/Component/Tui/Render/RenderRequestorInterface.php new file mode 100644 index 0000000000000..0f8f874df9d00 --- /dev/null +++ b/src/Symfony/Component/Tui/Render/RenderRequestorInterface.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Render; + +/** + * Capability interface for managing the render lifecycle. + * + * Used by internal collaborators (FocusManager, MouseCoordinator) + * that need to trigger or flush a render pass without depending + * on the full Tui API. + * + * @experimental + * + * @author Fabien Potencier + */ +interface RenderRequestorInterface +{ + /** + * Request a render on the next tick. + * + * @param bool $force If true, clear all cached state and do a full re-render + */ + public function requestRender(bool $force = false): void; + + /** + * Flush any pending render immediately. + * + * Unlike requestRender() which defers to the next tick, this + * synchronously renders the current frame. Used when up-to-date + * widget positions are needed before further processing (e.g. + * mouse hit-testing after a screen transition). + */ + public function processRender(): void; +} diff --git a/src/Symfony/Component/Tui/Render/Renderer.php b/src/Symfony/Component/Tui/Render/Renderer.php new file mode 100644 index 0000000000000..fab4cfd511315 --- /dev/null +++ b/src/Symfony/Component/Tui/Render/Renderer.php @@ -0,0 +1,337 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Render; + +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Exception\RenderException; +use Symfony\Component\Tui\Style\Align; +use Symfony\Component\Tui\Style\DefaultStyleSheet; +use Symfony\Component\Tui\Style\Direction; +use Symfony\Component\Tui\Style\Style; +use Symfony\Component\Tui\Style\StyleSheet; +use Symfony\Component\Tui\Widget\AbstractWidget; +use Symfony\Component\Tui\Widget\ContainerWidget; +use Symfony\Component\Tui\Widget\Figlet\FontRegistry; +use Symfony\Component\Tui\Widget\ParentInterface; + +/** + * Renders the widget tree with style resolution, layout, and chrome. + * + * The Renderer: + * 1. Resolves styles through cascade (* → FQCN → CSS class → state → instance) + * 2. Computes layout (vertical/horizontal with gap and fill children) + * 3. Calls widget->render() with enriched context + * 4. Applies chrome (padding, border, background) around widget content + * + * All widget types are rendered through the Renderer: containers via + * renderContainer(), and leaf widgets by delegating to widget->render(). + * + * @experimental + * + * @author Fabien Potencier + */ +final class Renderer implements WidgetRendererInterface +{ + private StyleSheet $styleSheet; + private FontRegistry $fontRegistry; + private PositionTracker $positionTracker; + private LayoutEngine $layoutEngine; + private ChromeApplier $chromeApplier; + + /** Current terminal columns, set during render() for breakpoint resolution */ + private ?int $currentColumns = null; + + public function __construct(?StyleSheet $styleSheet = null, ?FontRegistry $fontRegistry = null) + { + $this->fontRegistry = $fontRegistry ?? new FontRegistry(); + $this->positionTracker = new PositionTracker(); + $this->layoutEngine = new LayoutEngine($this->positionTracker, $this->fontRegistry); + $this->layoutEngine->setWidgetRenderer($this); + $this->chromeApplier = new ChromeApplier(); + $this->chromeApplier->setWidgetRenderer($this); + + if (null !== $styleSheet) { + // Clone the user stylesheet to preserve its runtime type + // (e.g. TailwindStylesheet) so that its resolve() override + // is used. Merge the defaults underneath: default rules are + // added only for selectors the user hasn't already defined. + $this->styleSheet = clone $styleSheet; + $this->styleSheet->mergeDefaults(DefaultStyleSheet::create()); + } else { + $this->styleSheet = DefaultStyleSheet::create(); + } + } + + /** + * Add a stylesheet. + */ + public function addStyleSheet(StyleSheet $styleSheet): void + { + $this->styleSheet->merge($styleSheet); + } + + /** + * Get the stylesheet. + */ + public function getStyleSheet(): StyleSheet + { + return $this->styleSheet; + } + + /** + * Get the tracked position of a widget from the last render pass. + * + * Returns null if the widget was not rendered or is not being tracked. + */ + public function getWidgetRect(AbstractWidget $widget): ?WidgetRect + { + return $this->positionTracker->getWidgetRect($widget); + } + + /** + * Render the widget tree starting from root. + * + * @return string[] Array of rendered lines + */ + public function render(ContainerWidget $root, int $columns, int $rows): array + { + $context = new RenderContext($columns, $rows, null, $this->fontRegistry); + $this->currentColumns = $columns; + $this->positionTracker->reset(); + + $result = $this->renderWidget($root, $context); + + // Track root widget position + $this->positionTracker->setWidgetRect($root, new WidgetRect(0, 0, $columns, \count($result))); + + return $result; + } + + public function renderWidget(AbstractWidget $widget, RenderContext $context): array + { + // Allow widget to sync state before rendering + $widget->beforeRender(); + + // Check render cache: if the widget hasn't been invalidated and + // the available dimensions are unchanged, reuse the previous output. + // This skips style resolution, layout, chrome, and content rendering. + $cacheColumns = $context->getColumns(); + $cacheRows = $context->getRows(); + $cached = $widget->getRenderCache($cacheColumns, $cacheRows); + if (null !== $cached) { + return $cached; + } + + // 1. Resolve style by merging: global → FQCN → state → instance + $resolvedStyle = $this->resolveStyle($widget); + + // Hidden widgets produce no output and take no space + if (true === $resolvedStyle->getHidden()) { + $widget->setRenderCache([], $cacheColumns, $cacheRows); + + return []; + } + + // 2. Apply maxColumns constraint if set + $maxColumns = $resolvedStyle->getMaxColumns(); + if (null !== $maxColumns && $context->getColumns() > $maxColumns) { + $context = $context->withColumns($maxColumns); + } + + // 3. Create enriched context with resolved style + $styledContext = $context->withStyle($resolvedStyle); + + // 4. For ContainerWidget, use the layout engine + if ($widget instanceof ContainerWidget) { + $lines = $this->renderContainer($widget, $styledContext, $resolvedStyle); + } else { + // 5. For all other widgets (leaf widgets + ParentInterface), + // render content with inner dimensions, then apply chrome + $innerContext = $this->chromeApplier->computeInnerContext($styledContext, $resolvedStyle); + $lines = $widget->render($innerContext); + $lines = $this->chromeApplier->apply($lines, $context->getColumns(), $resolvedStyle, $widget); + } + + // Validate that no line exceeds the available width. + // This catches widget bugs early, at the source, rather than + // letting over-wide lines flow to ScreenWriter where the widget + // context is lost. Image lines (Kitty/iTerm2 protocol) are + // excluded because their visible width is not meaningful. + $availableColumns = $context->getColumns(); + foreach ($lines as $i => $line) { + if ('' === $line || AnsiUtils::containsImage($line)) { + continue; + } + + $lineWidth = AnsiUtils::visibleWidth($line); + if ($lineWidth > $availableColumns) { + throw new RenderException(\sprintf("Widget %s rendered line %d with width %d, exceeding the available %d columns.\nLine preview: %s", $widget::class, $i, $lineWidth, $availableColumns, mb_substr(AnsiUtils::stripAnsiCodes($line), 0, 100)), $i, $lineWidth, $availableColumns); + } + } + + $widget->setRenderCache($lines, $cacheColumns, $cacheRows); + + return $lines; + } + + public function resolveStyle(AbstractWidget $widget): Style + { + return $this->styleSheet->resolve($widget, $this->currentColumns); + } + + public function measureIntrinsicWidth(AbstractWidget $widget, int $maxColumns, int $rows): int + { + $resolvedStyle = $this->resolveStyle($widget); + + // Apply maxColumns from the widget's own style + $styleMaxColumns = $resolvedStyle->getMaxColumns(); + if (null !== $styleMaxColumns) { + $maxColumns = min($maxColumns, $styleMaxColumns); + } + + // Compute chrome (border + padding) + [$innerColumns] = $this->chromeApplier->computeInnerDimensions($maxColumns, $rows, $resolvedStyle); + + if ($widget instanceof ContainerWidget) { + // For containers, render children within inner dimensions and measure + $children = array_values(array_filter( + $widget->all(), + fn (AbstractWidget $child) => true !== $this->resolveStyle($child)->getHidden(), + )); + + $direction = $resolvedStyle->getDirection() ?? Direction::Vertical; + $gap = $resolvedStyle->getGap() ?? 0; + + if (Direction::Horizontal === $direction) { + // Horizontal container: sum of children's intrinsic widths + gaps + $totalWidth = $gap * max(0, \count($children) - 1); + foreach ($children as $child) { + $totalWidth += $this->measureIntrinsicWidth($child, $innerColumns, $rows); + } + $contentWidth = $totalWidth; + } else { + // Vertical container: widest child + $contentWidth = 0; + foreach ($children as $child) { + $contentWidth = max($contentWidth, $this->measureIntrinsicWidth($child, $innerColumns, $rows)); + } + } + } else { + // For leaf widgets, render content at inner dimensions and measure widest line + $innerContext = $this->chromeApplier->computeInnerContext( + new RenderContext($maxColumns, $rows, null, $this->fontRegistry)->withStyle($resolvedStyle), + $resolvedStyle, + ); + $widget->beforeRender(); + $contentLines = $widget->render($innerContext); + $widget->clearRenderCache(); + + $contentWidth = 0; + foreach ($contentLines as $line) { + $contentWidth = max($contentWidth, AnsiUtils::visibleWidth($line)); + } + } + + $chromeWidth = $maxColumns - $innerColumns; + + return min(max(1, $contentWidth + $chromeWidth), $maxColumns); + } + + /** + * Render a container widget with its children. + * + * @return string[] + */ + private function renderContainer(ContainerWidget $widget, RenderContext $context, Style $resolvedStyle): array + { + // Filter out hidden children so they don't take up layout space + $children = array_values(array_filter( + $widget->all(), + fn (AbstractWidget $child) => true !== $this->resolveStyle($child)->getHidden(), + )); + + $columns = $context->getColumns(); + $rows = $context->getRows(); + + if ([] === $children) { + return $this->chromeApplier->apply([], $columns, $resolvedStyle, $widget); + } + + // Calculate inner dimensions (content area after border/padding) + [$innerColumns, $innerRows] = $this->chromeApplier->computeInnerDimensions($columns, $rows, $resolvedStyle); + + // Get direction and gap from resolved style + $direction = $resolvedStyle->getDirection() ?? Direction::Vertical; + $gap = $resolvedStyle->getGap() ?? 0; + + // Compute styled gap line matching what a child widget would render as blank + // This ensures gap lines inherit the container's resolved style (e.g. bold from * rule) + $gapLine = null; + if ($gap > 0) { + $gapContent = str_repeat(' ', max(1, $innerColumns)); + $gapLine = $resolvedStyle->isPlain() ? $gapContent : $resolvedStyle->apply($gapContent); + } + + // Push the content area's absolute offset onto the position stack + if ($this->positionTracker->isActive()) { + [$parentRow, $parentCol] = $this->positionTracker->currentOffset(); + [$chromeTop, $chromeLeft] = $this->chromeApplier->computeChromeOffset($resolvedStyle); + $this->positionTracker->push($parentRow + $chromeTop, $parentCol + $chromeLeft); + } + + // Snapshot positions before layout so we can adjust them if alignment shifts content + $align = $resolvedStyle->getAlign(); + $hasAlign = null !== $align && Align::Left !== $align; + $verticalAlign = $resolvedStyle->getVerticalAlign(); + $hasVerticalAlign = null !== $verticalAlign; + $positionsBeforeLayout = ($hasAlign || $hasVerticalAlign) ? $this->positionTracker->snapshotKeys() : null; + + // Render children using layout engine + $childLines = $this->layoutEngine->layout( + $children, + $innerColumns, + $innerRows, + $gap, + $direction, + $gapLine, + ); + + // Pop position stack + $this->positionTracker->pop(); + + // Apply vertical alignment for child widgets and adjust tracked positions + if ($hasVerticalAlign && \count($childLines) < $innerRows) { + $verticalOffset = $this->layoutEngine->computeVerticalAlignOffset(\count($childLines), $innerRows, $verticalAlign); + if ($verticalOffset > 0) { + $topPad = array_fill(0, $verticalOffset, ''); + array_unshift($childLines, ...$topPad); + $this->positionTracker->shiftDescendantPositions($positionsBeforeLayout, 0, $verticalOffset); + } + // Pad to fill remaining height so Tui::doRender() doesn't override alignment + while (\count($childLines) < $innerRows) { + $childLines[] = ''; + } + } + + // Apply horizontal alignment for child widgets and adjust tracked positions + if ($hasAlign) { + $alignOffset = $this->layoutEngine->computeAlignOffset($childLines, $innerColumns, $align); + if ($alignOffset > 0) { + $childLines = $this->layoutEngine->shiftLines($childLines, $alignOffset); + $this->positionTracker->shiftDescendantPositions($positionsBeforeLayout, $alignOffset); + } + } + + // Apply chrome (padding, border, background) + return $this->chromeApplier->apply($childLines, $columns, $resolvedStyle, $widget); + } +} diff --git a/src/Symfony/Component/Tui/Render/ScreenWriter.php b/src/Symfony/Component/Tui/Render/ScreenWriter.php new file mode 100644 index 0000000000000..c17d9a3df1afe --- /dev/null +++ b/src/Symfony/Component/Tui/Render/ScreenWriter.php @@ -0,0 +1,546 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Render; + +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Exception\RenderException; +use Symfony\Component\Tui\Terminal\TerminalInterface; + +/** + * Handles efficient terminal output with differential rendering. + * + * Accepts rendered lines (the composited screen state) and writes them + * to the terminal with minimal updates using line-level diffing. + * + * This class is responsible for: + * - Tracking screen state (previous lines, cursor position) + * - Computing minimal updates between frames + * - Writing ANSI sequences to the terminal + * - Managing cursor position for differential rendering + * + * @experimental + * + * @author Fabien Potencier + */ +final class ScreenWriter +{ + private const PRINTABLE_ASCII = ' !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; + + /** @var string[] */ + private array $previousLines = []; + private int $previousWidth = 0; + private int $cursorRow = 0; + private int $hardwareCursorRow = 0; + private int $maxLinesRendered = 0; + private bool $showHardwareCursor = true; + private int $scrollOffset = 0; + + /** @var string[] */ + private array $previousRawLines = []; + + /** @var array{row: int, col: int, shape: int}|null */ + private ?array $previousCursorPos = null; + + public function __construct( + private readonly TerminalInterface $terminal, + ) { + } + + public function setShowHardwareCursor(bool $enabled): void + { + if ($this->showHardwareCursor === $enabled) { + return; + } + + $this->showHardwareCursor = $enabled; + + if (!$enabled) { + $this->terminal->hideCursor(); + } + } + + /** + * Set the scroll offset (lines from bottom). + * + * When the content exceeds the viewport, the viewport normally shows + * the bottom portion. A positive scroll offset shifts the viewport + * up by that many lines. + */ + public function setScrollOffset(int $offset): void + { + $offset = max(0, $offset); + if ($this->scrollOffset !== $offset) { + $this->scrollOffset = $offset; + $this->reset(); + } + } + + /** + * Get the current scroll offset. + */ + public function getScrollOffset(): int + { + return $this->scrollOffset; + } + + /** + * Write ANSI lines to the terminal using differential rendering. + * + * @param string[] $lines The new content to display + */ + public function writeLines(array $lines): void + { + // Apply scroll offset: when content exceeds the viewport, slice to + // show a window shifted up from the bottom by scrollOffset lines. + if ($this->scrollOffset > 0) { + $totalLines = \count($lines); + $rows = $this->terminal->getRows(); + if ($totalLines > $rows) { + $maxOffset = $totalLines - $rows; + $effectiveOffset = min($this->scrollOffset, $maxOffset); + $startLine = $totalLines - $rows - $effectiveOffset; + $lines = \array_slice($lines, $startLine, $rows); + } + } + + if ([] !== $this->previousLines && $this->previousWidth === $this->terminal->getColumns() && $lines === $this->previousRawLines) { + $this->positionHardwareCursor($this->previousCursorPos, \count($this->previousLines)); + + return; + } + + $rawLines = $lines; + ['lines' => $lines, 'cursorPos' => $cursorPos, 'firstChanged' => $firstChanged, 'lastChanged' => $lastChanged] = $this->prepareLines($lines); + + $this->writeInternal($lines, $cursorPos, $firstChanged, $lastChanged); + $this->previousRawLines = $rawLines; + $this->previousCursorPos = $cursorPos; + } + + /** + * Clear rendering state, forcing a full re-render on next write. + * + * The scroll offset is preserved so that a forced re-render does not + * jump back to the bottom of the content. + */ + public function reset(): void + { + $this->previousLines = []; + $this->previousRawLines = []; + $this->previousCursorPos = null; + $this->previousWidth = -1; // -1 triggers widthChanged + $this->cursorRow = 0; + $this->hardwareCursorRow = 0; + $this->maxLinesRendered = 0; + } + + /** + * Get the final cursor position for cleanup when stopping. + * + * @return array{lineCount: int, cursorRow: int} + */ + public function getState(): array + { + return [ + 'lineCount' => \count($this->previousLines), + 'cursorRow' => $this->hardwareCursorRow, + ]; + } + + /** + * Internal write implementation. + * + * @param string[] $lines + * @param array{row: int, col: int, shape: int}|null $cursorPos + */ + private function writeInternal(array $lines, ?array $cursorPos, int $firstChanged, int $lastChanged): void + { + $columns = $this->terminal->getColumns(); + $rows = $this->terminal->getRows(); + + // Width changed - need full re-render + $widthChanged = 0 !== $this->previousWidth && $this->previousWidth !== $columns; + + // First render or width changed + if ([] === $this->previousLines || $widthChanged) { + $this->fullRender($lines, $cursorPos, $widthChanged); + + return; + } + + $lineCount = \count($lines); + + if (-1 === $firstChanged) { + $this->positionHardwareCursor($cursorPos, $lineCount); + + return; + } + + if ($firstChanged >= $lineCount) { + $this->handleDeletedLines($lines, $cursorPos, $rows); + + return; + } + + // Check if firstChanged is outside the viewport + $viewportTop = $this->terminal->isVirtual() + ? 0 + : max(0, $this->maxLinesRendered - $rows); + + if ($firstChanged < $viewportTop) { + $this->fullRender($lines, $cursorPos, true); + + return; + } + + // Differential render + $this->differentialRender($lines, $cursorPos, $firstChanged, $lastChanged, $columns); + } + + /** + * Writes all lines to the terminal from scratch. + * + * This is only used for the first render and for cases where incremental + * updates are not possible. For subsequent renders where changed lines are + * within the viewport, differentialRender() is used instead. + * + * When $clear is false, the output is appended from the current cursor + * position (used for the very first render when the screen is already + * empty). + * + * When $clear is true, the screen is erased and the cursor is moved home + * before writing. The consequence is that the display resets and starts + * from the top of the screen, which is a small caveat of the algorithm + * used. $clear must be true in three cases: + * + * - On terminal resize: a line may have been split into 2 lines by the + * terminal, making it impossible to update the display incrementally. + * - When changed lines are outside the viewport: there is no way to + * address lines that have scrolled out of the visible area. + * - When too many trailing lines were deleted: if the number of lines to + * erase exceeds the terminal height, clearing is more efficient than + * erasing them one by one. + * + * @param string[] $newLines + * @param array{row: int, col: int, shape: int}|null $cursorPos + */ + private function fullRender(array $newLines, ?array $cursorPos, bool $clear): void + { + $buffer = "\x1b[?2026h"; // Begin synchronized output + + if ($clear) { + $buffer .= "\x1b[2J\x1b[3J\x1b[H"; // Clear screen, clear scrollback, and home + } + + if ([] !== $newLines) { + $buffer .= implode("\r\n", $newLines); + } + + $buffer .= "\x1b[?2026l"; // End synchronized output + + $this->terminal->write($buffer); + $this->cursorRow = max(0, \count($newLines) - 1); + $this->hardwareCursorRow = $this->cursorRow; + + if ($clear) { + $this->maxLinesRendered = \count($newLines); + } else { + $this->maxLinesRendered = max($this->maxLinesRendered, \count($newLines)); + } + + $this->positionHardwareCursor($cursorPos, \count($newLines)); + $this->previousLines = $newLines; + $this->previousWidth = $this->terminal->getColumns(); + } + + /** + * @param string[] $newLines + * @param array{row: int, col: int, shape: int}|null $cursorPos + * + * @return bool True when a full render fallback was used + */ + private function handleDeletedLines(array $newLines, ?array $cursorPos, int $height): bool + { + if (\count($this->previousLines) <= \count($newLines)) { + $this->positionHardwareCursor($cursorPos, \count($newLines)); + $this->previousLines = $newLines; + $this->previousWidth = $this->terminal->getColumns(); + + return false; + } + + $buffer = "\x1b[?2026h"; + + $targetRow = max(0, \count($newLines) - 1); + $lineDiff = $targetRow - $this->hardwareCursorRow; + + if ($lineDiff > 0) { + $buffer .= "\x1b[{$lineDiff}B"; + } elseif ($lineDiff < 0) { + $buffer .= "\x1b[".(-$lineDiff).'A'; + } + + $buffer .= "\r"; + + $extraLines = \count($this->previousLines) - \count($newLines); + + if ($extraLines > $height) { + $this->fullRender($newLines, $cursorPos, true); + + return true; + } + + $newLineCount = \count($newLines); + + if ($extraLines > 0 && $newLineCount > 0) { + $buffer .= "\x1b[1B"; + } + + for ($i = 0; $i < $extraLines; ++$i) { + $buffer .= "\r\x1b[2K"; + if ($i < $extraLines - 1) { + $buffer .= "\x1b[1B"; + } + } + + $moveUp = $extraLines + ($newLineCount > 0 ? 0 : -1); + if ($moveUp > 0) { + $buffer .= "\x1b[{$moveUp}A"; + } + + $buffer .= "\x1b[?2026l"; + + $this->terminal->write($buffer); + $this->cursorRow = $targetRow; + $this->hardwareCursorRow = $targetRow; + + $this->positionHardwareCursor($cursorPos, \count($newLines)); + $this->previousLines = $newLines; + $this->previousWidth = $this->terminal->getColumns(); + + return false; + } + + /** + * @param string[] $newLines + * @param array{row: int, col: int, shape: int}|null $cursorPos + */ + private function differentialRender(array $newLines, ?array $cursorPos, int $firstChanged, int $lastChanged, int $width): void + { + $buffer = "\x1b[?2026h"; // Begin synchronized output + + // Move cursor to first changed line + $lineDiff = $firstChanged - $this->hardwareCursorRow; + if ($lineDiff > 0) { + $buffer .= "\x1b[{$lineDiff}B"; + } elseif ($lineDiff < 0) { + $buffer .= "\x1b[".(-$lineDiff).'A'; + } + + $buffer .= "\r"; + + // Render changed lines + $renderEnd = min($lastChanged, \count($newLines) - 1); + + for ($i = $firstChanged; $i <= $renderEnd; ++$i) { + if ($i > $firstChanged) { + $buffer .= "\r\n"; + } + $buffer .= "\x1b[2K"; + + $line = $newLines[$i]; + $lineWidth = null; + $lineLength = \strlen($line); + + if ($lineLength === strcspn($line, "\x1b\t") && $lineLength === strspn($line, self::PRINTABLE_ASCII)) { + $lineWidth = $lineLength; + } elseif (!AnsiUtils::containsImage($line)) { + $lineWidth = AnsiUtils::visibleWidth($line); + } + + if (null !== $lineWidth && $lineWidth > $width) { + // End synchronized output before throwing so the terminal + // is not left in buffered mode and ScreenWriter state stays consistent + $buffer .= "\x1b[?2026l"; + $this->terminal->write($buffer); + + $this->hardwareCursorRow = $i; + // Force a full re-render with screen clear on next call + // since the screen is now in a partially updated state + $this->previousLines = []; + $this->previousWidth = -1; + + // Strip ANSI codes for readable debug output + $plainLine = preg_replace('/\x1b(?:\[[0-9;]*[a-zA-Z]|\][^\x07]*\x07)/', '', $line); + $preview = mb_substr($plainLine, 0, 100); + + throw new RenderException(\sprintf("Rendered line %d exceeds terminal width (%d > %d).\nLine preview: %s%s", $i, $lineWidth, $width, $preview, mb_strlen($plainLine) > 100 ? '...' : ''), $i, $lineWidth, $width); + } + + $buffer .= $line; + } + + $finalCursorRow = $renderEnd; + + // Handle content size changes + if (\count($this->previousLines) > \count($newLines)) { + // Content shrunk - clear extra lines + if ($renderEnd < \count($newLines) - 1) { + $moveDown = \count($newLines) - 1 - $renderEnd; + $buffer .= "\x1b[{$moveDown}B"; + $finalCursorRow = \count($newLines) - 1; + } + + $extraLines = \count($this->previousLines) - \count($newLines); + $buffer .= str_repeat("\r\n\x1b[2K", $extraLines); + + $buffer .= "\x1b[{$extraLines}A"; + } elseif (\count($newLines) > \count($this->previousLines)) { + // Content grew - output any additional lines not already rendered + // Only needed if renderEnd < newLines.length - 1 (i.e., we didn't render to the end) + if ($renderEnd < \count($newLines) - 1) { + for ($i = $renderEnd + 1; $i < \count($newLines); ++$i) { + $buffer .= "\r\n\x1b[2K"; + $buffer .= $newLines[$i]; + $finalCursorRow = $i; + } + } + } + + $buffer .= "\x1b[?2026l"; // End synchronized output + + $this->terminal->write($buffer); + + $this->cursorRow = max(0, \count($newLines) - 1); + $this->hardwareCursorRow = $finalCursorRow; + $this->maxLinesRendered = max($this->maxLinesRendered, \count($newLines)); + + $this->positionHardwareCursor($cursorPos, \count($newLines)); + $this->previousLines = $newLines; + $this->previousWidth = $this->terminal->getColumns(); + } + + /** + * Strip cursor markers, apply line resets, and detect changed rows in one pass. + * + * @param string[] $lines + * + * @return array{lines: string[], cursorPos: array{row: int, col: int, shape: int}|null, firstChanged: int, lastChanged: int} + */ + private function prepareLines(array $lines): array + { + $cursorPos = null; + $firstChanged = -1; + $lastChanged = -1; + $lineCount = \count($lines); + $previousLineCount = \count($this->previousLines); + + foreach ($lines as $row => $line) { + $oldLine = $row < $previousLineCount ? $this->previousLines[$row] : ''; + if ($oldLine === $line) { + continue; + } + + if (str_contains($line, "\x1b")) { + if ($oldLine === $line."\x1b[0m" || $oldLine === $line.AnsiUtils::SEGMENT_RESET) { + $lines[$row] = $oldLine; + continue; + } + + if (null === $cursorPos) { + $markerIndex = strpos($line, AnsiUtils::CURSOR_MARKER_PREFIX); + if (false !== $markerIndex) { + $endIndex = strpos($line, "\x07", $markerIndex); + if (false !== $endIndex) { + $markerLen = $endIndex - $markerIndex + 1; + $shapeStr = substr($line, $markerIndex + \strlen(AnsiUtils::CURSOR_MARKER_PREFIX), $endIndex - $markerIndex - \strlen(AnsiUtils::CURSOR_MARKER_PREFIX)); + $beforeMarker = substr($line, 0, $markerIndex); + $cursorPos = ['row' => $row, 'col' => AnsiUtils::visibleWidth($beforeMarker), 'shape' => (int) $shapeStr]; + $line = substr($line, 0, $markerIndex).substr($line, $markerIndex + $markerLen); + } + } + } + + if (str_contains($line, "\x1b") && !AnsiUtils::containsImage($line)) { + $line = str_contains($line, "\x1b]8;") + ? $line.AnsiUtils::SEGMENT_RESET + : $line."\x1b[0m"; + } + } + + $lines[$row] = $line; + + if ($oldLine !== $line) { + if (-1 === $firstChanged) { + $firstChanged = $row; + } + $lastChanged = $row; + } + } + + if ($previousLineCount > $lineCount) { + if (-1 === $firstChanged) { + $firstChanged = $lineCount; + } + $lastChanged = $previousLineCount - 1; + } + + return [ + 'lines' => $lines, + 'cursorPos' => $cursorPos, + 'firstChanged' => $firstChanged, + 'lastChanged' => $lastChanged, + ]; + } + + /** + * Position the hardware cursor, set its shape, and manage visibility. + * + * @param array{row: int, col: int, shape: int}|null $cursorPos + */ + private function positionHardwareCursor(?array $cursorPos, int $totalLines): void + { + if (null === $cursorPos || $totalLines <= 0) { + $this->terminal->hideCursor(); + + return; + } + + $targetRow = max(0, min($cursorPos['row'], $totalLines - 1)); + $targetCol = max(0, $cursorPos['col']); + + $rowDelta = $targetRow - $this->hardwareCursorRow; + $buffer = ''; + + if ($rowDelta > 0) { + $buffer .= "\x1b[{$rowDelta}B"; + } elseif ($rowDelta < 0) { + $buffer .= "\x1b[".(-$rowDelta).'A'; + } + + // Move to absolute column (1-indexed) + $buffer .= "\x1b[".($targetCol + 1).'G'; + + // Set cursor shape via DECSCUSR (Set Cursor Style) + $buffer .= "\x1b[".$cursorPos['shape'].' q'; + + $this->terminal->write($buffer); + + $this->hardwareCursorRow = $targetRow; + + if ($this->showHardwareCursor) { + $this->terminal->showCursor(); + } else { + $this->terminal->hideCursor(); + } + } +} diff --git a/src/Symfony/Component/Tui/Render/WidgetRect.php b/src/Symfony/Component/Tui/Render/WidgetRect.php new file mode 100644 index 0000000000000..a6ae2dbfc80a0 --- /dev/null +++ b/src/Symfony/Component/Tui/Render/WidgetRect.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Render; + +/** + * Represents the absolute position and size of a rendered widget on screen. + * + * Coordinates are in terminal character cells, with (0, 0) at the top-left. + * + * @experimental + * + * @author Fabien Potencier + */ +final readonly class WidgetRect +{ + public function __construct( + private int $row, + private int $col, + private int $columns, + private int $rows, + ) { + } + + public function getRow(): int + { + return $this->row; + } + + public function getCol(): int + { + return $this->col; + } + + public function getColumns(): int + { + return $this->columns; + } + + public function getRows(): int + { + return $this->rows; + } + + /** + * Check if the given screen coordinates are within this rect. + * + * @param int $row 0-indexed row + * @param int $col 0-indexed column + */ + public function contains(int $row, int $col): bool + { + return $row >= $this->row + && $row < $this->row + $this->rows + && $col >= $this->col + && $col < $this->col + $this->columns; + } + + /** + * Convert absolute screen coordinates to widget-relative coordinates. + * + * @return array{row: int, col: int} Widget-relative coordinates + */ + public function toRelative(int $row, int $col): array + { + return [ + 'row' => $row - $this->row, + 'col' => $col - $this->col, + ]; + } +} diff --git a/src/Symfony/Component/Tui/Render/WidgetRendererInterface.php b/src/Symfony/Component/Tui/Render/WidgetRendererInterface.php new file mode 100644 index 0000000000000..c970f789dcb2e --- /dev/null +++ b/src/Symfony/Component/Tui/Render/WidgetRendererInterface.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Render; + +use Symfony\Component\Tui\Style\Style; +use Symfony\Component\Tui\Widget\AbstractWidget; + +/** + * Interface for rendering individual widgets and resolving their styles. + * + * Used by LayoutEngine and ChromeApplier to call back into the Renderer + * without creating a circular class dependency. + * + * @experimental + * + * @author Fabien Potencier + */ +interface WidgetRendererInterface +{ + /** + * Render a single widget through the full pipeline. + * + * @return string[] + */ + public function renderWidget(AbstractWidget $widget, RenderContext $context): array; + + /** + * Resolve the style for a widget by merging cascade layers. + */ + public function resolveStyle(AbstractWidget $widget): Style; + + /** + * Measure the intrinsic width of a widget: content width + chrome (border/padding). + * + * Unlike renderWidget(), this does not pad lines to the allocated width. + * Used by the layout engine to measure flex: 0 children. + */ + public function measureIntrinsicWidth(AbstractWidget $widget, int $maxColumns, int $rows): int; +} diff --git a/src/Symfony/Component/Tui/Style/Align.php b/src/Symfony/Component/Tui/Style/Align.php new file mode 100644 index 0000000000000..cc0e36ccc7270 --- /dev/null +++ b/src/Symfony/Component/Tui/Style/Align.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Style; + +/** + * Horizontal alignment of child widgets within a container. + * + * Controls how a child widget's block is positioned when it renders + * narrower than the container's available width (e.g. due to maxColumns). + * + * @experimental + * + * @author Fabien Potencier + */ +enum Align: string +{ + case Left = 'left'; + case Center = 'center'; + case Right = 'right'; +} diff --git a/src/Symfony/Component/Tui/Style/Border.php b/src/Symfony/Component/Tui/Style/Border.php new file mode 100644 index 0000000000000..95028118417ea --- /dev/null +++ b/src/Symfony/Component/Tui/Style/Border.php @@ -0,0 +1,238 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Style; + +use Symfony\Component\Tui\Exception\InvalidArgumentException; + +/** + * Represents border values like CSS border. + * + * Supports 1, 2, 3, or 4 values: + * - 1 value: all sides + * - 2 values: top/bottom, left/right + * - 3 values: top, left/right, bottom + * - 4 values: top, right, bottom, left + * + * @experimental + * + * @author Fabien Potencier + */ +final class Border +{ + private const DEFAULT_PATTERN = BorderPattern::NORMAL; + + private readonly int $top; + private readonly int $right; + private readonly int $bottom; + private readonly int $left; + + private readonly BorderPattern $pattern; + private readonly ?Color $color; + + public function __construct( + int $top, + int $right, + int $bottom, + int $left, + BorderPattern|string|null $pattern = null, + Color|string|int|null $color = null, + ) { + $this->top = max(0, $top); + $this->right = max(0, $right); + $this->bottom = max(0, $bottom); + $this->left = max(0, $left); + $this->pattern = self::normalizePattern($pattern); + $this->color = null !== $color ? Color::from($color) : null; + } + + public function getTop(): int + { + return $this->top; + } + + public function getRight(): int + { + return $this->right; + } + + public function getBottom(): int + { + return $this->bottom; + } + + public function getLeft(): int + { + return $this->left; + } + + /** + * Create a Border from various input formats. + * + * @param self|array $border Border specification: + * - Border instance: returned as-is + * - array with 1 element: all sides + * - array with 2 elements: [top/bottom, left/right] + * - array with 3 elements: [top, left/right, bottom] + * - array with 4 elements: [top, right, bottom, left] + */ + public static function from(self|array $border, BorderPattern|string|null $pattern = null, Color|string|int|null $color = null): self + { + if ($border instanceof self) { + if (null === $pattern && null === $color) { + return $border; + } + + return new self($border->top, $border->right, $border->bottom, $border->left, $pattern ?? $border->pattern, $color ?? $border->color); + } + + return match (\count($border)) { + 1 => new self($border[0], $border[0], $border[0], $border[0], $pattern, $color), + 2 => new self($border[0], $border[1], $border[0], $border[1], $pattern, $color), + 3 => new self($border[0], $border[1], $border[2], $border[1], $pattern, $color), + 4 => new self($border[0], $border[1], $border[2], $border[3], $pattern, $color), + default => throw new InvalidArgumentException('Border array must have 1, 2, 3, or 4 elements'), + }; + } + + /** + * Create a border with all sides equal. + */ + public static function all(int $value, BorderPattern|string|null $pattern = null, Color|string|int|null $color = null): self + { + return new self($value, $value, $value, $value, $pattern, $color); + } + + /** + * Create a border with horizontal and vertical values. + * + * @param int $x Left/right border + * @param int $y Top/bottom border + */ + public static function xy(int $x, int $y = 0, BorderPattern|string|null $pattern = null, Color|string|int|null $color = null): self + { + return new self($y, $x, $y, $x, $pattern, $color); + } + + /** + * Get horizontal border (left + right). + */ + public function getHorizontal(): int + { + return $this->left + $this->right; + } + + /** + * Get vertical border (top + bottom). + */ + public function getVertical(): int + { + return $this->top + $this->bottom; + } + + /** + * Get the border pattern. + */ + public function getPattern(): BorderPattern + { + return $this->pattern; + } + + /** + * Get the border color. + */ + public function getColor(): ?Color + { + return $this->color; + } + + /** + * Create a new border with a different pattern. + */ + public function withPattern(BorderPattern|string|null $pattern): self + { + return new self($this->top, $this->right, $this->bottom, $this->left, $pattern, $this->color); + } + + /** + * Create a new border with a different color. + */ + public function withColor(Color|string|int|null $color): self + { + return new self($this->top, $this->right, $this->bottom, $this->left, $this->pattern, $color); + } + + /** + * @param string[] $innerLines + * + * @return string[] + * + * @internal + */ + public function wrapLines(array $innerLines, int $innerWidth, Style $innerStyle, ?Style $outerStyle = null): array + { + $pattern = $this->pattern; + $chars = $pattern->getChars(); + $strategies = $pattern->getStrategies(); + + $outerStyle = $outerStyle ?? new Style(); + $borderColor = $this->color ?? $innerStyle->getColor(); + + $lines = []; + + if ($this->top > 0) { + for ($row = 0; $row < $this->top; ++$row) { + $lines[] = $this->buildBorderRow($pattern, $chars[0], $strategies[0], $innerWidth, $this->left, $this->right, $outerStyle, $innerStyle, $borderColor); + } + } + + $leftSegment = $this->left > 0 ? $pattern->applyBorderSegment(str_repeat('' !== $chars[1][0] ? $chars[1][0] : ' ', $this->left), $strategies[1][0], $outerStyle, $innerStyle, $borderColor) : ''; + $rightSegment = $this->right > 0 ? $pattern->applyBorderSegment(str_repeat('' !== $chars[1][2] ? $chars[1][2] : ' ', $this->right), $strategies[1][2], $outerStyle, $innerStyle, $borderColor) : ''; + + foreach ($innerLines as $line) { + $lines[] = $leftSegment.$line.$rightSegment; + } + + if ($this->bottom > 0) { + for ($row = 0; $row < $this->bottom; ++$row) { + $lines[] = $this->buildBorderRow($pattern, $chars[2], $strategies[2], $innerWidth, $this->left, $this->right, $outerStyle, $innerStyle, $borderColor); + } + } + + return $lines; + } + + /** + * @param array $chars + * @param array $strategies + */ + private function buildBorderRow(BorderPattern $pattern, array $chars, array $strategies, int $innerWidth, int $leftWidth, int $rightWidth, Style $outerStyle, Style $innerStyle, ?Color $borderColor): string + { + $left = $leftWidth > 0 + ? $pattern->applyBorderSegment(str_repeat('' !== $chars[0] ? $chars[0] : ' ', $leftWidth), $strategies[0], $outerStyle, $innerStyle, $borderColor) + : ''; + $middle = $pattern->applyBorderSegment(str_repeat('' !== $chars[1] ? $chars[1] : ' ', $innerWidth), $strategies[1], $outerStyle, $innerStyle, $borderColor); + $right = $rightWidth > 0 + ? $pattern->applyBorderSegment(str_repeat('' !== $chars[2] ? $chars[2] : ' ', $rightWidth), $strategies[2], $outerStyle, $innerStyle, $borderColor) + : ''; + + return $left.$middle.$right; + } + + private static function normalizePattern(BorderPattern|string|null $pattern): BorderPattern + { + if ($pattern instanceof BorderPattern) { + return $pattern; + } + + return BorderPattern::fromName($pattern ?? self::DEFAULT_PATTERN); + } +} diff --git a/src/Symfony/Component/Tui/Style/BorderPattern.php b/src/Symfony/Component/Tui/Style/BorderPattern.php new file mode 100644 index 0000000000000..6171cf49eff2b --- /dev/null +++ b/src/Symfony/Component/Tui/Style/BorderPattern.php @@ -0,0 +1,447 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Style; + +use Symfony\Component\Tui\Exception\InvalidArgumentException; + +/** + * Defines border pattern character and strategy matrices. + * + * The 3x3 strategy matrix is used by renderers to decide how to swap colors + * for block-style borders: + * - 0: border color on inner background (standard border rendering) + * - 1: border color on outer background (blend with outer background) + * - 2: outer background on border color (inverse left-style border) + * - 3: inner background on border color (inverse right-style border) + * + * @experimental + * + * @author Fabien Potencier + */ +final class BorderPattern +{ + public const NONE = 'none'; + public const NORMAL = 'normal'; + public const ROUNDED = 'rounded'; + public const DOUBLE = 'double'; + public const TALL = 'tall'; + public const WIDE = 'wide'; + public const TALL_MEDIUM = 'tall-medium'; + public const WIDE_MEDIUM = 'wide-medium'; + public const TALL_LARGE = 'tall-large'; + public const WIDE_LARGE = 'wide-large'; + + /** + * @param array> $chars + * @param array> $strategies + */ + public function __construct( + private array $chars = [ + ['', '', ''], + ['', '', ''], + ['', '', ''], + ], + private array $strategies = [ + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + ], + ) { + } + + /** + * @return array> + */ + public function getChars(): array + { + return $this->chars; + } + + /** + * @return array> + */ + public function getStrategies(): array + { + return $this->strategies; + } + + public function applyBorderSegment( + string $segment, + int $strategy, + Style $outerStyle, + Style $innerStyle, + ?Color $borderColor = null, + ): string { + $segment = '' !== $segment ? $segment : ' '; + + $outerForeground = $outerStyle->getColor(); + $outerBackground = $outerStyle->getBackground(); + $innerBackground = $innerStyle->getBackground(); + + return match ($strategy) { + 1 => $this->applyColors($segment, $borderColor, $outerBackground, $outerForeground, $outerBackground), + 2 => $this->applyColors($segment, $outerBackground, $borderColor, $outerForeground, $outerBackground), + 3 => $this->applyColors($segment, $innerBackground, $borderColor, $outerForeground, $outerBackground), + default => $this->applyColors($segment, $borderColor, $innerBackground, $outerForeground, $outerBackground), + }; + } + + public function applyInnerSegment(string $segment, Style $outerStyle, Style $innerStyle): string + { + return $this->applyColors( + $segment, + $innerStyle->getColor(), + $innerStyle->getBackground(), + $outerStyle->getColor(), + $outerStyle->getBackground(), + ); + } + + public function isNone(): bool + { + foreach ($this->chars as $row) { + foreach ($row as $char) { + if ('' !== $char) { + return false; + } + } + } + + return true; + } + + /** + * @return array{string, int} + */ + public function getTop(): array + { + return [$this->chars[0][1], $this->strategies[0][1]]; + } + + public function top(string $char, int $strategy = 0): static + { + $t = clone $this; + $t->chars[0][1] = $char; + $t->strategies[0][1] = $strategy; + + return $t; + } + + /** + * @return array{string, int} + */ + public function getBottom(): array + { + return [$this->chars[2][1], $this->strategies[2][1]]; + } + + public function bottom(string $char, int $strategy = 0): static + { + $t = clone $this; + $t->chars[2][1] = $char; + $t->strategies[2][1] = $strategy; + + return $t; + } + + /** + * @return array{string, int} + */ + public function getLeft(): array + { + return [$this->chars[1][0], $this->strategies[1][0]]; + } + + public function left(string $char, int $strategy = 0): static + { + $t = clone $this; + $t->chars[1][0] = $char; + $t->strategies[1][0] = $strategy; + + return $t; + } + + /** + * @return array{string, int} + */ + public function getRight(): array + { + return [$this->chars[1][2], $this->strategies[1][2]]; + } + + public function right(string $char, int $strategy = 0): static + { + $t = clone $this; + $t->chars[1][2] = $char; + $t->strategies[1][2] = $strategy; + + return $t; + } + + public function topLeft(string $char, int $strategy = 0): static + { + $t = clone $this; + $t->chars[0][0] = $char; + $t->strategies[0][0] = $strategy; + + return $t; + } + + public function topRight(string $char, int $strategy = 0): static + { + $t = clone $this; + $t->chars[0][2] = $char; + $t->strategies[0][2] = $strategy; + + return $t; + } + + public function bottomRight(string $char, int $strategy = 0): static + { + $t = clone $this; + $t->chars[2][2] = $char; + $t->strategies[2][2] = $strategy; + + return $t; + } + + public function bottomLeft(string $char, int $strategy = 0): static + { + $t = clone $this; + $t->chars[2][0] = $char; + $t->strategies[2][0] = $strategy; + + return $t; + } + + public static function fromName(string $style): self + { + return match ($style) { + self::NONE => new self(), + self::NORMAL => self::normal(), + self::ROUNDED => self::rounded(), + self::DOUBLE => self::double(), + self::TALL => self::tall(), + self::WIDE => self::wide(), + self::TALL_MEDIUM => self::tallMedium(), + self::WIDE_MEDIUM => self::wideMedium(), + self::TALL_LARGE => self::tallLarge(), + self::WIDE_LARGE => self::wideLarge(), + default => throw new InvalidArgumentException(sprintf('Unknown border pattern "%s".', $style)), + }; + } + + public static function normal(): self + { + return new self( + [ + ['┌', '─', '┐'], + ['│', ' ', '│'], + ['└', '─', '┘'], + ], + ); + } + + public static function rounded(): self + { + return new self( + [ + ['╭', '─', '╮'], + ['│', ' ', '│'], + ['╰', '─', '╯'], + ], + ); + } + + public static function double(): self + { + return new self( + [ + ['╔', '═', '╗'], + ['║', ' ', '║'], + ['╚', '═', '╝'], + ], + ); + } + + public static function tall(): self + { + return new self( + [ + ['▊', '▔', '▎'], + ['▊', ' ', '▎'], + ['▊', '▁', '▎'], + ], + [ + [2, 0, 1], + [2, 0, 1], + [2, 0, 1], + ], + ); + } + + public static function wide(): self + { + return new self( + [ + ['▁', '▁', '▁'], + ['▎', ' ', '▊'], + ['▔', '▔', '▔'], + ], + [ + [1, 1, 1], + [0, 1, 3], + [1, 1, 1], + ], + ); + } + + /** + * Visually uniform 4px border with tall-style corners. + * + * Terminal cells are ~2× taller than wide, so 2px vertical (▆/▂) is + * paired with 4px horizontal (▌) for a balanced appearance (2px vertical + * appears as ~4px perceived with the 1:2 cell aspect ratio). Uses the + * same technique as tall(): left-aligned block character (▌) with + * strategy 2 for the left column (fg=outer fills left half, bg=border + * fills right half) and strategy 1 for the right column (fg=border + * fills left half, bg=outer fills right half). Top uses ▆ (lower 6/8) + * with strategy 3 (fg=inner, bg=border) to place the 2px border at the + * top of the cell. Bottom uses ▂ (lower 2/8) with strategy 0 (fg=border, + * bg=inner) to place the 2px border at the bottom. The side character + * extends through all rows including corners. + */ + public static function tallMedium(): self + { + return new self( + [ + ['▌', '▆', '▌'], + ['▌', ' ', '▌'], + ['▌', '▂', '▌'], + ], + [ + [2, 3, 1], + [2, 0, 1], + [2, 0, 1], + ], + ); + } + + /** + * Visually uniform ~4px border with wide-style corners. + * + * Terminal cells are ~2× taller than wide, so 2px vertical (▂/▆) is + * paired with 4px horizontal (▌) for a balanced appearance (2px vertical + * appears as ~4px perceived with the 1:2 cell aspect ratio). Uses the + * same technique as wide(): the horizontal bar character extends through + * all columns including corners. Top uses ▂ (lower 2/8) with strategy 1 + * (fg=border, bg=outer) to place the 2px border at the bottom of the + * cell. Bottom uses ▆ (lower 6/8) with strategy 2 (fg=outer, bg=border) + * to place the 2px border at the top. Left uses ▌ (left 4/8) with + * strategy 0 (fg=border, bg=inner). Right uses ▌ with strategy 3 + * (fg=inner, bg=border). + */ + public static function wideMedium(): self + { + return new self( + [ + ['▂', '▂', '▂'], + ['▌', ' ', '▌'], + ['▆', '▆', '▆'], + ], + [ + [1, 1, 1], + [0, 0, 3], + [2, 2, 2], + ], + ); + } + + /** + * Visually uniform ~8px border with tall-style corners. + * + * Terminal cells are ~2× taller than wide, so 4px vertical (▀/▄) is + * paired with 8px horizontal (█) for a balanced appearance (4px vertical + * appears as ~8px perceived with the 1:2 cell aspect ratio). Uses the + * same technique as tall(): the side character extends through all rows + * including corners. Since the sides are full-cell width (█), the corners + * are solid border color, differing from wide-large where corners show + * outer background in the non-bar half. Top/bottom use strategy 0 + * (fg=border, bg=inner) so that no outer background bleeds into the + * top/bottom bars: top uses ▀ (upper half = border, lower half = inner) + * and bottom uses ▄ (lower half = border, upper half = inner). + */ + public static function tallLarge(): self + { + return new self( + [ + ['█', '▀', '█'], + ['█', ' ', '█'], + ['█', '▄', '█'], + ], + [ + [1, 0, 1], + [1, 0, 1], + [1, 0, 1], + ], + ); + } + + /** + * Visually uniform ~8px border with wide-style corners. + * + * Terminal cells are ~2× taller than wide, so 4px vertical (▄/▀) is paired + * with 8px horizontal (█) for a balanced appearance. Corners repeat the + * horizontal bar character of their row since the side bars are full-cell + * width and naturally fill the corners. The outer background shows through + * the non-bar half of the corner cells. + */ + public static function wideLarge(): self + { + return new self( + [ + ['▄', '▄', '▄'], + ['█', ' ', '█'], + ['▀', '▀', '▀'], + ], + [ + [1, 1, 1], + [1, 0, 1], + [1, 1, 1], + ], + ); + } + + private function applyColors( + string $segment, + ?Color $foreground, + ?Color $background, + ?Color $outerForeground, + ?Color $outerBackground, + ): string { + return $this->foregroundCode($foreground) + .$this->backgroundCode($background) + .$segment + .$this->foregroundCode($outerForeground) + .$this->backgroundCode($outerBackground); + } + + private function foregroundCode(?Color $color): string + { + return $color?->toForegroundCode() ?? Color::resetForeground(); + } + + private function backgroundCode(?Color $color): string + { + return $color?->toBackgroundCode() ?? Color::resetBackground(); + } +} diff --git a/src/Symfony/Component/Tui/Style/Color.php b/src/Symfony/Component/Tui/Style/Color.php new file mode 100644 index 0000000000000..cb17cb3e22a35 --- /dev/null +++ b/src/Symfony/Component/Tui/Style/Color.php @@ -0,0 +1,387 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Style; + +use Symfony\Component\Tui\Exception\InvalidArgumentException; + +/** + * Represents a terminal color. + * + * Supports multiple color formats: + * - Basic 16 ANSI colors (named: 'black', 'red', 'green', etc.) + * - 256-color palette (integers 0-255) + * - True color RGB (hex strings like '#ff5500' or '#f50') + * + * @experimental + * + * @author Fabien Potencier + */ +final class Color +{ + // Basic 16 ANSI color codes (foreground) + private const BASIC_COLORS = [ + 'black' => 30, + 'red' => 31, + 'green' => 32, + 'yellow' => 33, + 'blue' => 34, + 'magenta' => 35, + 'cyan' => 36, + 'white' => 37, + 'default' => 39, + 'bright_black' => 90, + 'bright_red' => 91, + 'bright_green' => 92, + 'bright_yellow' => 93, + 'bright_blue' => 94, + 'bright_magenta' => 95, + 'bright_cyan' => 96, + 'bright_white' => 97, + // Aliases + 'gray' => 90, + 'grey' => 90, + ]; + + /** Standard RGB values for named ANSI colors (xterm defaults). */ + private const NAMED_RGB = [ + 'black' => [0, 0, 0], + 'red' => [205, 0, 0], + 'green' => [0, 205, 0], + 'yellow' => [205, 205, 0], + 'blue' => [0, 0, 238], + 'magenta' => [205, 0, 205], + 'cyan' => [0, 205, 205], + 'white' => [229, 229, 229], + 'default' => [229, 229, 229], + 'bright_black' => [127, 127, 127], + 'bright_red' => [255, 0, 0], + 'bright_green' => [0, 255, 0], + 'bright_yellow' => [255, 255, 0], + 'bright_blue' => [92, 92, 255], + 'bright_magenta' => [255, 0, 255], + 'bright_cyan' => [0, 255, 255], + 'bright_white' => [255, 255, 255], + 'gray' => [127, 127, 127], + 'grey' => [127, 127, 127], + ]; + + /** + * Create a color from a named ANSI color. + */ + public static function named(string $name): self + { + $name = strtolower($name); + if (!isset(self::BASIC_COLORS[$name])) { + throw new InvalidArgumentException(\sprintf('Unknown color name: %s', $name)); + } + + return new self(ColorType::Named, $name); + } + + /** + * Create a color from the 256-color palette. + */ + public static function palette(int $index): self + { + if ($index < 0 || $index > 255) { + throw new InvalidArgumentException(\sprintf('Color palette index must be 0-255, got: %d', $index)); + } + + return new self(ColorType::Palette, $index); + } + + /** + * Create a color from RGB hex string. + * + * @param string $hex Hex color like '#ff5500', 'ff5500', '#f50', or 'f50' + */ + public static function hex(string $hex): self + { + $hex = ltrim($hex, '#'); + + // Expand short form (#f50 -> #ff5500) + if (3 === \strlen($hex)) { + $hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2]; + } + + if (6 !== \strlen($hex) || !ctype_xdigit($hex)) { + throw new InvalidArgumentException(\sprintf('Invalid hex color: %s', $hex)); + } + + return new self(ColorType::Hex, $hex); + } + + /** + * Create a color from RGB values. + */ + public static function rgb(int $r, int $g, int $b): self + { + if ($r < 0 || $r > 255 || $g < 0 || $g > 255 || $b < 0 || $b > 255) { + throw new InvalidArgumentException(\sprintf('RGB values must be 0-255, got: %d, %d, %d', $r, $g, $b)); + } + + return new self(ColorType::Hex, \sprintf('%02x%02x%02x', $r, $g, $b)); + } + + /** + * Create a Color from various input types. + * + * @param string|int|self $color Color specification: + * - Color instance (returned as-is) + * - string starting with '#' -> hex color + * - string -> named color + * - int -> 256-palette index + */ + public static function from(string|int|self $color): self + { + if ($color instanceof self) { + return $color; + } + + if (\is_int($color)) { + return self::palette($color); + } + + if (str_starts_with($color, '#')) { + return self::hex($color); + } + + return self::named($color); + } + + /** + * Get the RGB components of this color. + * + * Named and palette colors are converted using standard xterm defaults. + * + * @return array{r: int, g: int, b: int} + */ + public function toRgb(): array + { + return match ($this->type) { + ColorType::Named => self::namedToRgb((string) $this->value), + ColorType::Palette => self::paletteToRgb((int) $this->value), + ColorType::Hex => self::hexToRgb((string) $this->value), + }; + } + + /** + * Mix this color with another by a given percentage. + * + * At 0 % the result is this color; at 100 % it is the other color. + * + * @param self|string $color The color to mix with + * @param int $percentage 0–100 + */ + public function mix(self|string $color, int $percentage): self + { + if ($percentage < 0 || $percentage > 100) { + throw new InvalidArgumentException(\sprintf('Percentage must be 0-100, got: %d', $percentage)); + } + + if (\is_string($color)) { + $color = self::from($color); + } + + $base = $this->toRgb(); + $other = $color->toRgb(); + $factor = $percentage / 100; + + return self::rgb( + (int) round($base['r'] * (1 - $factor) + $other['r'] * $factor), + (int) round($base['g'] * (1 - $factor) + $other['g'] * $factor), + (int) round($base['b'] * (1 - $factor) + $other['b'] * $factor), + ); + } + + /** + * Lighten this color by mixing it with white. + * + * @param int $percentage 0 (unchanged) to 100 (pure white) + */ + public function tint(int $percentage): self + { + return $this->mix('#ffffff', $percentage); + } + + /** + * Darken this color by mixing it with black. + * + * @param int $percentage 0 (unchanged) to 100 (pure black) + */ + public function shade(int $percentage): self + { + return $this->mix('#000000', $percentage); + } + + /** + * Lighten or darken this color. + * + * Positive values darken (shade), negative values lighten (tint). + * + * @param int $percentage -100 (white) to 100 (black) + */ + public function scale(int $percentage): self + { + return $percentage > 0 ? $this->shade($percentage) : $this->tint(-$percentage); + } + + /** + * Convert an SGR foreground color code (30-37, 90-97) to a Color. + * + * Returns null if the code is not a basic/bright foreground color. + */ + public static function fromSgrForeground(int $code): ?self + { + return match (true) { + $code >= 30 && $code <= 37 => self::palette($code - 30), + $code >= 90 && $code <= 97 => self::palette($code - 90 + 8), + default => null, + }; + } + + /** + * Convert an SGR background color code (40-47, 100-107) to a Color. + * + * Returns null if the code is not a basic/bright background color. + */ + public static function fromSgrBackground(int $code): ?self + { + return match (true) { + $code >= 40 && $code <= 47 => self::palette($code - 40), + $code >= 100 && $code <= 107 => self::palette($code - 100 + 8), + default => null, + }; + } + + /** + * Get the hex representation of this color (e.g. '#ff5500'). + */ + public function toHex(): string + { + $rgb = $this->toRgb(); + + return \sprintf('#%02x%02x%02x', $rgb['r'], $rgb['g'], $rgb['b']); + } + + /** + * Get the ANSI escape code for this color as foreground. + */ + public function toForegroundCode(): string + { + return match ($this->type) { + ColorType::Named => \sprintf("\x1b[%dm", self::BASIC_COLORS[(string) $this->value]), + ColorType::Palette => \sprintf("\x1b[38;5;%dm", (int) $this->value), + ColorType::Hex => \sprintf( + "\x1b[38;2;%d;%d;%dm", + hexdec(substr((string) $this->value, 0, 2)), + hexdec(substr((string) $this->value, 2, 2)), + hexdec(substr((string) $this->value, 4, 2)) + ), + }; + } + + /** + * Get the ANSI escape code for this color as background. + */ + public function toBackgroundCode(): string + { + return match ($this->type) { + ColorType::Named => \sprintf("\x1b[%dm", self::BASIC_COLORS[(string) $this->value] + 10), + ColorType::Palette => \sprintf("\x1b[48;5;%dm", (int) $this->value), + ColorType::Hex => \sprintf( + "\x1b[48;2;%d;%d;%dm", + hexdec(substr((string) $this->value, 0, 2)), + hexdec(substr((string) $this->value, 2, 2)), + hexdec(substr((string) $this->value, 4, 2)) + ), + }; + } + + /** + * Get the ANSI reset code for foreground color. + */ + public static function resetForeground(): string + { + return "\x1b[39m"; + } + + /** + * Get the ANSI reset code for background color. + */ + public static function resetBackground(): string + { + return "\x1b[49m"; + } + + private function __construct( + private readonly ColorType $type, + private readonly int|string $value, + ) { + } + + /** + * @return array{r: int, g: int, b: int} + */ + private static function namedToRgb(string $name): array + { + $rgb = self::NAMED_RGB[$name]; + + return ['r' => $rgb[0], 'g' => $rgb[1], 'b' => $rgb[2]]; + } + + /** + * @return array{r: int, g: int, b: int} + */ + private static function paletteToRgb(int $index): array + { + // 0–15: basic 16 colors + if ($index < 16) { + $names = [ + 'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', + 'bright_black', 'bright_red', 'bright_green', 'bright_yellow', + 'bright_blue', 'bright_magenta', 'bright_cyan', 'bright_white', + ]; + + return self::namedToRgb($names[$index]); + } + + // 16–231: 6×6×6 color cube + if ($index < 232) { + $i = $index - 16; + $levels = [0, 95, 135, 175, 215, 255]; + + return [ + 'r' => $levels[(int) ($i / 36)], + 'g' => $levels[(int) (($i % 36) / 6)], + 'b' => $levels[$i % 6], + ]; + } + + // 232–255: grayscale ramp + $level = 8 + 10 * ($index - 232); + + return ['r' => $level, 'g' => $level, 'b' => $level]; + } + + /** + * @return array{r: int, g: int, b: int} + */ + private static function hexToRgb(string $hex): array + { + return [ + 'r' => (int) hexdec(substr($hex, 0, 2)), + 'g' => (int) hexdec(substr($hex, 2, 2)), + 'b' => (int) hexdec(substr($hex, 4, 2)), + ]; + } +} diff --git a/src/Symfony/Component/Tui/Style/ColorType.php b/src/Symfony/Component/Tui/Style/ColorType.php new file mode 100644 index 0000000000000..01fd558fde01f --- /dev/null +++ b/src/Symfony/Component/Tui/Style/ColorType.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Style; + +/** + * Represents the type of a terminal color. + * + * @experimental + * + * @author Fabien Potencier + */ +enum ColorType +{ + /** Basic 16 ANSI colors (named: 'black', 'red', 'green', etc.) */ + case Named; + + /** 256-color palette (integers 0-255) */ + case Palette; + + /** True color RGB (hex strings like '#ff5500' or '#f50') */ + case Hex; +} diff --git a/src/Symfony/Component/Tui/Style/CursorShape.php b/src/Symfony/Component/Tui/Style/CursorShape.php new file mode 100644 index 0000000000000..af026ab3db9db --- /dev/null +++ b/src/Symfony/Component/Tui/Style/CursorShape.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Style; + +/** + * Terminal cursor shape, mapped to DECSCUSR escape sequences. + * + * These values correspond to the parameter N in `ESC [ N SP q` + * (DECSCUSR: Set Cursor Style). Odd values produce a blinking + * cursor; even values produce a steady one. We use the blinking + * variants so the terminal provides native cursor animation at + * zero CPU cost. + * + * @experimental + * + * @author Fabien Potencier + */ +enum CursorShape: int +{ + case Block = 1; + case Underline = 3; + case Bar = 5; +} diff --git a/src/Symfony/Component/Tui/Style/DefaultStyleSheet.php b/src/Symfony/Component/Tui/Style/DefaultStyleSheet.php new file mode 100644 index 0000000000000..fbec41454aec2 --- /dev/null +++ b/src/Symfony/Component/Tui/Style/DefaultStyleSheet.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Style; + +use Symfony\Component\Tui\Widget\CancellableLoaderWidget; +use Symfony\Component\Tui\Widget\EditorWidget; +use Symfony\Component\Tui\Widget\InputWidget; +use Symfony\Component\Tui\Widget\LoaderWidget; +use Symfony\Component\Tui\Widget\MarkdownWidget; +use Symfony\Component\Tui\Widget\SelectListWidget; +use Symfony\Component\Tui\Widget\SettingsListWidget; + +/** + * Default TUI stylesheet with base styling rules. + * + * Provides sensible defaults for all core widget sub-elements. + * These can be overridden by application or theme stylesheets + * via the cascade mechanism. + * + * @experimental + * + * @author Fabien Potencier + */ +final class DefaultStyleSheet +{ + public static function create(): StyleSheet + { + return new StyleSheet([ + // Heading classes (used by

tag aliases) + '.h1' => new Style(bold: true, color: 'cyan'), + '.h2' => new Style(bold: true, color: 'blue'), + '.h3' => new Style(bold: true), + '.h4' => new Style(bold: true, dim: true), + '.h5' => new Style(dim: true), + '.h6' => new Style(dim: true, italic: true), + '.hr' => new Style(color: 'gray'), + '.p' => new Style(), + + // Layout aliases (used by / tag aliases) + '.columns' => new Style(direction: Direction::Horizontal, gap: 2), + '.column' => new Style(), + + // CancellableLoaderWidget + CancellableLoaderWidget::class.':focused' => new Style()->withBold(), + + // LoaderWidget + LoaderWidget::class.'::spinner' => new Style()->withColor('cyan'), + LoaderWidget::class.'::message' => new Style()->withColor('gray'), + + // InputWidget + InputWidget::class.'::cursor' => new Style(cursorShape: CursorShape::Block), + + // EditorWidget + EditorWidget::class.'::cursor' => new Style(cursorShape: CursorShape::Block), + EditorWidget::class.'::frame' => new Style()->withColor('gray'), + + // SelectListWidget + SelectListWidget::class.'::selected' => new Style()->withBold(), + SelectListWidget::class.'::selected:focused' => new Style()->withBold(), + SelectListWidget::class.'::description' => new Style()->withColor('gray'), + SelectListWidget::class.'::scroll-info' => new Style()->withColor('gray'), + SelectListWidget::class.'::no-match' => new Style()->withColor('yellow'), + + // SettingsListWidget + SettingsListWidget::class.'::label-selected' => new Style()->withBold(), + SettingsListWidget::class.'::label-selected:focused' => new Style()->withBold(), + SettingsListWidget::class.'::value' => new Style()->withColor('gray'), + SettingsListWidget::class.'::value-selected' => new Style()->withColor('cyan'), + SettingsListWidget::class.'::value-selected:focused' => new Style()->withColor('cyan'), + SettingsListWidget::class.'::description' => new Style()->withColor('gray'), + SettingsListWidget::class.'::hint' => new Style()->withColor('gray'), + + // MarkdownWidget + MarkdownWidget::class.'::heading' => new Style()->withColor('cyan')->withBold(), + MarkdownWidget::class.'::link' => new Style()->withColor('blue')->withUnderline(), + MarkdownWidget::class.'::link-url' => new Style()->withColor('gray'), + MarkdownWidget::class.'::code' => new Style()->withColor('yellow'), + MarkdownWidget::class.'::code-block-border' => new Style()->withColor('gray'), + MarkdownWidget::class.'::quote' => new Style()->withItalic(), + MarkdownWidget::class.'::quote-border' => new Style()->withColor('gray'), + MarkdownWidget::class.'::hr' => new Style()->withColor('gray'), + MarkdownWidget::class.'::list-bullet' => new Style()->withColor('cyan'), + MarkdownWidget::class.'::bold' => new Style()->withBold(), + MarkdownWidget::class.'::italic' => new Style()->withItalic(), + MarkdownWidget::class.'::strikethrough' => new Style()->withStrikethrough(), + ]); + } +} diff --git a/src/Symfony/Component/Tui/Style/Direction.php b/src/Symfony/Component/Tui/Style/Direction.php new file mode 100644 index 0000000000000..bb053d6694f13 --- /dev/null +++ b/src/Symfony/Component/Tui/Style/Direction.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Style; + +/** + * Layout direction for container widgets. + * + * @experimental + * + * @author Fabien Potencier + */ +enum Direction: string +{ + case Vertical = 'vertical'; + case Horizontal = 'horizontal'; +} diff --git a/src/Symfony/Component/Tui/Style/Padding.php b/src/Symfony/Component/Tui/Style/Padding.php new file mode 100644 index 0000000000000..4fe7d1ed14e83 --- /dev/null +++ b/src/Symfony/Component/Tui/Style/Padding.php @@ -0,0 +1,123 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Style; + +use Symfony\Component\Tui\Exception\InvalidArgumentException; + +/** + * Represents padding values like CSS padding. + * + * Supports 1, 2, 3, or 4 values: + * - 1 value: all sides + * - 2 values: top/bottom, left/right + * - 3 values: top, left/right, bottom + * - 4 values: top, right, bottom, left + * + * @experimental + * + * @author Fabien Potencier + */ +final class Padding +{ + private readonly int $top; + private readonly int $right; + private readonly int $bottom; + private readonly int $left; + + public function __construct(int $top, int $right, int $bottom, int $left) + { + $this->top = max(0, $top); + $this->right = max(0, $right); + $this->bottom = max(0, $bottom); + $this->left = max(0, $left); + } + + public function getTop(): int + { + return $this->top; + } + + public function getRight(): int + { + return $this->right; + } + + public function getBottom(): int + { + return $this->bottom; + } + + public function getLeft(): int + { + return $this->left; + } + + /** + * Create a Padding from various input formats. + * + * @param self|array $padding Padding specification: + * - Padding instance: returned as-is + * - array with 1 element: all sides + * - array with 2 elements: [top/bottom, left/right] + * - array with 3 elements: [top, left/right, bottom] + * - array with 4 elements: [top, right, bottom, left] + */ + public static function from(self|array $padding): self + { + if ($padding instanceof self) { + return $padding; + } + + return match (\count($padding)) { + 1 => new self($padding[0], $padding[0], $padding[0], $padding[0]), + 2 => new self($padding[0], $padding[1], $padding[0], $padding[1]), + 3 => new self($padding[0], $padding[1], $padding[2], $padding[1]), + 4 => new self($padding[0], $padding[1], $padding[2], $padding[3]), + default => throw new InvalidArgumentException('Padding array must have 1, 2, 3, or 4 elements'), + }; + } + + /** + * Create padding with all sides equal. + */ + public static function all(int $value): self + { + return new self($value, $value, $value, $value); + } + + /** + * Create padding with horizontal and vertical values. + * + * @param int $x Left/right padding + * @param int $y Top/bottom padding + */ + public static function xy(int $x, int $y = 0): self + { + return new self($y, $x, $y, $x); + } + + /** + * Get horizontal padding (left + right). + */ + public function getHorizontal(): int + { + return $this->left + $this->right; + } + + /** + * Get vertical padding (top + bottom). + */ + public function getVertical(): int + { + return $this->top + $this->bottom; + } +} diff --git a/src/Symfony/Component/Tui/Style/Style.php b/src/Symfony/Component/Tui/Style/Style.php new file mode 100644 index 0000000000000..184af132620e0 --- /dev/null +++ b/src/Symfony/Component/Tui/Style/Style.php @@ -0,0 +1,823 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Style; + +use Symfony\Component\Tui\Ansi\AnsiUtils; + +/** + * Represents styling options for widgets including padding, borders, background, and text formatting. + * + * This class is an immutable value object. All with*() methods return a new + * instance rather than modifying the existing one. This design allows styles + * to be safely shared and reused without risk of unintended side effects. + * + * ## Nullable Properties for Style Inheritance + * + * All style properties are nullable to distinguish between "not set" and "explicitly set": + * + * - `null` means "not set" - the value will be inherited from parent styles during merge + * - An explicit value (even if zero/false) means "explicitly set" - overrides inheritance + * + * This applies to: + * - `padding` - null (inherit) vs Padding instance (explicit, even if all zeros) + * - `border` - null (inherit) vs Border instance (explicit, even if all zeros) + * - `background` - null (inherit) vs Color instance (explicit color) + * - `color` - null (inherit) vs Color instance (explicit color) + * - `bold`, `dim`, `italic`, `strikethrough`, `underline`, `reverse` - null (inherit) vs bool (explicit true/false) + * - `hidden` - null (inherit) vs bool (true = hidden, false = explicitly visible) + * + * Examples: + * + * // Color only - other properties will inherit from parent rules + * $style = new Style()->withColor('red'); + * $style->getPadding(); // null - will inherit + * $style->getBold(); // null - will inherit + * + * // Explicit bold=false to override a parent's bold=true + * $style = new Style()->withBold(false); + * $style->getBold(); // false - explicitly disabled + * + * // Explicit zero padding - will override inherited padding + * $style = Style::padding([0]); + * $style->getPadding(); // Padding(0, 0, 0, 0) - explicit zero + * + * Compare with Tui, which is a stateful service that returns $this from fluent + * methods to maintain object identity across the application. + * + * @experimental + * + * @author Fabien Potencier + */ +final class Style +{ + private ?Color $backgroundColor; + private ?Color $foregroundColor; + private ?string $ansiPrefix = null; + private ?string $ansiSuffix = null; + private ?string $bgCode = null; + + /** + * @param Padding|null $padding Padding specification (null = not set, see Padding::from()) + * @param Border|null $border Border specification (null = not set, see Border::from()) + * @param Color|string|int|null $background Background color (null = not set) + * @param Color|string|int|null $color Foreground color (null = not set) + * @param bool|null $bold Bold text (null = not set, true/false = explicit) + * @param bool|null $dim Dim/faint text (null = not set, true/false = explicit) + * @param bool|null $italic Italic text (null = not set, true/false = explicit) + * @param bool|null $strikethrough Strikethrough text (null = not set, true/false = explicit) + * @param bool|null $underline Underlined text (null = not set, true/false = explicit) + * @param bool|null $reverse Reverse video (null = not set, true/false = explicit) + * @param Direction|null $direction Layout direction for containers (null = not set, defaults to vertical) + * @param int|null $gap Gap between children for containers (null = not set, defaults to 0) + * @param bool|null $hidden Whether the widget is hidden (null = not set, true = hidden, false = visible) + * @param CursorShape|null $cursorShape Cursor shape for ::cursor sub-elements (null = not set) + * @param TextAlign|null $textAlign Text alignment (null = not set, defaults to left) + * @param string|null $font FIGlet font name or path (null = not set, defaults to normal text) + * @param int|null $maxColumns Maximum width in columns (null = not set, no constraint) + * @param Align|null $align Horizontal alignment of child widgets (null = not set, defaults to left) + * @param VerticalAlign|null $verticalAlign Vertical alignment of child widgets (null = not set, defaults to top) + * @param int|null $flex Flex grow weight for horizontal layouts (null = not set, 0 = intrinsic width, 1+ = proportional) + */ + public function __construct( + private ?Padding $padding = null, + private ?Border $border = null, + Color|string|int|null $background = null, + Color|string|int|null $color = null, + private ?bool $bold = null, + private ?bool $dim = null, + private ?bool $italic = null, + private ?bool $strikethrough = null, + private ?bool $underline = null, + private ?bool $reverse = null, + private ?Direction $direction = null, + private ?int $gap = null, + private ?bool $hidden = null, + private ?CursorShape $cursorShape = null, + private ?TextAlign $textAlign = null, + private ?string $font = null, + private ?int $maxColumns = null, + private ?Align $align = null, + private ?VerticalAlign $verticalAlign = null, + private ?int $flex = null, + ) { + $this->backgroundColor = null !== $background ? Color::from($background) : null; + $this->foregroundColor = null !== $color ? Color::from($color) : null; + $this->gap = null !== $gap ? max(0, $gap) : null; + $this->flex = null !== $flex ? max(0, $flex) : null; + } + + public function __clone(): void + { + $this->ansiPrefix = null; + $this->ansiSuffix = null; + $this->bgCode = null; + } + + /** + * Get the background color. + */ + public function getBackground(): ?Color + { + return $this->backgroundColor; + } + + /** + * Get the foreground color. + */ + public function getColor(): ?Color + { + return $this->foregroundColor; + } + + /** + * Get the padding. + */ + public function getPadding(): ?Padding + { + return $this->padding; + } + + /** + * Get the border. + */ + public function getBorder(): ?Border + { + return $this->border; + } + + /** + * Get the bold flag. + */ + public function getBold(): ?bool + { + return $this->bold; + } + + /** + * Get the dim flag. + */ + public function getDim(): ?bool + { + return $this->dim; + } + + /** + * Get the italic flag. + */ + public function getItalic(): ?bool + { + return $this->italic; + } + + /** + * Get the strikethrough flag. + */ + public function getStrikethrough(): ?bool + { + return $this->strikethrough; + } + + /** + * Get the underline flag. + */ + public function getUnderline(): ?bool + { + return $this->underline; + } + + /** + * Get the reverse flag. + */ + public function getReverse(): ?bool + { + return $this->reverse; + } + + /** + * Get the layout direction. + */ + public function getDirection(): ?Direction + { + return $this->direction; + } + + /** + * Get the gap between children. + */ + public function getGap(): ?int + { + return $this->gap; + } + + /** + * Get the hidden flag. + */ + public function getHidden(): ?bool + { + return $this->hidden; + } + + /** + * Get the cursor shape. + */ + public function getCursorShape(): ?CursorShape + { + return $this->cursorShape; + } + + /** + * Get the text alignment. + */ + public function getTextAlign(): ?TextAlign + { + return $this->textAlign; + } + + /** + * Get the FIGlet font name or path. + */ + public function getFont(): ?string + { + return $this->font; + } + + /** + * Get the maximum width in columns. + */ + public function getMaxColumns(): ?int + { + return $this->maxColumns; + } + + /** + * Get the horizontal alignment of child widgets. + */ + public function getAlign(): ?Align + { + return $this->align; + } + + /** + * Get the vertical alignment of child widgets. + */ + public function getVerticalAlign(): ?VerticalAlign + { + return $this->verticalAlign; + } + + /** + * Get the flex grow weight for horizontal layouts. + * + * - null: not set (inherits default behavior: equal distribution) + * - 0: use intrinsic (content) width + * - 1+: proportional weight (higher values get more space) + */ + public function getFlex(): ?int + { + return $this->flex; + } + + /** + * Check whether the style applies no visual formatting. + * + * @internal + */ + public function isPlain(): bool + { + return null === $this->backgroundColor + && null === $this->foregroundColor + && null === $this->bold + && null === $this->dim + && null === $this->italic + && null === $this->strikethrough + && null === $this->underline + && null === $this->reverse; + } + + /** + * Create a style with padding only. + * + * @param Padding|array $padding Padding specification (see Padding::from()) + */ + public static function padding(Padding|array $padding): self + { + return new self(padding: Padding::from($padding)); + } + + /** + * Create a style with border only. + * + * @param Border|array $border Border specification (see Border::from()) + * @param BorderPattern|string|null $pattern Border pattern (see BorderPattern::fromName()) + * @param Color|string|int|null $color Border color + */ + public static function border(Border|array $border, BorderPattern|string|null $pattern = null, Color|string|int|null $color = null): self + { + return new self(border: Border::from($border, $pattern, $color)); + } + + /** + * Merge multiple styles into one in a single pass. + * + * Later styles override earlier ones for non-null properties. + * Allocates a single Style object regardless of the number of inputs. + * + * @param Style[] $styles + */ + public static function mergeAll(array $styles): self + { + $padding = null; + $border = null; + $background = null; + $color = null; + $bold = null; + $dim = null; + $italic = null; + $strikethrough = null; + $underline = null; + $reverse = null; + $direction = null; + $gap = null; + $hidden = null; + $cursorShape = null; + $textAlign = null; + $font = null; + $maxColumns = null; + $align = null; + $verticalAlign = null; + $flex = null; + + foreach ($styles as $style) { + $padding = $style->padding ?? $padding; + $border = $style->border ?? $border; + $background = $style->backgroundColor ?? $background; + $color = $style->foregroundColor ?? $color; + $bold = $style->bold ?? $bold; + $dim = $style->dim ?? $dim; + $italic = $style->italic ?? $italic; + $strikethrough = $style->strikethrough ?? $strikethrough; + $underline = $style->underline ?? $underline; + $reverse = $style->reverse ?? $reverse; + $direction = $style->direction ?? $direction; + $gap = $style->gap ?? $gap; + $hidden = $style->hidden ?? $hidden; + $cursorShape = $style->cursorShape ?? $cursorShape; + $textAlign = $style->textAlign ?? $textAlign; + $font = $style->font ?? $font; + $maxColumns = $style->maxColumns ?? $maxColumns; + $align = $style->align ?? $align; + $verticalAlign = $style->verticalAlign ?? $verticalAlign; + $flex = $style->flex ?? $flex; + } + + return new self( + $padding, + $border, + $background, + $color, + $bold, + $dim, + $italic, + $strikethrough, + $underline, + $reverse, + $direction, + $gap, + $hidden, + $cursorShape, + $textAlign, + $font, + $maxColumns, + $align, + $verticalAlign, + $flex, + ); + } + + /** + * Create new style with different padding. + * + * @param Padding|array $padding Padding specification (see Padding::from()) + */ + public function withPadding(Padding|array $padding): self + { + $clone = clone $this; + $clone->padding = Padding::from($padding); + + return $clone; + } + + /** + * Create new style with different border. + * + * @param Border|array $border Border specification (see Border::from()) + * @param BorderPattern|string|null $pattern Border pattern (see BorderPattern::fromName()) + * @param Color|string|int|null $color Border color + */ + public function withBorder(Border|array $border, BorderPattern|string|null $pattern = null, Color|string|int|null $color = null): self + { + $clone = clone $this; + $clone->border = Border::from($border, $pattern, $color); + + return $clone; + } + + /** + * Create new style with a different border pattern. + */ + public function withBorderPattern(BorderPattern|string|null $pattern): self + { + $border = $this->border ?? new Border(0, 0, 0, 0); + + return $this->withBorder($border->withPattern($pattern)); + } + + /** + * Create new style with a different border color. + */ + public function withBorderColor(Color|string|int|null $color): self + { + $border = $this->border ?? new Border(0, 0, 0, 0); + + return $this->withBorder($border->withColor($color)); + } + + /** + * Create new style with background color. + * + * @param Color|string|int|null $background Color specification: + * - Color instance + * - string starting with '#' -> hex color + * - string -> named color ('red', 'blue', etc.) + * - int -> 256-palette index (0-255) + * - null -> no background + */ + public function withBackground(Color|string|int|null $background): self + { + $clone = clone $this; + $clone->backgroundColor = null !== $background ? Color::from($background) : null; + + return $clone; + } + + /** + * Create new style with foreground color. + * + * @param Color|string|int|null $color Color specification: + * - Color instance + * - string starting with '#' -> hex color + * - string -> named color ('red', 'blue', etc.) + * - int -> 256-palette index (0-255) + * - null -> no color + */ + public function withColor(Color|string|int|null $color): self + { + $clone = clone $this; + $clone->foregroundColor = null !== $color ? Color::from($color) : null; + + return $clone; + } + + /** + * Create new style with bold enabled. + */ + public function withBold(bool $bold = true): self + { + $clone = clone $this; + $clone->bold = $bold; + + return $clone; + } + + /** + * Create new style with dim/faint enabled. + */ + public function withDim(bool $dim = true): self + { + $clone = clone $this; + $clone->dim = $dim; + + return $clone; + } + + /** + * Create new style with italic enabled. + */ + public function withItalic(bool $italic = true): self + { + $clone = clone $this; + $clone->italic = $italic; + + return $clone; + } + + /** + * Create new style with strikethrough enabled. + */ + public function withStrikethrough(bool $strikethrough = true): self + { + $clone = clone $this; + $clone->strikethrough = $strikethrough; + + return $clone; + } + + /** + * Create new style with underline enabled. + */ + public function withUnderline(bool $underline = true): self + { + $clone = clone $this; + $clone->underline = $underline; + + return $clone; + } + + /** + * Create new style with reverse video enabled. + */ + public function withReverse(bool $reverse = true): self + { + $clone = clone $this; + $clone->reverse = $reverse; + + return $clone; + } + + /** + * Create new style with layout direction. + */ + public function withDirection(Direction $direction): self + { + $clone = clone $this; + $clone->direction = $direction; + + return $clone; + } + + /** + * Create new style with gap between children. + */ + public function withGap(int $gap): self + { + $clone = clone $this; + $clone->gap = max(0, $gap); + + return $clone; + } + + /** + * Create new style with hidden flag. + * + * Hidden widgets are skipped during rendering; they produce no output + * and take no space, similar to CSS `display: none`. + */ + public function withHidden(bool $hidden = true): self + { + $clone = clone $this; + $clone->hidden = $hidden; + + return $clone; + } + + /** + * Create new style with a cursor shape. + */ + public function withCursorShape(CursorShape $cursorShape): self + { + $clone = clone $this; + $clone->cursorShape = $cursorShape; + + return $clone; + } + + /** + * Create new style with text alignment. + */ + public function withTextAlign(TextAlign $textAlign): self + { + $clone = clone $this; + $clone->textAlign = $textAlign; + + return $clone; + } + + /** + * Create new style with a FIGlet font. + * + * @param string|null $font Bundled font name (big, small, slant, standard, mini) or path to a .flf file, or null to clear + */ + public function withFont(?string $font): self + { + $clone = clone $this; + $clone->font = $font; + + return $clone; + } + + /** + * Create new style with a maximum column width. + * + * @param int|null $maxColumns Maximum width in columns, or null to clear + */ + public function withMaxColumns(?int $maxColumns): self + { + $clone = clone $this; + $clone->maxColumns = $maxColumns; + + return $clone; + } + + /** + * Create new style with horizontal alignment for child widgets. + */ + public function withAlign(Align $align): self + { + $clone = clone $this; + $clone->align = $align; + + return $clone; + } + + /** + * Create new style with vertical alignment for child widgets. + */ + public function withVerticalAlign(VerticalAlign $verticalAlign): self + { + $clone = clone $this; + $clone->verticalAlign = $verticalAlign; + + return $clone; + } + + /** + * Create new style with a flex grow weight for horizontal layouts. + * + * @param int|null $flex 0 = intrinsic width, 1+ = proportional weight, null = clear + */ + public function withFlex(?int $flex): self + { + $clone = clone $this; + $clone->flex = null !== $flex ? max(0, $flex) : null; + + return $clone; + } + + /** + * Create a copy with only visual formatting and content properties. + * + * Strips layout properties that the Renderer owns: padding, border, + * gap, direction, hidden, cursorShape, textAlign, maxColumns, align, + * verticalAlign, and flex. + * Used by the Renderer to build the inner context for leaf widgets, + * enforcing a clear contract: the Renderer owns layout, widgets own + * content styling. + * + * Content properties like font are preserved because widgets need them + * during render() to produce the correct output. + * + * @internal + */ + public function withoutLayoutProperties(): self + { + if (null === $this->padding && null === $this->border + && null === $this->gap && null === $this->direction + && null === $this->hidden && null === $this->cursorShape + && null === $this->textAlign && null === $this->maxColumns + && null === $this->align && null === $this->verticalAlign + && null === $this->flex) { + return $this; + } + + $clone = clone $this; + $clone->padding = null; + $clone->border = null; + $clone->gap = null; + $clone->direction = null; + $clone->hidden = null; + $clone->cursorShape = null; + $clone->textAlign = null; + $clone->maxColumns = null; + $clone->align = null; + $clone->verticalAlign = null; + $clone->flex = null; + + return $clone; + } + + /** + * Get the ANSI codes that activate this style's formatting. + * + * This returns only the "turn on" codes (foreground, background, bold, etc.) + * without any corresponding reset codes. Useful for restoring a parent style + * after a child style's reset codes have run. + * + * Returns an empty string if no formatting properties are set. + */ + public function getAnsiRestore(): string + { + if (null === $this->ansiPrefix) { + $this->computeAnsiCodes(); + } + + return $this->ansiPrefix; + } + + /** + * Apply all formatting styles to a string. + * + * Applies color, background, bold, dim, italic, strikethrough, and underline. + * Padding and borders are not applied (that's a layout concern for the widget). + * + * Uses attribute-specific reset codes to preserve other styles that may + * be set by parent containers. + * + * When a boolean property is explicitly false (not null), it emits a reset + * code to cancel any inherited styling from parent containers. + */ + public function apply(string $text): string + { + if (null === $this->ansiPrefix) { + $this->computeAnsiCodes(); + } + + $processedText = $text; + if (null !== $this->bgCode) { + $processedText = AnsiUtils::reapplyBackgroundAfterResets($text, $this->bgCode); + } + + return $this->ansiPrefix.$processedText.$this->ansiSuffix; + } + + /** + * Compute and cache the ANSI prefix/suffix codes for this style. + * Called lazily on first apply(). + */ + private function computeAnsiCodes(): void + { + $prefix = ''; + $suffix = ''; + + if (null !== $this->foregroundColor) { + $prefix .= $this->foregroundColor->toForegroundCode(); + $suffix = Color::resetForeground().$suffix; + } + if (null !== $this->backgroundColor) { + $prefix .= $this->backgroundColor->toBackgroundCode(); + $suffix = Color::resetBackground().$suffix; + $this->bgCode = $this->backgroundColor->toBackgroundCode(); + } + // Bold (SGR 1) and dim (SGR 2) share the same reset code (SGR 22), + // so they must be handled together. Emit the reset first, then + // re-enable whichever attributes should be active. + $needsBoldDimReset = false === $this->bold || false === $this->dim; + if ($needsBoldDimReset) { + $prefix .= "\x1b[22m"; + } + if (true === $this->bold) { + $prefix .= "\x1b[1m"; + } + if (true === $this->dim) { + $prefix .= "\x1b[2m"; + } + if (true === $this->bold || true === $this->dim) { + $suffix = "\x1b[22m".$suffix; + } + if (true === $this->italic) { + $prefix .= "\x1b[3m"; + $suffix = "\x1b[23m".$suffix; + } elseif (false === $this->italic) { + $prefix .= "\x1b[23m"; + } + if (true === $this->strikethrough) { + $prefix .= "\x1b[9m"; + $suffix = "\x1b[29m".$suffix; + } elseif (false === $this->strikethrough) { + $prefix .= "\x1b[29m"; + } + if (true === $this->underline) { + $prefix .= "\x1b[4m"; + $suffix = "\x1b[24m".$suffix; + } elseif (false === $this->underline) { + $prefix .= "\x1b[24m"; + } + if (true === $this->reverse) { + $prefix .= "\x1b[7m"; + $suffix = "\x1b[27m".$suffix; + } elseif (false === $this->reverse) { + $prefix .= "\x1b[27m"; + } + + $this->ansiPrefix = $prefix; + $this->ansiSuffix = $suffix; + } +} diff --git a/src/Symfony/Component/Tui/Style/StyleSheet.php b/src/Symfony/Component/Tui/Style/StyleSheet.php new file mode 100644 index 0000000000000..a05e38edc131a --- /dev/null +++ b/src/Symfony/Component/Tui/Style/StyleSheet.php @@ -0,0 +1,464 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Style; + +use Symfony\Component\Tui\Widget\AbstractWidget; + +/** + * A collection of style rules with CSS-like selectors. + * + * Selectors can be: + * - FQCN: 'Symfony\Component\Tui\Widget\Input' or Input::class + * - FQCN with state: 'Symfony\Component\Tui\Widget\Input:focused' + * - CSS class: '.sidebar' + * - CSS class with state: '.sidebar:focused' + * - Universal: '*' (matches all widgets) + * - Sub-element (pseudo-element): SelectList::class.'::selected' + * - Sub-element with state: SelectList::class.'::selected:focused' + * - Class sub-element: '.my-list::selected' + * - Class sub-element with state: '.my-list::selected:focused' + * + * ## Style Inheritance + * + * When resolving styles, rules are applied in this order (later rules override earlier): + * 1. Universal selector ('*') + * 2. Widget FQCN selector (e.g., Text::class) + * 3. CSS class selectors (e.g., '.header') + * 4. State selectors (e.g., Input::class.':focused') + * 5. Instance style (widget's own setStyle()) + * + * All style properties use `null` to mean "inherit from earlier rules": + * + * // This rule sets color but not bold - bold will be inherited + * $stylesheet->addRule('.link', new Style()->withColor('blue')); + * + * To explicitly override inherited values: + * + * // Explicitly set padding to 0, overriding any inherited padding + * $stylesheet->addRule('.no-padding', Style::padding([0])); + * + * // Explicitly disable bold, overriding a parent's bold=true + * $stylesheet->addRule('.normal', new Style()->withBold(false)); + * + * ## Cascading Stylesheets + * + * Multiple stylesheets can be merged together like CSS cascading: + * later rules override earlier ones with the same selector. + * + * $defaults = new StyleSheet([ + * Overlay::class => new Style()->withBackground('#1e1e2e'), + * ]); + * + * $theme = new StyleSheet([ + * Overlay::class => new Style()->withBorder([1]), + * ]); + * + * // Merge theme on top of defaults - theme rules override defaults + * $merged = $defaults->merge($theme); + * + * ## Responsive Breakpoints + * + * Rules can be scoped to terminal width using breakpoints, similar to CSS + * `@media (min-width: ...)`. Breakpoint rules apply when the terminal has + * at least the specified number of columns: + * + * $stylesheet->addBreakpoint(120, '.panes', new Style(direction: Direction::Horizontal)); + * // Below 120 columns: .panes uses default (vertical) + * // At 120+ columns: .panes switches to horizontal + * + * Multiple breakpoints can be defined. They are evaluated in ascending order + * of column threshold, so narrower breakpoints are overridden by wider ones + * when both match. + * + * Breakpoint rules are applied after base rules and state selectors but + * before instance styles in the cascade. + * + * @experimental + * + * @author Fabien Potencier + */ +class StyleSheet +{ + /** @var array> Breakpoint rules keyed by min-columns threshold */ + private array $breakpoints = []; + + /** + * @param array $rules Map of selectors to styles + */ + public function __construct( + private array $rules = [], + ) { + } + + /** + * Add a rule to the stylesheet. + * + * @return $this + */ + public function addRule(string $selector, Style $style): self + { + $this->rules[$selector] = $style; + + return $this; + } + + /** + * Add a responsive breakpoint rule. + * + * The rule applies only when the terminal has at least $minColumns columns. + * This is equivalent to CSS `@media (min-width: ...)`. + * + * @return $this + */ + public function addBreakpoint(int $minColumns, string $selector, Style $style): self + { + $this->breakpoints[$minColumns][$selector] = $style; + + return $this; + } + + /** + * Merge another stylesheet's rules into this one. + * + * Rules from the other stylesheet override rules in this one + * for the same selector. This works like CSS cascading: later + * stylesheets win. + * + * @return $this + */ + public function merge(self $other): self + { + foreach ($other->rules as $selector => $style) { + $this->rules[$selector] = $style; + } + + foreach ($other->breakpoints as $minColumns => $rules) { + foreach ($rules as $selector => $style) { + $this->breakpoints[$minColumns][$selector] = $style; + } + } + + return $this; + } + + /** + * Merge another stylesheet's rules as defaults (lower priority). + * + * Rules from the other stylesheet are added only for selectors + * that are not already defined in this stylesheet. This is the + * reverse of merge(): existing rules are preserved. + * + * @return $this + */ + public function mergeDefaults(self $defaults): self + { + foreach ($defaults->rules as $selector => $style) { + if (!isset($this->rules[$selector])) { + $this->rules[$selector] = $style; + } + } + + foreach ($defaults->breakpoints as $minColumns => $rules) { + foreach ($rules as $selector => $style) { + if (!isset($this->breakpoints[$minColumns][$selector])) { + $this->breakpoints[$minColumns][$selector] = $style; + } + } + } + + return $this; + } + + /** + * Get all rules. + * + * @return array + */ + public function getRules(): array + { + return $this->rules; + } + + /** + * Resolve the style for a widget by merging applicable rules. + * + * Resolution order (later overrides earlier): + * 1. Universal selector (*) + * 2. FQCN selector (widget class and parent classes, parent first) + * 3. CSS class selectors (.class): only classes from {@see getCssClasses()} + * 4. State selectors (:focused, :disabled, etc.) + * 5. Breakpoint rules (ascending min-columns order) + * 6. Extra styles from subclasses (see {@see resolveExtraStyles()}) + * 7. Instance style (widget's own style) + * + * @param int|null $columns Current terminal width (for responsive breakpoints) + */ + public function resolve(AbstractWidget $widget, ?int $columns = null): Style + { + $fqcn = $widget::class; + $cssClasses = $this->getCssClasses($widget); + $classHierarchy = static::getClassHierarchy($fqcn); + $applicableStyles = []; + + // 1. Universal selector + if (isset($this->rules['*'])) { + $applicableStyles[] = $this->rules['*']; + } + + // 2. FQCN selector (walk class hierarchy, parent classes first for lower priority) + foreach ($classHierarchy as $class) { + if (isset($this->rules[$class])) { + $applicableStyles[] = $this->rules[$class]; + } + } + + // 3. CSS class selectors + foreach ($cssClasses as $class) { + $selector = '.'.$class; + if (isset($this->rules[$selector])) { + $applicableStyles[] = $this->rules[$selector]; + } + } + + // 4. State selectors (applied to FQCN hierarchy and CSS classes) + foreach ($widget->getStateFlags() as $state) { + // FQCN:state (walk class hierarchy, parent classes first) + foreach ($classHierarchy as $class) { + $classStateSelector = $class.':'.$state; + if (isset($this->rules[$classStateSelector])) { + $applicableStyles[] = $this->rules[$classStateSelector]; + } + } + + // .class:state + foreach ($cssClasses as $class) { + $classStateSelector = '.'.$class.':'.$state; + if (isset($this->rules[$classStateSelector])) { + $applicableStyles[] = $this->rules[$classStateSelector]; + } + } + } + + // 5. Breakpoint rules (ascending min-columns order) + if (null !== $columns && [] !== $this->breakpoints) { + $applicableStyles = $this->resolveBreakpoints($widget, $columns, $applicableStyles, $cssClasses); + } + + // 6. Extra styles from subclasses (e.g. utility classes) + $applicableStyles = $this->resolveExtraStyles($widget, $applicableStyles); + + // 7. Instance style + if ($widget->getStyle()) { + $applicableStyles[] = $widget->getStyle(); + } + + // Merge all applicable styles + return static::mergeStyles($applicableStyles); + } + + /** + * Resolve the style for a sub-element of a widget. + * + * Sub-elements are parts within a widget (e.g., "selected item", "description") + * that need independent styling. They use CSS pseudo-element syntax (::). + * + * Resolution order (later overrides earlier): + * 1. FQCN::element (e.g., SelectListWidget::class.'::selected') + * 2. .class::element (e.g., '.my-list::selected') + * 3. FQCN::element:state (e.g., SelectListWidget::class.'::selected:focused') + * 4. .class::element:state (e.g., '.my-list::selected:focused') + * + * Example stylesheet rules: + * + * SelectListWidget::class.'::selected' => new Style()->withBold(), + * SelectListWidget::class.'::selected:focused' => new Style()->withBold()->withColor('cyan'), + * '.my-list::selected' => new Style()->withColor('green'), + */ + public function resolveElement(AbstractWidget $widget, string $element): Style + { + $fqcn = $widget::class; + $cssClasses = $this->getCssClasses($widget); + $classHierarchy = static::getClassHierarchy($fqcn); + $applicableStyles = []; + + // 1. FQCN::element (walk class hierarchy, parent classes first for lower priority) + foreach ($classHierarchy as $class) { + $selector = $class.'::'.$element; + if (isset($this->rules[$selector])) { + $applicableStyles[] = $this->rules[$selector]; + } + } + + // 2. .class::element + foreach ($cssClasses as $class) { + $selector = '.'.$class.'::'.$element; + if (isset($this->rules[$selector])) { + $applicableStyles[] = $this->rules[$selector]; + } + } + + // 3. FQCN::element:state and .class::element:state + foreach ($widget->getStateFlags() as $state) { + // Walk class hierarchy for state selectors too + foreach ($classHierarchy as $class) { + $selector = $class.'::'.$element.':'.$state; + if (isset($this->rules[$selector])) { + $applicableStyles[] = $this->rules[$selector]; + } + } + + foreach ($cssClasses as $class) { + $selector = '.'.$class.'::'.$element.':'.$state; + if (isset($this->rules[$selector])) { + $applicableStyles[] = $this->rules[$selector]; + } + } + } + + return static::mergeStyles($applicableStyles); + } + + /** + * Return the widget classes that participate in CSS selector matching. + * + * By default, all style classes are CSS classes. Subclasses may override + * this to filter out classes handled separately (e.g. utility classes). + * + * @return string[] + */ + protected function getCssClasses(AbstractWidget $widget): array + { + return $widget->getStyleClasses(); + } + + /** + * Hook for subclasses to inject extra styles into the cascade. + * + * Called after breakpoint rules and before the instance style. + * The default implementation returns the styles unchanged. + * + * @param Style[] $applicableStyles Current styles in cascade order + * + * @return Style[] + */ + protected function resolveExtraStyles(AbstractWidget $widget, array $applicableStyles): array + { + return $applicableStyles; + } + + /** + * Resolve breakpoint rules that apply at the given column width. + * + * Evaluates breakpoints in ascending order of min-columns threshold. + * Each matching breakpoint's rules go through the same selector matching + * as base rules (universal, FQCN, CSS class, state). + * + * @param Style[] $applicableStyles Current styles in cascade order + * @param string[] $cssClasses CSS classes to use for selector matching + * + * @return Style[] + */ + protected function resolveBreakpoints(AbstractWidget $widget, int $columns, array $applicableStyles, array $cssClasses): array + { + $classHierarchy = static::getClassHierarchy($widget::class); + + // Sort breakpoints by threshold ascending so narrower ones apply first + $thresholds = array_keys($this->breakpoints); + sort($thresholds); + + foreach ($thresholds as $minColumns) { + if ($columns < $minColumns) { + continue; + } + + $rules = $this->breakpoints[$minColumns]; + + // Apply same selector matching as base rules + if (isset($rules['*'])) { + $applicableStyles[] = $rules['*']; + } + + foreach ($classHierarchy as $class) { + if (isset($rules[$class])) { + $applicableStyles[] = $rules[$class]; + } + } + + foreach ($cssClasses as $class) { + $selector = '.'.$class; + if (isset($rules[$selector])) { + $applicableStyles[] = $rules[$selector]; + } + } + + foreach ($widget->getStateFlags() as $state) { + foreach ($classHierarchy as $class) { + $classStateSelector = $class.':'.$state; + if (isset($rules[$classStateSelector])) { + $applicableStyles[] = $rules[$classStateSelector]; + } + } + + foreach ($cssClasses as $class) { + $classStateSelector = '.'.$class.':'.$state; + if (isset($rules[$classStateSelector])) { + $applicableStyles[] = $rules[$classStateSelector]; + } + } + } + } + + return $applicableStyles; + } + + /** + * Get the class hierarchy for a widget class (parent classes first, concrete class last). + * + * Stops at AbstractWidget (excluded) since rules should not target it directly. + * + * @return string[] + */ + protected static function getClassHierarchy(string $fqcn): array + { + $hierarchy = []; + $class = $fqcn; + + while ($class && AbstractWidget::class !== $class) { + $hierarchy[] = $class; + $class = get_parent_class($class); + } + + return array_reverse($hierarchy); + } + + /** + * Merge multiple styles into one. + * + * Later styles override earlier ones for non-null properties. + * Uses {@see Style::mergeAll()} for a single-pass merge that + * allocates one Style object instead of N-1 intermediates. + * + * @param Style[] $styles + */ + protected static function mergeStyles(array $styles): Style + { + if ([] === $styles) { + return new Style(); + } + + if (1 === \count($styles)) { + return $styles[0]; + } + + return Style::mergeAll($styles); + } +} diff --git a/src/Symfony/Component/Tui/Style/TailwindStylesheet.php b/src/Symfony/Component/Tui/Style/TailwindStylesheet.php new file mode 100644 index 0000000000000..845bfc8ebb0ba --- /dev/null +++ b/src/Symfony/Component/Tui/Style/TailwindStylesheet.php @@ -0,0 +1,469 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Style; + +use Symfony\Component\Tui\Widget\AbstractWidget; + +/** + * A stylesheet that supports Tailwind-like utility classes. + * + * Utility classes are parsed from widget style classes and dynamically + * converted to Style objects. They coexist with regular CSS-like rules + * and take precedence over them in the cascade (they are "immutable"). + * + * ## Supported utility classes + * + * ### Padding + * p-{n} All sides + * px-{n} Left and right + * py-{n} Top and bottom + * pt-{n} pr-{n} pb-{n} pl-{n} Individual sides + * + * ### Border + * border All sides, width 1 + * border-{n} All sides, width n + * border-t border-r border-b border-l Individual side, width 1 + * border-t-{n} border-r-{n} border-b-{n} border-l-{n} Individual side, width n + * border-none Remove border + * border-{pattern} Pattern: normal, rounded, double, tall, wide, tall-medium, wide-medium, tall-large, wide-large + * border-{color} Color: {family}-{shade}, [#hex], or palette index + * + * ### Background color + * bg-{family}-{shade} Tailwind shade (e.g., bg-red-300, bg-emerald-700) + * bg-[#hex] Hex color (e.g., bg-[#ff5500], bg-[#f50]) + * bg-{0-255} 256-palette index + * + * ### Text color + * text-{family}-{shade} Tailwind shade (e.g., text-blue-700, text-sky-400) + * text-[#hex] Hex color + * text-{0-255} 256-palette index + * + * Color families: slate, gray, zinc, neutral, stone, red, orange, amber, + * yellow, lime, green, emerald, teal, cyan, sky, blue, indigo, violet, + * purple, fuchsia, pink, rose. Shade numbers: 50–950. + * + * ### Text decoration + * bold / not-bold + * dim / not-dim + * italic / not-italic + * underline / no-underline + * line-through / no-line-through + * reverse / no-reverse + * + * ### Text alignment + * text-left Left-align text (default) + * text-center Center-align text + * text-right Right-align text + * + * ### Font + * font-{name} FIGlet font (big, small, slant, standard, mini, or path) + * + * ### Layout + * flex-row Horizontal direction + * flex-col Vertical direction + * flex-{n} Flex grow weight (0 = intrinsic width, 1+ = proportional) + * gap-{n} Gap between children + * hidden Hide widget + * visible Show widget + * align-left Left-align child widgets (default) + * align-center Center child widgets horizontally + * align-right Right-align child widgets + * valign-top Top-align child widgets + * valign-center Center child widgets vertically + * valign-bottom Bottom-align child widgets (default) + * + * ## Cascade order + * + * Inherits the standard {@see StyleSheet} cascade, with utility classes + * injected at step 6 (above breakpoints, below instance style): + * 1. Universal selector (*) + * 2. FQCN selector + * 3. CSS class selectors (.class): utility classes excluded + * 4. State selectors (:focused) + * 5. Breakpoint rules + * 6. **Utility class styles** (immutable, override all above) + * 7. Instance style (widget's own setStyle()) + * + * ## Composability + * + * Multiple utility classes compose naturally: + * + * $widget->addStyleClass('p-2') + * ->addStyleClass('bg-red-500') + * ->addStyleClass('bold') + * ->addStyleClass('border') + * ->addStyleClass('border-rounded') + * ->addStyleClass('border-cyan-400'); + * + * Border-related classes (width, pattern, color) are combined into + * a single Border object. When the same property is set twice, + * the last class wins (e.g., `p-2 p-4` results in padding 4). + * + * @experimental + * + * @author Fabien Potencier + */ +class TailwindStylesheet extends StyleSheet +{ + /** + * Tailwind color palette: base (500) hex values. + * + * These are the official Tailwind CSS v4 color families. + * Shade variants (e.g. red-300) are computed from these by + * tinting toward white or shading toward black. + */ + private const TAILWIND_COLORS = [ + 'slate' => '#64748b', + 'gray' => '#6b7280', + 'zinc' => '#71717a', + 'neutral' => '#737373', + 'stone' => '#78716c', + 'red' => '#ef4444', + 'orange' => '#f97316', + 'amber' => '#f59e0b', + 'yellow' => '#eab308', + 'lime' => '#84cc16', + 'green' => '#22c55e', + 'emerald' => '#10b981', + 'teal' => '#14b8a6', + 'cyan' => '#06b6d4', + 'sky' => '#0ea5e9', + 'blue' => '#3b82f6', + 'indigo' => '#6366f1', + 'violet' => '#8b5cf6', + 'purple' => '#a855f7', + 'fuchsia' => '#d946ef', + 'pink' => '#ec4899', + 'rose' => '#f43f5e', + ]; + + /** + * Maps Tailwind shade numbers to scale() percentages. + * + * Negative = tint (lighter), positive = shade (darker), 0 = base. + */ + private const SHADE_SCALE = [ + 50 => -95, + 100 => -80, + 200 => -60, + 300 => -40, + 400 => -20, + 500 => 0, + 600 => 20, + 700 => 40, + 800 => 60, + 900 => 80, + 950 => 90, + ]; + + protected function getCssClasses(AbstractWidget $widget): array + { + return $this->partitionClasses($widget)['css']; + } + + protected function resolveExtraStyles(AbstractWidget $widget, array $applicableStyles): array + { + $utilityClasses = $this->partitionClasses($widget)['utility']; + + if ([] === $utilityClasses) { + return $applicableStyles; + } + + $utilityStyle = $this->resolveUtilityClasses($utilityClasses); + if (null !== $utilityStyle) { + $applicableStyles[] = $utilityStyle; + } + + return $applicableStyles; + } + + /** + * Partition widget classes into CSS classes and utility classes. + * + * @return array{css: string[], utility: string[]} + */ + private function partitionClasses(AbstractWidget $widget): array + { + $css = []; + $utility = []; + + foreach ($widget->getStyleClasses() as $class) { + if (null !== $this->parseSingleUtility($class)) { + $utility[] = $class; + } else { + $css[] = $class; + } + } + + return ['css' => $css, 'utility' => $utility]; + } + + /** + * Resolve all utility classes into a single combined Style. + * + * @param string[] $classes Utility class names + */ + private function resolveUtilityClasses(array $classes): ?Style + { + /** @var array $slots */ + $slots = []; + foreach ($classes as $class) { + $parsed = $this->parseSingleUtility($class); + if (null !== $parsed) { + $slots = array_merge($slots, $parsed); + } + } + + if ([] === $slots) { + return null; + } + + return $this->buildStyleFromSlots($slots); + } + + /** + * Parse a single utility class name into property slots. + * + * Returns null if the class is not a recognized utility. + * + * @return array|null + */ + private function parseSingleUtility(string $class): ?array + { + // === PADDING === + if (preg_match('/^p-(\d+)$/', $class, $m)) { + $v = (int) $m[1]; + + return ['pt' => $v, 'pr' => $v, 'pb' => $v, 'pl' => $v]; + } + if (preg_match('/^px-(\d+)$/', $class, $m)) { + $v = (int) $m[1]; + + return ['pr' => $v, 'pl' => $v]; + } + if (preg_match('/^py-(\d+)$/', $class, $m)) { + $v = (int) $m[1]; + + return ['pt' => $v, 'pb' => $v]; + } + if (preg_match('/^pt-(\d+)$/', $class, $m)) { + return ['pt' => (int) $m[1]]; + } + if (preg_match('/^pr-(\d+)$/', $class, $m)) { + return ['pr' => (int) $m[1]]; + } + if (preg_match('/^pb-(\d+)$/', $class, $m)) { + return ['pb' => (int) $m[1]]; + } + if (preg_match('/^pl-(\d+)$/', $class, $m)) { + return ['pl' => (int) $m[1]]; + } + + // === BORDER === + if ('border' === $class) { + return ['bt' => 1, 'br' => 1, 'bb' => 1, 'bl' => 1]; + } + if ('border-none' === $class) { + return ['bt' => 0, 'br' => 0, 'bb' => 0, 'bl' => 0, 'border-pattern' => 'none']; + } + if (preg_match('/^border-(\d+)$/', $class, $m)) { + $v = (int) $m[1]; + + return ['bt' => $v, 'br' => $v, 'bb' => $v, 'bl' => $v]; + } + if (preg_match('/^border-(t|r|b|l)$/', $class, $m)) { + return ['b'.$m[1] => 1]; + } + if (preg_match('/^border-(t|r|b|l)-(\d+)$/', $class, $m)) { + return ['b'.$m[1] => (int) $m[2]]; + } + if (preg_match('/^border-(normal|rounded|double|tall|wide|tall-medium|wide-medium|tall-large|wide-large)$/', $class, $m)) { + return ['border-pattern' => $m[1]]; + } + if (preg_match('/^border-(.+)$/', $class, $m)) { + $color = $this->parseColorValue($m[1]); + if (null !== $color) { + return ['border-color' => $color]; + } + } + + // === BACKGROUND === + if (preg_match('/^bg-(.+)$/', $class, $m)) { + $color = $this->parseColorValue($m[1]); + if (null !== $color) { + return ['bg' => $color]; + } + } + + // === TEXT ALIGNMENT === + $textAlign = match ($class) { + 'text-left' => TextAlign::Left, + 'text-center' => TextAlign::Center, + 'text-right' => TextAlign::Right, + default => null, + }; + if (null !== $textAlign) { + return ['textAlign' => $textAlign]; + } + + // === TEXT COLOR === + if (preg_match('/^text-(.+)$/', $class, $m)) { + $color = $this->parseColorValue($m[1]); + if (null !== $color) { + return ['fg' => $color]; + } + } + + // === GAP === + if (preg_match('/^gap-(\d+)$/', $class, $m)) { + return ['gap' => (int) $m[1]]; + } + + // === FLEX WEIGHT === + if (preg_match('/^flex-(\d+)$/', $class, $m)) { + return ['flex' => (int) $m[1]]; + } + + // === FONT === + if (preg_match('/^font-(.+)$/', $class, $m)) { + return ['font' => $m[1]]; + } + + // === ALIGN === + $align = match ($class) { + 'align-left' => Align::Left, + 'align-center' => Align::Center, + 'align-right' => Align::Right, + default => null, + }; + if (null !== $align) { + return ['align' => $align]; + } + + // === VERTICAL ALIGN === + $verticalAlign = match ($class) { + 'valign-top' => VerticalAlign::Top, + 'valign-center' => VerticalAlign::Center, + 'valign-bottom' => VerticalAlign::Bottom, + default => null, + }; + if (null !== $verticalAlign) { + return ['verticalAlign' => $verticalAlign]; + } + + // === SIMPLE KEYWORDS === + return match ($class) { + 'bold' => ['bold' => true], + 'not-bold' => ['bold' => false], + 'dim' => ['dim' => true], + 'not-dim' => ['dim' => false], + 'italic' => ['italic' => true], + 'not-italic' => ['italic' => false], + 'underline' => ['underline' => true], + 'no-underline' => ['underline' => false], + 'line-through' => ['strikethrough' => true], + 'no-line-through' => ['strikethrough' => false], + 'reverse' => ['reverse' => true], + 'no-reverse' => ['reverse' => false], + 'flex-col' => ['direction' => Direction::Vertical], + 'flex-row' => ['direction' => Direction::Horizontal], + 'hidden' => ['hidden' => true], + 'visible' => ['hidden' => false], + default => null, + }; + } + + /** + * Parse a color value from a utility class suffix. + * + * Supports: + * - Tailwind shade: red-300, emerald-700, sky-400, etc. + * - Hex with brackets: [#ff5500], [#f50] + * - 256-palette index: 0-255 + */ + private function parseColorValue(string $value): Color|string|int|null + { + // Bracket syntax for arbitrary hex: [#ff5500] + if (preg_match('/^\[#([0-9a-fA-F]{3,6})]$/', $value, $m)) { + return '#'.$m[1]; + } + + // Numeric palette: 0-255 + if (preg_match('/^\d+$/', $value)) { + $index = (int) $value; + if ($index >= 0 && $index <= 255) { + return $index; + } + } + + // Tailwind shade syntax: {family}-{shade} + if (preg_match('/^([a-z]+)-(\d+)$/', $value, $m)) { + $shade = (int) $m[2]; + if (isset(self::TAILWIND_COLORS[$m[1]]) && isset(self::SHADE_SCALE[$shade])) { + return Color::hex(self::TAILWIND_COLORS[$m[1]])->scale(self::SHADE_SCALE[$shade]); + } + } + + return null; + } + + /** + * Build a Style from accumulated property slots. + * + * @param array $slots + */ + private function buildStyleFromSlots(array $slots): Style + { + $padding = null; + if (isset($slots['pt']) || isset($slots['pr']) || isset($slots['pb']) || isset($slots['pl'])) { + $padding = new Padding( + $slots['pt'] ?? 0, + $slots['pr'] ?? 0, + $slots['pb'] ?? 0, + $slots['pl'] ?? 0, + ); + } + + $border = null; + if (isset($slots['bt']) || isset($slots['br']) || isset($slots['bb']) || isset($slots['bl']) || isset($slots['border-pattern']) || isset($slots['border-color'])) { + $border = new Border( + $slots['bt'] ?? 0, + $slots['br'] ?? 0, + $slots['bb'] ?? 0, + $slots['bl'] ?? 0, + $slots['border-pattern'] ?? null, + $slots['border-color'] ?? null, + ); + } + + return new Style( + padding: $padding, + border: $border, + background: $slots['bg'] ?? null, + color: $slots['fg'] ?? null, + bold: $slots['bold'] ?? null, + dim: $slots['dim'] ?? null, + italic: $slots['italic'] ?? null, + strikethrough: $slots['strikethrough'] ?? null, + underline: $slots['underline'] ?? null, + reverse: $slots['reverse'] ?? null, + direction: $slots['direction'] ?? null, + gap: $slots['gap'] ?? null, + hidden: $slots['hidden'] ?? null, + textAlign: $slots['textAlign'] ?? null, + font: $slots['font'] ?? null, + align: $slots['align'] ?? null, + verticalAlign: $slots['verticalAlign'] ?? null, + flex: $slots['flex'] ?? null, + ); + } +} diff --git a/src/Symfony/Component/Tui/Style/TextAlign.php b/src/Symfony/Component/Tui/Style/TextAlign.php new file mode 100644 index 0000000000000..264d68e78b679 --- /dev/null +++ b/src/Symfony/Component/Tui/Style/TextAlign.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Style; + +/** + * Text alignment within a widget's content area. + * + * @experimental + * + * @author Fabien Potencier + */ +enum TextAlign: string +{ + case Left = 'left'; + case Center = 'center'; + case Right = 'right'; +} diff --git a/src/Symfony/Component/Tui/Style/VerticalAlign.php b/src/Symfony/Component/Tui/Style/VerticalAlign.php new file mode 100644 index 0000000000000..e462b9f447360 --- /dev/null +++ b/src/Symfony/Component/Tui/Style/VerticalAlign.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Style; + +/** + * Vertical alignment of child widgets within a container. + * + * Controls how child widget content is positioned vertically when it + * renders shorter than the container's available height. + * + * @experimental + * + * @author Fabien Potencier + */ +enum VerticalAlign: string +{ + case Top = 'top'; + case Center = 'center'; + case Bottom = 'bottom'; +} diff --git a/src/Symfony/Component/Tui/Terminal/ScreenBuffer.php b/src/Symfony/Component/Tui/Terminal/ScreenBuffer.php new file mode 100644 index 0000000000000..3a746385e49aa --- /dev/null +++ b/src/Symfony/Component/Tui/Terminal/ScreenBuffer.php @@ -0,0 +1,840 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Terminal; + +use Symfony\Component\Tui\Ansi\AnsiUtils; + +/** + * A simple terminal emulator that interprets ANSI escape sequences + * and maintains a screen buffer representing what the user actually sees. + * + * This is used in tests to convert raw terminal output (with differential + * updates, cursor movements, etc.) into the actual rendered screen state. + * It preserves ANSI styling (colors, bold, etc.) for accurate visual comparison. + * + * @experimental + */ +final class ScreenBuffer +{ + /** + * @var array{ + * bold: bool, + * dim: bool, + * italic: bool, + * underline: bool, + * blink: bool, + * reverse: bool, + * strikethrough: bool, + * fg: string|null, + * bg: string|null, + * underlineColor: string|null + * } + */ + private const DEFAULT_STYLE_STATE = [ + 'bold' => false, + 'dim' => false, + 'italic' => false, + 'underline' => false, + 'blink' => false, + 'reverse' => false, + 'strikethrough' => false, + 'fg' => null, + 'bg' => null, + 'underlineColor' => null, + ]; + + /** @var array> */ + private array $cells = []; + private int $cursorRow = 0; + private int $cursorCol = 0; + private int $width; + private int $height; + + /** + * Current style state - tracks individual attributes. + * + * @var array{ + * bold: bool, + * dim: bool, + * italic: bool, + * underline: bool, + * blink: bool, + * reverse: bool, + * strikethrough: bool, + * fg: string|null, + * bg: string|null, + * underlineColor: string|null + * } + */ + private array $styleState = self::DEFAULT_STYLE_STATE; + + public function __construct(int $width = 80, int $height = 24) + { + $this->width = $width; + $this->height = $height; + $this->clear(); + } + + /** + * Clear the screen buffer. + */ + public function clear(): void + { + $this->cells = []; + for ($row = 0; $row < $this->height; ++$row) { + $this->cells[$row] = []; + } + $this->cursorRow = 0; + $this->cursorCol = 0; + $this->styleState = self::DEFAULT_STYLE_STATE; + } + + /** + * Process terminal output and update the screen buffer. + */ + public function write(string $data): void + { + $i = 0; + $len = strlen($data); + + while ($i < $len) { + $char = $data[$i]; + + // Handle escape sequences + if ("\x1b" === $char) { + $consumed = $this->parseEscapeSequence($data, $i); + $i += $consumed; + continue; + } + + // Handle special characters + if ("\r" === $char) { + $this->cursorCol = 0; + ++$i; + continue; + } + + if ("\n" === $char) { + ++$this->cursorRow; + $this->cursorCol = 0; // Newline also resets column + if ($this->cursorRow >= $this->height) { + $this->scrollUp(); + $this->cursorRow = $this->height - 1; + } + ++$i; + continue; + } + + // Handle tab + if ("\t" === $char) { + $spaces = 8 - ($this->cursorCol % 8); + for ($j = 0; $j < $spaces && $this->cursorCol < $this->width; ++$j) { + $this->putChar(' '); + } + ++$i; + continue; + } + + // Handle backspace (move cursor back) + if ("\x08" === $char) { + if ($this->cursorCol > 0) { + --$this->cursorCol; + } + ++$i; + continue; + } + + // Handle DEL (delete character at cursor, move cursor back) + if ("\x7f" === $char) { + if ($this->cursorCol > 0) { + --$this->cursorCol; + // Clear the character at the new cursor position + $this->cells[$this->cursorRow][$this->cursorCol] = ['char' => ' ', 'style' => '']; + } + ++$i; + continue; + } + + // Skip other control characters + if (ord($char) < 32 && "\x1b" !== $char) { + ++$i; + continue; + } + + // Regular character - extract full grapheme cluster + $grapheme = grapheme_extract($data, 1, \GRAPHEME_EXTR_COUNT, $i, $next); + if (false !== $grapheme && '' !== $grapheme) { + $this->putChar($grapheme); + $i = $next; + } else { + ++$i; + } + } + } + + /** + * Get the current screen content as a string (without styles). + */ + public function getScreen(): string + { + $result = []; + $lastNonEmpty = -1; + + for ($row = 0; $row < $this->height; ++$row) { + $line = $this->getLineText($row); + $trimmed = rtrim($line); + if ('' !== $trimmed) { + $lastNonEmpty = $row; + } + $result[] = $trimmed; + } + + // Only include lines up to the last non-empty line + if ($lastNonEmpty >= 0) { + $result = array_slice($result, 0, $lastNonEmpty + 1); + } else { + $result = []; + } + + return implode("\n", $result); + } + + /** + * Get the current screen content with ANSI styles preserved. + */ + public function getStyledScreen(): string + { + $result = []; + $lastNonEmpty = -1; + + for ($row = 0; $row < $this->height; ++$row) { + $line = $this->getLineStyled($row); + if (isset($this->cells[$row])) { + foreach ($this->cells[$row] as $cell) { + if (' ' !== $cell['char'] && '' !== $cell['char']) { + $lastNonEmpty = $row; + break; + } + } + } + $result[] = rtrim($line); + } + + // Only include lines up to the last non-empty line + if ($lastNonEmpty >= 0) { + $result = array_slice($result, 0, $lastNonEmpty + 1); + } else { + $result = []; + } + + return implode("\n", $result); + } + + /** + * Get screen lines as array (without styles). + * + * @return string[] + */ + public function getLines(): array + { + $lines = []; + for ($row = 0; $row < $this->height; ++$row) { + $lines[] = $this->getLineText($row); + } + + return $lines; + } + + /** + * Get the cell data for external processing (e.g., HTML conversion). + * + * @return array> + */ + public function getCells(): array + { + return $this->cells; + } + + /** + * Get the screen height. + */ + public function getHeight(): int + { + return $this->height; + } + + /** + * Get a single line's text content. + */ + private function getLineText(int $row): string + { + if (!isset($this->cells[$row]) || [] === $this->cells[$row]) { + return ''; + } + + $line = ''; + $maxCol = max(array_keys($this->cells[$row])); + + for ($col = 0; $col <= $maxCol; ++$col) { + $char = $this->cells[$row][$col]['char'] ?? ' '; + // Skip wide character continuation cells (empty string placeholders) + if ('' === $char) { + continue; + } + $line .= $char; + } + + return $line; + } + + /** + * Get a single line with ANSI styles. + */ + private function getLineStyled(int $row): string + { + if (!isset($this->cells[$row]) || [] === $this->cells[$row]) { + return ''; + } + + $line = ''; + $maxCol = max(array_keys($this->cells[$row])); + + $lastStyle = ''; + for ($col = 0; $col <= $maxCol; ++$col) { + $cell = $this->cells[$row][$col] ?? ['char' => ' ', 'style' => '']; + + // Skip wide character continuation cells (empty string placeholders) + if ('' === $cell['char']) { + continue; + } + + $style = $cell['style']; + + if ($style !== $lastStyle) { + if ('' !== $lastStyle) { + $line .= "\x1b[0m"; // Reset before changing + } + if ('' !== $style) { + $line .= $style; + } + $lastStyle = $style; + } + + $line .= $cell['char']; + } + + if ('' !== $lastStyle) { + $line .= "\x1b[0m"; + } + + return $line; + } + + /** + * Put a character at the current cursor position. + */ + private function putChar(string $char): void + { + if ($this->cursorRow < 0 || $this->cursorRow >= $this->height) { + return; + } + + if (!isset($this->cells[$this->cursorRow])) { + $this->cells[$this->cursorRow] = []; + } + + // Fill any gaps with spaces + for ($col = count($this->cells[$this->cursorRow]); $col < $this->cursorCol; ++$col) { + $this->cells[$this->cursorRow][$col] = ['char' => ' ', 'style' => '']; + } + + $style = $this->buildStyleString(); + $charWidth = AnsiUtils::graphemeWidth($char); + + // If the wide character doesn't fit at the right edge, skip it + if ($charWidth > 1 && $this->cursorCol + $charWidth > $this->width) { + return; + } + + $row = &$this->cells[$this->cursorRow]; + + // Clean up wide character fragments in cells being overwritten + for ($w = 0; $w < $charWidth; ++$w) { + $col = $this->cursorCol + $w; + if (isset($row[$col])) { + if ('' === $row[$col]['char']) { + // This is a continuation cell, clear the wide char to its left + if ($col > 0 && isset($row[$col - 1]) && '' !== $row[$col - 1]['char'] && ' ' !== $row[$col - 1]['char']) { + $row[$col - 1] = ['char' => ' ', 'style' => '']; + } + } elseif (' ' !== $row[$col]['char']) { + // This cell may be a wide char, clear its continuation cell to the right + if (isset($row[$col + 1]) && '' === $row[$col + 1]['char']) { + $row[$col + 1] = ['char' => ' ', 'style' => '']; + } + } + } + } + + $row[$this->cursorCol] = [ + 'char' => $char, + 'style' => $style, + ]; + + // For wide characters (e.g. CJK), mark continuation cell(s) as placeholders + for ($w = 1; $w < $charWidth; ++$w) { + $row[$this->cursorCol + $w] = [ + 'char' => '', + 'style' => $style, + ]; + } + + $this->cursorCol += $charWidth; + } + + /** + * Build an ANSI style string from the current style state. + */ + private function buildStyleString(): string + { + $codes = []; + + if ($this->styleState['bold']) { + $codes[] = '1'; + } + if ($this->styleState['dim']) { + $codes[] = '2'; + } + if ($this->styleState['italic']) { + $codes[] = '3'; + } + if ($this->styleState['underline']) { + $codes[] = '4'; + } + if ($this->styleState['blink']) { + $codes[] = '5'; + } + if ($this->styleState['reverse']) { + $codes[] = '7'; + } + if ($this->styleState['strikethrough']) { + $codes[] = '9'; + } + if (null !== $this->styleState['fg']) { + $codes[] = $this->styleState['fg']; + } + if (null !== $this->styleState['bg']) { + $codes[] = $this->styleState['bg']; + } + if (null !== $this->styleState['underlineColor']) { + $codes[] = $this->styleState['underlineColor']; + } + + if ([] === $codes) { + return ''; + } + + return "\x1b[".implode(';', $codes).'m'; + } + + /** + * Scroll the screen up by one line. + */ + private function scrollUp(): void + { + array_shift($this->cells); + $this->cells[] = []; + } + + /** + * Parse an escape sequence and return the number of bytes consumed. + */ + private function parseEscapeSequence(string $data, int $start): int + { + $len = strlen($data); + if ($start + 1 >= $len) { + return 1; + } + + $next = $data[$start + 1]; + $nextOrd = ord($next); + + // CSI sequence: ESC [ + if ('[' === $next) { + return $this->parseCsiSequence($data, $start); + } + + // String sequences: OSC (ESC ]), DCS (ESC P), APC (ESC _), PM (ESC ^), SOS (ESC X) + // All terminated by BEL (0x07) or ST (ESC \) + if (']' === $next || 'P' === $next || '_' === $next || '^' === $next || 'X' === $next) { + return $this->parseStringSequence($data, $start); + } + + // nF announced sequences: ESC + intermediate bytes (0x20-0x2F)+ + final byte (0x30-0x7E) + if ($nextOrd >= 0x20 && $nextOrd <= 0x2F) { + $j = $start + 2; + while ($j < $len && ord($data[$j]) >= 0x20 && ord($data[$j]) <= 0x2F) { + ++$j; + } + if ($j < $len && ord($data[$j]) >= 0x30 && ord($data[$j]) <= 0x7E) { + return $j + 1 - $start; + } + + return $len - $start; + } + + // Fe (0x40-0x5F), Fp (0x30-0x3F), Fs (0x60-0x7E) two-byte sequences + if ($nextOrd >= 0x30 && $nextOrd <= 0x7E) { + return 2; + } + + // Unknown: skip the ESC byte + return 1; + } + + /** + * Parse string sequence (OSC, DCS, APC, PM, SOS). + * Format: ESC ... ST (where ST is ESC \ or BEL). + */ + private function parseStringSequence(string $data, int $start): int + { + $len = strlen($data); + $i = $start + 2; // Skip ESC + introducer byte + + // Find terminator: ST (ESC \) or BEL (\x07) + while ($i < $len) { + if ("\x07" === $data[$i]) { + // BEL terminator + return $i - $start + 1; + } + if ("\x1b" === $data[$i] && $i + 1 < $len && '\\' === $data[$i + 1]) { + // ST terminator (ESC \) + return $i - $start + 2; + } + ++$i; + } + + // No terminator found - consume what we have + return $len - $start; + } + + /** + * Parse CSI (Control Sequence Introducer) sequence. + */ + private function parseCsiSequence(string $data, int $start): int + { + $len = strlen($data); + $i = $start + 2; // Skip ESC [ + + // Collect parameter bytes (0x30-0x3F) + $params = ''; + while ($i < $len && ord($data[$i]) >= 0x30 && ord($data[$i]) <= 0x3F) { + $params .= $data[$i]; + ++$i; + } + + // Collect intermediate bytes (0x20-0x2F) + while ($i < $len && ord($data[$i]) >= 0x20 && ord($data[$i]) <= 0x2F) { + ++$i; + } + + // Final byte (0x40-0x7E) + if ($i >= $len) { + return $i - $start; + } + + $finalByte = $data[$i]; + $consumed = $i - $start + 1; + + // Strip private mode prefix ("?") before parsing numeric parameters + $paramStr = str_starts_with($params, '?') ? substr($params, 1) : $params; + $nums = '' !== $paramStr ? array_map('intval', explode(';', $paramStr)) : []; + + switch ($finalByte) { + case 'A': // Cursor Up + $n = $nums[0] ?? 1; + $this->cursorRow = max(0, $this->cursorRow - $n); + break; + + case 'B': // Cursor Down + $n = $nums[0] ?? 1; + $this->cursorRow = min($this->height - 1, $this->cursorRow + $n); + break; + + case 'C': // Cursor Forward + $n = $nums[0] ?? 1; + $this->cursorCol = min($this->width - 1, $this->cursorCol + $n); + break; + + case 'D': // Cursor Back + $n = $nums[0] ?? 1; + $this->cursorCol = max(0, $this->cursorCol - $n); + break; + + case 'G': // Cursor Horizontal Absolute + $col = ($nums[0] ?? 1) - 1; // 1-indexed + $this->cursorCol = max(0, min($this->width - 1, $col)); + break; + + case 'H': // Cursor Position + case 'f': + $row = ($nums[0] ?? 1) - 1; + $col = ($nums[1] ?? 1) - 1; + $this->cursorRow = max(0, min($this->height - 1, $row)); + $this->cursorCol = max(0, min($this->width - 1, $col)); + break; + + case 'J': // Erase in Display + $mode = $nums[0] ?? 0; + $this->eraseInDisplay($mode); + break; + + case 'K': // Erase in Line + $mode = $nums[0] ?? 0; + $this->eraseInLine($mode); + break; + + case 'm': // SGR (Select Graphic Rendition) + $this->handleSgr($paramStr); + break; + + case 'h': // Set Mode - ignore + case 'l': // Reset Mode - ignore + break; + } + + return $consumed; + } + + /** + * Handle SGR (Select Graphic Rendition) - colors and styles. + */ + private function handleSgr(string $params): void + { + if ('' === $params) { + $params = '0'; + } + + $codes = array_map('intval', explode(';', $params)); + $i = 0; + $codeCount = \count($codes); + + while ($i < $codeCount) { + $code = $codes[$i]; + + switch ($code) { + case 0: // Reset all + $this->styleState = self::DEFAULT_STYLE_STATE; + break; + + case 1: // Bold + $this->styleState['bold'] = true; + break; + case 2: // Dim + $this->styleState['dim'] = true; + break; + case 3: // Italic + $this->styleState['italic'] = true; + break; + case 4: // Underline + $this->styleState['underline'] = true; + break; + case 5: // Blink + $this->styleState['blink'] = true; + break; + case 7: // Reverse + $this->styleState['reverse'] = true; + break; + case 9: // Strikethrough + $this->styleState['strikethrough'] = true; + break; + + // Reset individual attributes + case 22: // Reset bold and dim + $this->styleState['bold'] = false; + $this->styleState['dim'] = false; + break; + case 23: // Reset italic + $this->styleState['italic'] = false; + break; + case 24: // Reset underline + $this->styleState['underline'] = false; + break; + case 25: // Reset blink + $this->styleState['blink'] = false; + break; + case 27: // Reset reverse + $this->styleState['reverse'] = false; + break; + case 29: // Reset strikethrough + $this->styleState['strikethrough'] = false; + break; + + // Standard foreground colors (30-37) + case 30: case 31: case 32: case 33: case 34: case 35: case 36: case 37: + $this->styleState['fg'] = (string) $code; + break; + + // Default foreground color + case 39: + $this->styleState['fg'] = null; + break; + + // Standard background colors (40-47) + case 40: case 41: case 42: case 43: case 44: case 45: case 46: case 47: + $this->styleState['bg'] = (string) $code; + break; + + // Default background color + case 49: + $this->styleState['bg'] = null; + break; + + // Bright foreground colors (90-97) + case 90: case 91: case 92: case 93: case 94: case 95: case 96: case 97: + $this->styleState['fg'] = (string) $code; + break; + + // Bright background colors (100-107) + case 100: case 101: case 102: case 103: case 104: case 105: case 106: case 107: + $this->styleState['bg'] = (string) $code; + break; + + // 256-color and true-color foreground: 38;5;N or 38;2;R;G;B + case 38: + if (isset($codes[$i + 1])) { + if (5 === $codes[$i + 1] && isset($codes[$i + 2])) { + // 256-color mode + $this->styleState['fg'] = '38;5;'.$codes[$i + 2]; + $i += 2; + } elseif (2 === $codes[$i + 1] && isset($codes[$i + 2], $codes[$i + 3], $codes[$i + 4])) { + // True-color mode + $this->styleState['fg'] = '38;2;'.$codes[$i + 2].';'.$codes[$i + 3].';'.$codes[$i + 4]; + $i += 4; + } + } + break; + + // 256-color and true-color background: 48;5;N or 48;2;R;G;B + case 48: + if (isset($codes[$i + 1])) { + if (5 === $codes[$i + 1] && isset($codes[$i + 2])) { + // 256-color mode + $this->styleState['bg'] = '48;5;'.$codes[$i + 2]; + $i += 2; + } elseif (2 === $codes[$i + 1] && isset($codes[$i + 2], $codes[$i + 3], $codes[$i + 4])) { + // True-color mode + $this->styleState['bg'] = '48;2;'.$codes[$i + 2].';'.$codes[$i + 3].';'.$codes[$i + 4]; + $i += 4; + } + } + break; + + // 256-color and true-color underline color: 58;5;N or 58;2;R;G;B + case 58: + if (isset($codes[$i + 1])) { + if (5 === $codes[$i + 1] && isset($codes[$i + 2])) { + // 256-color mode + $this->styleState['underlineColor'] = '58;5;'.$codes[$i + 2]; + $i += 2; + } elseif (2 === $codes[$i + 1] && isset($codes[$i + 2], $codes[$i + 3], $codes[$i + 4])) { + // True-color mode + $this->styleState['underlineColor'] = '58;2;'.$codes[$i + 2].';'.$codes[$i + 3].';'.$codes[$i + 4]; + $i += 4; + } + } + break; + + // Default underline color + case 59: + $this->styleState['underlineColor'] = null; + break; + } + + ++$i; + } + } + + /** + * Erase in display. + */ + private function eraseInDisplay(int $mode): void + { + switch ($mode) { + case 0: // Erase from cursor to end of screen + $this->eraseInLine(0); + for ($i = $this->cursorRow + 1; $i < $this->height; ++$i) { + $this->cells[$i] = []; + } + break; + + case 1: // Erase from start to cursor + for ($i = 0; $i < $this->cursorRow; ++$i) { + $this->cells[$i] = []; + } + $this->eraseInLine(1); + break; + + case 2: // Erase entire screen (but don't move cursor) + case 3: + for ($i = 0; $i < $this->height; ++$i) { + $this->cells[$i] = []; + } + break; + } + } + + /** + * Erase in line. + */ + private function eraseInLine(int $mode): void + { + if (!isset($this->cells[$this->cursorRow])) { + $this->cells[$this->cursorRow] = []; + + return; + } + + $row = &$this->cells[$this->cursorRow]; + + switch ($mode) { + case 0: // Erase from cursor to end of line + // If cursor is on a continuation cell, also clear the wide char's main cell + if (isset($row[$this->cursorCol]) && '' === $row[$this->cursorCol]['char'] + && $this->cursorCol > 0 && isset($row[$this->cursorCol - 1])) { + unset($row[$this->cursorCol - 1]); + } + foreach ($row as $col => $cell) { + if ($col >= $this->cursorCol) { + unset($row[$col]); + } + } + break; + + case 1: // Erase from start of line to cursor + foreach ($row as $col => $cell) { + if ($col <= $this->cursorCol) { + unset($row[$col]); + } + } + // If the last erased cell was a wide char's main cell, also clear its continuation + if (isset($row[$this->cursorCol + 1]) && '' === $row[$this->cursorCol + 1]['char']) { + unset($row[$this->cursorCol + 1]); + } + break; + + case 2: // Erase entire line + $row = []; + break; + } + } +} diff --git a/src/Symfony/Component/Tui/Terminal/TeeTerminal.php b/src/Symfony/Component/Tui/Terminal/TeeTerminal.php new file mode 100644 index 0000000000000..fb055fce9bfea --- /dev/null +++ b/src/Symfony/Component/Tui/Terminal/TeeTerminal.php @@ -0,0 +1,133 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Terminal; + +/** + * Terminal that delegates to multiple terminals simultaneously. + * + * This is useful for: + * - Running examples with both a real terminal and a VirtualTerminal for testing + * - Recording terminal output while displaying it + * - Debugging terminal output + * + * The primary terminal is used for input handling and dimension queries. + * All terminals receive write operations. + * + * @experimental + * + * @author Fabien Potencier + */ +final class TeeTerminal implements TerminalInterface +{ + /** + * @param TerminalInterface $primary The primary terminal (used for input and dimensions) + * @param TerminalInterface $secondary Additional terminal that receives writes + */ + public function __construct( + private TerminalInterface $primary, + private TerminalInterface $secondary, + ) { + } + + public function start(callable $onInput, callable $onResize, callable $onKittyProtocolActivated): void + { + // Start primary with real callbacks + $this->primary->start($onInput, $onResize, $onKittyProtocolActivated); + + // Start secondary with no-op callbacks (they just record) + $noopInput = function (string $data): void {}; + $noopResize = function (): void {}; + $noopKitty = function (): void {}; + + $this->secondary->start($noopInput, $noopResize, $noopKitty); + } + + public function stop(): void + { + $this->primary->stop(); + $this->secondary->stop(); + } + + public function write(string $data): void + { + $this->primary->write($data); + $this->secondary->write($data); + } + + public function getColumns(): int + { + return $this->primary->getColumns(); + } + + public function getRows(): int + { + return $this->primary->getRows(); + } + + public function isKittyProtocolActive(): bool + { + return $this->primary->isKittyProtocolActive(); + } + + public function moveBy(int $lines): void + { + $this->primary->moveBy($lines); + $this->secondary->moveBy($lines); + } + + public function hideCursor(): void + { + $this->primary->hideCursor(); + $this->secondary->hideCursor(); + } + + public function showCursor(): void + { + $this->primary->showCursor(); + $this->secondary->showCursor(); + } + + public function clearLine(): void + { + $this->primary->clearLine(); + $this->secondary->clearLine(); + } + + public function clearFromCursor(): void + { + $this->primary->clearFromCursor(); + $this->secondary->clearFromCursor(); + } + + public function clearScreen(): void + { + $this->primary->clearScreen(); + $this->secondary->clearScreen(); + } + + public function setTitle(string $title): void + { + $this->primary->setTitle($title); + $this->secondary->setTitle($title); + } + + public function bell(): void + { + $this->primary->bell(); + $this->secondary->bell(); + } + + public function isVirtual(): bool + { + return $this->primary->isVirtual(); + } +} diff --git a/src/Symfony/Component/Tui/Terminal/Terminal.php b/src/Symfony/Component/Tui/Terminal/Terminal.php new file mode 100644 index 0000000000000..6ac34bc001bc9 --- /dev/null +++ b/src/Symfony/Component/Tui/Terminal/Terminal.php @@ -0,0 +1,342 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Terminal; + +use Revolt\EventLoop; +use Symfony\Component\Tui\Input\StdinBuffer; + +/** + * Real terminal implementation using stdin/stdout. + * + * @experimental + * + * @author Fabien Potencier + */ +final class Terminal implements TerminalInterface +{ + private ?StdinBuffer $stdinBuffer = null; + + private string $initialSttyState = ''; + private bool $kittyProtocolActive = false; + private bool $started = false; + private ?string $stdinCallbackId = null; + private ?string $signalCallbackId = null; + + /** @var callable(string): void|null */ + private $onInput; + + /** @var callable(): void|null */ + private $onResize; + + /** @var callable(): void|null */ + private $onKittyProtocolActivated; + + // Cached terminal dimensions (refreshed on SIGWINCH) + private ?int $cachedColumns = null; + private ?int $cachedRows = null; + + public function start(callable $onInput, callable $onResize, callable $onKittyProtocolActivated): void + { + if ($this->started) { + return; + } + + $this->onInput = $onInput; + $this->onResize = $onResize; + $this->onKittyProtocolActivated = $onKittyProtocolActivated; + $this->started = true; + + // Save initial terminal state and enable raw mode + if ($this->hasSttyAvailable()) { + $this->initialSttyState = (string) shell_exec('stty -g'); + + // Enable raw mode, equivalent to cfmakeraw(), matching Node.js + // setRawMode(true) used by the Pi reference implementation. + // This disables canonical mode, echo, signal interpretation, and + // extended input processing so that ALL key combinations (including + // Ctrl+C, Ctrl+Z, Alt+Backspace) are delivered as raw bytes to the + // application rather than being intercepted by the kernel. + shell_exec('stty raw -echo'); + } + + // Set up stdin buffer for proper sequence parsing - must be done + // BEFORE sending any queries so responses can be captured + $this->setupStdinBuffer(); + + // Enable bracketed paste mode + $this->write("\x1b[?2004h"); + + // Set up signal handlers for resize using Revolt's event loop + if (\defined('SIGWINCH')) { + $this->signalCallbackId = EventLoop::onSignal(\SIGWINCH, function (): void { + // Clear cached dimensions so they get re-read + $this->cachedColumns = null; + $this->cachedRows = null; + + if (null !== $this->onResize) { + ($this->onResize)(); + } + }); + } + + // Query for Kitty keyboard protocol support + // If terminal supports it, it will respond with \x1b[?u + // which is handled in setupStdinBuffer() + $this->write("\x1b[?u"); + + // Register STDIN watcher with Revolt's event loop for non-blocking input + $this->stdinCallbackId = EventLoop::onReadable(\STDIN, function (): void { + $data = fread(\STDIN, 4096); + if (false !== $data && '' !== $data && null !== $this->stdinBuffer) { + $this->stdinBuffer->process($data); + } + }); + } + + public function stop(): void + { + if (!$this->started) { + return; + } + $this->started = false; + + // Cancel STDIN watcher + if (null !== $this->stdinCallbackId) { + EventLoop::cancel($this->stdinCallbackId); + $this->stdinCallbackId = null; + } + + // Cancel signal watcher + if (null !== $this->signalCallbackId) { + EventLoop::cancel($this->signalCallbackId); + $this->signalCallbackId = null; + } + + // Disable bracketed paste mode + $this->write("\x1b[?2004l"); + + // Disable Kitty keyboard protocol if we enabled it + if ($this->kittyProtocolActive) { + $this->write("\x1b[kittyProtocolActive = false; + } + + // Clear stdin buffer + if (null !== $this->stdinBuffer) { + $this->stdinBuffer->clear(); + $this->stdinBuffer = null; + } + + // Restore terminal state + if ('' !== $this->initialSttyState) { + shell_exec('stty '.escapeshellarg(trim($this->initialSttyState))); + } + + $this->onInput = null; + $this->onResize = null; + $this->onKittyProtocolActivated = null; + } + + public function write(string $data): void + { + fwrite(\STDOUT, $data); + fflush(\STDOUT); + } + + public function getColumns(): int + { + if (null === $this->cachedColumns) { + $this->refreshDimensions(); + } + + return $this->cachedColumns ?? 80; + } + + public function getRows(): int + { + if (null === $this->cachedRows) { + $this->refreshDimensions(); + } + + return $this->cachedRows ?? 24; + } + + public function isKittyProtocolActive(): bool + { + return $this->kittyProtocolActive; + } + + public function moveBy(int $lines): void + { + if ($lines > 0) { + $this->write("\x1b[{$lines}B"); + } elseif ($lines < 0) { + $this->write("\x1b[".(-$lines).'A'); + } + } + + public function hideCursor(): void + { + $this->write("\x1b[?25l"); + } + + public function showCursor(): void + { + $this->write("\x1b[?25h"); + } + + public function clearLine(): void + { + $this->write("\x1b[2K"); + } + + public function clearFromCursor(): void + { + $this->write("\x1b[0J"); + } + + public function clearScreen(): void + { + $this->write("\x1b[2J\x1b[H"); + } + + public function setTitle(string $title): void + { + $safe = preg_replace("/[\x00-\x1f\x7f]/", '', $title); + $this->write("\x1b]0;{$safe}\x07"); + } + + public function bell(): void + { + if ('Darwin' === \PHP_OS_FAMILY && file_exists('/System/Library/Sounds/Glass.aiff')) { + // On macOS, play the system sound in the background to avoid + // blocking the event loop. + $this->fireAndForget(['afplay', '/System/Library/Sounds/Glass.aiff']); + + return; + } + + $this->write("\x07"); + } + + public function isVirtual(): bool + { + return false; + } + + /** + * Start a command in the background (fire-and-forget). + * + * Does not wait for the process to complete or collect output. + * + * @param list $command + */ + public function fireAndForget(array $command): void + { + $process = proc_open( + $command, + [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); + + if (\is_resource($process)) { + fclose($pipes[0]); + fclose($pipes[1]); + fclose($pipes[2]); + // Do not call proc_close(), let the process run detached. + } + } + + /** + * Refresh terminal dimensions from stty. + */ + private function refreshDimensions(): void + { + // Query terminal size directly using stty + // shell_exec is required here because stty must operate on the + // process's own tty; proc_open gives the child a pipe, not the tty. + $sttyOutput = shell_exec('stty size 2>/dev/null'); + + if (null !== $sttyOutput && false !== $sttyOutput && preg_match('/^(\d+)\s+(\d+)$/', trim($sttyOutput), $matches)) { + $this->cachedRows = (int) $matches[1]; + $this->cachedColumns = (int) $matches[2]; + } else { + // Default fallback + $this->cachedColumns = 80; + $this->cachedRows = 24; + } + } + + /** + * Set up StdinBuffer to split batched input into individual sequences. + */ + private function setupStdinBuffer(): void + { + $this->stdinBuffer = new StdinBuffer(); + + // Kitty protocol response pattern: \x1b[?u + $kittyResponsePattern = '/^\x1b\[\?(\d+)u$/'; + + // Forward individual sequences to the input handler + $this->stdinBuffer->onData(function (string $sequence) use ($kittyResponsePattern): void { + // Check for Kitty protocol response (only if not already enabled) + if (!$this->kittyProtocolActive && preg_match($kittyResponsePattern, $sequence)) { + $this->kittyProtocolActive = true; + // Enable Kitty keyboard protocol with enhanced features + // Flag 1 = disambiguate escape codes + // Flag 2 = report event types (press/repeat/release) + // Flag 4 = report alternate keys + $this->write("\x1b[>7u"); + + // Notify the TUI that Kitty protocol is active + if (null !== $this->onKittyProtocolActivated) { + ($this->onKittyProtocolActivated)(); + } + + return; // Don't forward protocol response to TUI + } + + if (null !== $this->onInput) { + ($this->onInput)($sequence); + } + }); + + // Re-wrap paste content with bracketed paste markers + $this->stdinBuffer->onPaste(function (string $content): void { + if (null !== $this->onInput) { + ($this->onInput)("\x1b[200~".$content."\x1b[201~"); + } + }); + } + + /** + * Check if stty is available on this system. + */ + private function hasSttyAvailable(): bool + { + static $available = null; + + if (null !== $available) { + return $available; + } + + if ('\\' === \DIRECTORY_SEPARATOR) { + return $available = false; + } + + return $available = (bool) shell_exec('stty 2>/dev/null'); + } +} diff --git a/src/Symfony/Component/Tui/Terminal/TerminalInterface.php b/src/Symfony/Component/Tui/Terminal/TerminalInterface.php new file mode 100644 index 0000000000000..ab7df73ade3fe --- /dev/null +++ b/src/Symfony/Component/Tui/Terminal/TerminalInterface.php @@ -0,0 +1,116 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Terminal; + +/** + * Interface for terminal implementations. + * + * Provides abstraction over terminal I/O for the TUI framework. + * Implementations handle raw mode, input reading, and output writing. + * + * @experimental + * + * @author Fabien Potencier + */ +interface TerminalInterface +{ + /** + * Start the terminal with input and resize handlers. + * + * This typically enables raw mode, sets up signal handlers, + * and prepares the terminal for TUI operation. + * + * @param callable(string): void $onInput Called when input is received + * @param callable(): void $onResize Called when terminal is resized + * @param callable(): void $onKittyProtocolActivated Called when Kitty keyboard protocol is detected + */ + public function start(callable $onInput, callable $onResize, callable $onKittyProtocolActivated): void; + + /** + * Stop the terminal and restore original state. + * + * This should restore the terminal to its original mode, + * remove signal handlers, and clean up resources. + */ + public function stop(): void; + + /** + * Write data to the terminal output. + */ + public function write(string $data): void; + + /** + * Get the terminal width in columns. + */ + public function getColumns(): int; + + /** + * Get the terminal height in rows. + */ + public function getRows(): int; + + /** + * Check if Kitty keyboard protocol is active. + * + * The Kitty protocol provides enhanced key reporting including + * key release events and modifier disambiguation. + */ + public function isKittyProtocolActive(): bool; + + /** + * Move cursor up (negative) or down (positive) by N lines. + */ + public function moveBy(int $lines): void; + + /** + * Hide the terminal cursor. + */ + public function hideCursor(): void; + + /** + * Show the terminal cursor. + */ + public function showCursor(): void; + + /** + * Clear the current line. + */ + public function clearLine(): void; + + /** + * Clear from cursor to end of screen. + */ + public function clearFromCursor(): void; + + /** + * Clear entire screen and move cursor to home position. + */ + public function clearScreen(): void; + + /** + * Set the terminal window title. + */ + public function setTitle(string $title): void; + + /** + * Ring the terminal bell. + * + * Emits the BEL character (\x07) which causes the terminal + * to produce an audible or visual notification. + */ + public function bell(): void; + + /** + * Whether this is a virtual (non-TTY) terminal. + */ + public function isVirtual(): bool; +} diff --git a/src/Symfony/Component/Tui/Terminal/VirtualTerminal.php b/src/Symfony/Component/Tui/Terminal/VirtualTerminal.php new file mode 100644 index 0000000000000..fd4beb72d57bd --- /dev/null +++ b/src/Symfony/Component/Tui/Terminal/VirtualTerminal.php @@ -0,0 +1,241 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Terminal; + +use Symfony\Component\Tui\Input\StdinBuffer; + +/** + * Virtual terminal for testing. + * + * Captures output and allows simulating input for unit tests. + * Uses StdinBuffer for input parsing to match real Terminal behavior. + * + * Virtual terminals don't have a physical screen: they don't scroll + * content out of the addressable area and don't respond to terminal + * queries (e.g. cell size). This is used by the rendering pipeline + * to adjust its behavior accordingly. + * + * @experimental + * + * @author Fabien Potencier + */ +final class VirtualTerminal implements TerminalInterface +{ + private string $output = ''; + private bool $cursorVisible = true; + private ?StdinBuffer $stdinBuffer = null; + + /** @var callable(): void|null */ + private $onResize; + + public function __construct( + private int $columns = 80, + private int $rows = 24, + private bool $kittyProtocolActive = false, + ) { + } + + public function start(callable $onInput, callable $onResize, callable $onKittyProtocolActivated): void + { + $this->onResize = $onResize; + + // Set up StdinBuffer for input parsing (matches real Terminal behavior) + $this->stdinBuffer = new StdinBuffer(); + $this->stdinBuffer->onData($onInput); + + // Re-wrap paste content with bracketed paste markers (matches real Terminal behavior) + $this->stdinBuffer->onPaste(function (string $content) use ($onInput): void { + $onInput("\x1b[200~".$content."\x1b[201~"); + }); + } + + public function stop(): void + { + $this->onResize = null; + $this->stdinBuffer = null; + } + + public function write(string $data): void + { + $this->output .= $data; + } + + public function getColumns(): int + { + return $this->columns; + } + + public function getRows(): int + { + return $this->rows; + } + + public function isKittyProtocolActive(): bool + { + return $this->kittyProtocolActive; + } + + public function moveBy(int $lines): void + { + if ($lines > 0) { + $this->write("\x1b[{$lines}B"); + } elseif ($lines < 0) { + $this->write("\x1b[".(-$lines).'A'); + } + } + + public function hideCursor(): void + { + $this->cursorVisible = false; + $this->write("\x1b[?25l"); + } + + public function showCursor(): void + { + $this->cursorVisible = true; + $this->write("\x1b[?25h"); + } + + public function clearLine(): void + { + $this->write("\x1b[2K"); + } + + public function clearFromCursor(): void + { + $this->write("\x1b[0J"); + } + + public function clearScreen(): void + { + $this->write("\x1b[2J\x1b[H"); + } + + public function setTitle(string $title): void + { + $safe = preg_replace("/[\x00-\x1f\x7f]/", '', $title); + $this->write("\x1b]0;{$safe}\x07"); + } + + public function bell(): void + { + $this->write("\x07"); + } + + public function isVirtual(): bool + { + return true; + } + + // Testing helpers + + /** + * Get all output written to the terminal. + */ + public function getOutput(): string + { + return $this->output; + } + + /** + * Clear the output buffer. + */ + public function clearOutput(): void + { + $this->output = ''; + } + + /** + * Get all output written since the last call and clear the buffer. + * + * This is useful for streaming scenarios where you need to get + * the output diff since the last read (e.g. publishing to Mercure). + */ + public function consumeOutput(): string + { + $output = $this->output; + $this->output = ''; + + return $output; + } + + /** + * Simulate raw input from the user. + * + * Input is processed through StdinBuffer for proper sequence parsing, + * matching the behavior of the real Terminal. + */ + public function simulateInput(string $data): void + { + if (null !== $this->stdinBuffer) { + $this->stdinBuffer->process($data); + } + } + + /** + * Flush any pending input in the buffer. + * + * This should be called when no more input is expected (e.g., end of test input) + * to ensure any pending Escape key is emitted. + */ + public function flushInput(): void + { + if (null !== $this->stdinBuffer) { + $this->stdinBuffer->flush(); + } + } + + /** + * Simulate terminal resize. + */ + public function simulateResize(int $columns, int $rows): void + { + $this->columns = $columns; + $this->rows = $rows; + + if (null !== $this->onResize) { + ($this->onResize)(); + } + } + + /** + * Check if cursor is visible. + */ + public function isCursorVisible(): bool + { + return $this->cursorVisible; + } + + /** + * Set Kitty protocol state. + */ + public function setKittyProtocolActive(bool $active): void + { + $this->kittyProtocolActive = $active; + } + + /** + * Get output split into lines (stripping ANSI codes for comparison). + * + * @return string[] + */ + public function getOutputLines(): array + { + $output = $this->output; + + // Remove synchronized output markers + $output = str_replace(["\x1b[?2026h", "\x1b[?2026l"], '', $output); + + // Split by newlines + return explode("\n", $output); + } +} diff --git a/src/Symfony/Component/Tui/Tests/Ansi/AnsiCodeTrackerTest.php b/src/Symfony/Component/Tui/Tests/Ansi/AnsiCodeTrackerTest.php new file mode 100644 index 0000000000000..1bb26c5337547 --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Ansi/AnsiCodeTrackerTest.php @@ -0,0 +1,432 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Ansi; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Ansi\AnsiCodeTracker; + +class AnsiCodeTrackerTest extends TestCase +{ + private AnsiCodeTracker $tracker; + + protected function setUp(): void + { + $this->tracker = new AnsiCodeTracker(); + } + + // -------------------------------------------------- + // Basic SGR attributes on/off + // -------------------------------------------------- + + /** + * @return iterable + */ + public static function sgrAttributeOnOffProvider(): iterable + { + yield 'bold' => [1, 22]; + yield 'dim' => [2, 22]; + yield 'italic' => [3, 23]; + yield 'underline' => [4, 24]; + yield 'blink' => [5, 25]; + yield 'inverse' => [7, 27]; + yield 'hidden' => [8, 28]; + yield 'strikethrough' => [9, 29]; + } + + #[DataProvider('sgrAttributeOnOffProvider')] + public function testSgrAttributeOnAndOff(int $onCode, int $offCode) + { + $this->tracker->process("\x1b[{$onCode}m"); + $this->assertTrue($this->tracker->hasActiveCodes()); + $this->assertSame("\x1b[{$onCode}m", $this->tracker->getActiveCodes()); + + $this->tracker->process("\x1b[{$offCode}m"); + $this->assertFalse($this->tracker->hasActiveCodes()); + } + + public function testBoldAndDimResetTogether() + { + $this->tracker->process("\x1b[1m"); + $this->tracker->process("\x1b[2m"); + $this->assertSame("\x1b[1;2m", $this->tracker->getActiveCodes()); + + // SGR 22 resets both bold and dim + $this->tracker->process("\x1b[22m"); + $this->assertFalse($this->tracker->hasActiveCodes()); + } + + public function testSgr21SetsDoubleUnderline() + { + $this->tracker->process("\x1b[21m"); + $this->assertTrue($this->tracker->hasActiveCodes()); + $this->assertSame("\x1b[21m", $this->tracker->getActiveCodes()); + } + + public function testSgr21DoesNotTurnOffBold() + { + $this->tracker->process("\x1b[1m"); + $this->assertTrue($this->tracker->hasActiveCodes()); + + // SGR 21 is "doubly underlined" per ECMA-48, not bold-off. + // SGR 22 is the correct code to turn off bold. + $this->tracker->process("\x1b[21m"); + $this->assertTrue($this->tracker->hasActiveCodes()); + $this->assertSame("\x1b[1;21m", $this->tracker->getActiveCodes()); + } + + public function testSgr24ResetsDoubleUnderline() + { + $this->tracker->process("\x1b[21m"); + $this->assertTrue($this->tracker->hasActiveCodes()); + + $this->tracker->process("\x1b[24m"); + $this->assertFalse($this->tracker->hasActiveCodes()); + } + + public function testSgr24ResetsBothUnderlineAndDoubleUnderline() + { + $this->tracker->process("\x1b[4m"); + $this->tracker->process("\x1b[21m"); + $this->assertSame("\x1b[4;21m", $this->tracker->getActiveCodes()); + + $this->tracker->process("\x1b[24m"); + $this->assertFalse($this->tracker->hasActiveCodes()); + } + + public function testGetLineEndResetWithDoubleUnderline() + { + $this->tracker->process("\x1b[21m"); + $this->assertSame("\x1b[24m", $this->tracker->getLineEndReset()); + } + + // -------------------------------------------------- + // Color tracking + // -------------------------------------------------- + + /** + * @param list $sequences + */ + #[DataProvider('colorTrackingProvider')] + public function testColorTracking(array $sequences, string $expected) + { + foreach ($sequences as $seq) { + $this->tracker->process($seq); + } + $this->assertSame($expected, $this->tracker->getActiveCodes()); + } + + /** + * @return iterable, string}> + */ + public static function colorTrackingProvider(): iterable + { + // Standard foreground (30-37) + yield 'standard fg replaces previous' => [["\x1b[31m", "\x1b[37m"], "\x1b[37m"]; + // Bright foreground (90-97) + yield 'bright fg replaces previous' => [["\x1b[91m", "\x1b[97m"], "\x1b[97m"]; + // Standard background (40-47) + yield 'standard bg replaces previous' => [["\x1b[41m", "\x1b[47m"], "\x1b[47m"]; + // Bright background (100-107) + yield 'bright bg replaces previous' => [["\x1b[101m", "\x1b[107m"], "\x1b[107m"]; + // Foreground + background together + yield 'fg and bg together' => [["\x1b[31m", "\x1b[42m"], "\x1b[31;42m"]; + // 256-color mode + yield '256-color fg' => [["\x1b[38;5;196m"], "\x1b[38;5;196m"]; + yield '256-color bg' => [["\x1b[48;5;82m"], "\x1b[48;5;82m"]; + yield '256-color fg and bg' => [["\x1b[38;5;196m", "\x1b[48;5;82m"], "\x1b[38;5;196;48;5;82m"]; + // RGB color mode + yield 'RGB fg' => [["\x1b[38;2;255;0;0m"], "\x1b[38;2;255;0;0m"]; + yield 'RGB bg' => [["\x1b[48;2;0;255;0m"], "\x1b[48;2;0;255;0m"]; + yield 'RGB fg and bg' => [["\x1b[38;2;255;0;0m", "\x1b[48;2;0;255;0m"], "\x1b[38;2;255;0;0;48;2;0;255;0m"]; + } + + // -------------------------------------------------- + // Reset behavior + // -------------------------------------------------- + + public function testSgr0ResetsEverything() + { + $this->tracker->process("\x1b[1m"); // bold + $this->tracker->process("\x1b[3m"); // italic + $this->tracker->process("\x1b[31m"); // red fg + $this->tracker->process("\x1b[42m"); // green bg + $this->assertTrue($this->tracker->hasActiveCodes()); + + $this->tracker->process("\x1b[0m"); + $this->assertFalse($this->tracker->hasActiveCodes()); + $this->assertSame('', $this->tracker->getActiveCodes()); + } + + public function testSgr39ResetsForegroundOnly() + { + $this->tracker->process("\x1b[31m"); // red fg + $this->tracker->process("\x1b[42m"); // green bg + $this->assertSame("\x1b[31;42m", $this->tracker->getActiveCodes()); + + $this->tracker->process("\x1b[39m"); // reset fg + $this->assertSame("\x1b[42m", $this->tracker->getActiveCodes()); + } + + public function testSgr49ResetsBackgroundOnly() + { + $this->tracker->process("\x1b[31m"); // red fg + $this->tracker->process("\x1b[42m"); // green bg + $this->assertSame("\x1b[31;42m", $this->tracker->getActiveCodes()); + + $this->tracker->process("\x1b[49m"); // reset bg + $this->assertSame("\x1b[31m", $this->tracker->getActiveCodes()); + } + + // -------------------------------------------------- + // Combined codes + // -------------------------------------------------- + + /** + * @param list $sequences + */ + #[DataProvider('combinedCodesProvider')] + public function testCombinedCodes(array $sequences, string $expected) + { + foreach ($sequences as $seq) { + $this->tracker->process($seq); + } + $this->assertSame($expected, $this->tracker->getActiveCodes()); + } + + /** + * @return iterable, string}> + */ + public static function combinedCodesProvider(): iterable + { + yield 'bold + red fg' => [["\x1b[1;31m"], "\x1b[1;31m"]; + yield 'bold + italic + underline + red fg + green bg' => [["\x1b[1;3;4;31;42m"], "\x1b[1;3;4;31;42m"]; + yield 'reset then green' => [["\x1b[1;31m", "\x1b[0;32m"], "\x1b[32m"]; + yield 'bold + 256-color red' => [["\x1b[1;38;5;196m"], "\x1b[1;38;5;196m"]; + yield 'bold + RGB red' => [["\x1b[1;38;2;255;0;0m"], "\x1b[1;38;2;255;0;0m"]; + } + + // -------------------------------------------------- + // getActiveCodes() + // -------------------------------------------------- + + public function testGetActiveCodesPreservesOrder() + { + // Order should always be: bold, dim, italic, underline, blink, inverse, hidden, strikethrough, fg, bg + $this->tracker->process("\x1b[42m"); // bg first + $this->tracker->process("\x1b[1m"); // then bold + $this->tracker->process("\x1b[31m"); // then fg + $this->tracker->process("\x1b[4m"); // then underline + + // Output is always in canonical order regardless of input order + $this->assertSame("\x1b[1;4;31;42m", $this->tracker->getActiveCodes()); + } + + // -------------------------------------------------- + // hasActiveCodes() + // -------------------------------------------------- + + /** + * @return iterable + */ + public static function hasActiveCodesProvider(): iterable + { + yield 'attribute (bold)' => ["\x1b[1m"]; + yield 'foreground color' => ["\x1b[31m"]; + yield 'background color' => ["\x1b[42m"]; + } + + #[DataProvider('hasActiveCodesProvider')] + public function testHasActiveCodesReturnsTrueWithCode(string $code) + { + $this->tracker->process($code); + $this->assertTrue($this->tracker->hasActiveCodes()); + } + + public function testHasActiveCodesReturnsFalseAfterReset() + { + $this->tracker->process("\x1b[1;31;42m"); + $this->assertTrue($this->tracker->hasActiveCodes()); + + $this->tracker->process("\x1b[0m"); + $this->assertFalse($this->tracker->hasActiveCodes()); + } + + // -------------------------------------------------- + // getLineEndReset() + // -------------------------------------------------- + + public function testGetLineEndResetReturnsEmptyWhenNoUnderline() + { + $this->assertSame('', $this->tracker->getLineEndReset()); + + $this->tracker->process("\x1b[1m"); // bold, not underline + $this->assertSame('', $this->tracker->getLineEndReset()); + } + + public function testGetLineEndResetReturnsResetWhenUnderlineActive() + { + $this->tracker->process("\x1b[4m"); + $this->assertSame("\x1b[24m", $this->tracker->getLineEndReset()); + } + + public function testGetLineEndResetReturnsResetWhenUnderlineActiveWithOtherCodes() + { + $this->tracker->process("\x1b[1;4;31m"); // bold + underline + red + $this->assertSame("\x1b[24m", $this->tracker->getLineEndReset()); + } + + public function testGetLineEndResetReturnsEmptyAfterUnderlineOff() + { + $this->tracker->process("\x1b[4m"); + $this->assertSame("\x1b[24m", $this->tracker->getLineEndReset()); + + $this->tracker->process("\x1b[24m"); + $this->assertSame('', $this->tracker->getLineEndReset()); + } + + // -------------------------------------------------- + // processText() + // -------------------------------------------------- + + public function testProcessTextUpdatesStateFromAnsiCodes() + { + $this->tracker->processText("Hello \x1b[1;31mworld\x1b[0m"); + // After processing, the reset at the end clears everything + $this->assertFalse($this->tracker->hasActiveCodes()); + } + + public function testProcessTextTracksActiveCodesAtEnd() + { + $this->tracker->processText("Hello \x1b[1;31mworld"); + // bold + red is still active (no reset at end) + $this->assertTrue($this->tracker->hasActiveCodes()); + $this->assertSame("\x1b[1;31m", $this->tracker->getActiveCodes()); + } + + public function testProcessTextWithMultipleSequences() + { + $this->tracker->processText("\x1b[1mHello \x1b[31mworld \x1b[42mfoo"); + // bold + red fg + green bg + $this->assertSame("\x1b[1;31;42m", $this->tracker->getActiveCodes()); + } + + public function testProcessTextWithNoAnsiCodes() + { + $this->tracker->processText('Hello world'); + $this->assertFalse($this->tracker->hasActiveCodes()); + } + + public function testProcessTextWithEmptyString() + { + $this->tracker->processText(''); + $this->assertFalse($this->tracker->hasActiveCodes()); + } + + // -------------------------------------------------- + // Edge cases + // -------------------------------------------------- + + public function testEmptyParamsActsAsReset() + { + $this->tracker->process("\x1b[1;31m"); // bold + red + $this->assertTrue($this->tracker->hasActiveCodes()); + + $this->tracker->process("\x1b[m"); // empty params = reset + $this->assertFalse($this->tracker->hasActiveCodes()); + } + + public function testNonSgrSequencesAreIgnored() + { + $this->tracker->process("\x1b[2J"); // clear screen - not an SGR code + $this->assertFalse($this->tracker->hasActiveCodes()); + } + + public function testCursorMovementSequencesAreIgnored() + { + $this->tracker->process("\x1b[10G"); // cursor to column 10 + $this->assertFalse($this->tracker->hasActiveCodes()); + + $this->tracker->process("\x1b[5A"); // cursor up 5 + $this->assertFalse($this->tracker->hasActiveCodes()); + } + + public function testResetMethod() + { + $this->tracker->process("\x1b[1;3;4;31;42m"); + $this->assertTrue($this->tracker->hasActiveCodes()); + + $this->tracker->reset(); + $this->assertFalse($this->tracker->hasActiveCodes()); + $this->assertSame('', $this->tracker->getActiveCodes()); + } + + /** + * @return iterable + */ + public static function colorReplacementProvider(): iterable + { + yield 'standard replaces standard' => ["\x1b[31m", "\x1b[32m", "\x1b[32m"]; + yield '256-color replaces standard' => ["\x1b[31m", "\x1b[38;5;82m", "\x1b[38;5;82m"]; + yield 'RGB replaces 256-color' => ["\x1b[38;5;82m", "\x1b[38;2;255;128;0m", "\x1b[38;2;255;128;0m"]; + } + + #[DataProvider('colorReplacementProvider')] + public function testColorReplacementOverwritesPreviousColor(string $first, string $second, string $expected) + { + $this->tracker->process($first); + $this->tracker->process($second); + + $this->assertSame($expected, $this->tracker->getActiveCodes()); + } + + public function testProcessTextIgnoresNonSgrSequencesInText() + { + $this->tracker->processText("\x1b[2JHello\x1b[1mworld"); + // Only bold should be active, \x1b[2J is not SGR + $this->assertSame("\x1b[1m", $this->tracker->getActiveCodes()); + } + + // -------------------------------------------------- + // Malformed extended color sequences + // -------------------------------------------------- + + /** + * @return iterable + */ + public static function malformedExtendedColorProvider(): iterable + { + yield '256-color fg missing color number (38;5)' => ["\x1b[38;5m"]; + yield '256-color bg missing color number (48;5)' => ["\x1b[48;5m"]; + yield 'RGB fg missing component (38;2;255;0)' => ["\x1b[38;2;255;0m"]; + yield 'RGB bg missing components (48;2;255)' => ["\x1b[48;2;255m"]; + yield 'RGB fg no components (38;2)' => ["\x1b[38;2m"]; + yield '38 alone' => ["\x1b[38m"]; + yield '48 alone' => ["\x1b[48m"]; + } + + #[DataProvider('malformedExtendedColorProvider')] + public function testMalformedExtendedColorIsIgnored(string $sequence) + { + $this->tracker->process($sequence); + $this->assertFalse($this->tracker->hasActiveCodes()); + } + + public function testMalformed256ColorDoesNotAffectSubsequentCodes() + { + // Bold should still be set from a previous valid code + $this->tracker->process("\x1b[1m"); + $this->tracker->process("\x1b[38;5m"); // malformed; should be ignored + $this->assertTrue($this->tracker->hasActiveCodes()); + $this->assertSame("\x1b[1m", $this->tracker->getActiveCodes()); + } +} diff --git a/src/Symfony/Component/Tui/Tests/Ansi/AnsiUtilsTest.php b/src/Symfony/Component/Tui/Tests/Ansi/AnsiUtilsTest.php new file mode 100644 index 0000000000000..0cb562e85eba0 --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Ansi/AnsiUtilsTest.php @@ -0,0 +1,508 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Ansi; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Ansi\AnsiUtils; + +class AnsiUtilsTest extends TestCase +{ + /** + * @return iterable + */ + public static function visibleWidthSimpleProvider(): iterable + { + yield 'simple word' => ['Hello', 5]; + yield 'two words' => ['Hello World', 11]; + yield 'empty string' => ['', 0]; + } + + #[DataProvider('visibleWidthSimpleProvider')] + public function testVisibleWidthSimple(string $input, int $expected) + { + $this->assertSame($expected, AnsiUtils::visibleWidth($input)); + } + + public function testVisibleWidthWithAnsiCodes() + { + // Red "Hello" with reset + $this->assertSame(5, AnsiUtils::visibleWidth("\x1b[31mHello\x1b[0m")); + + // Bold + colors + $this->assertSame(5, AnsiUtils::visibleWidth("\x1b[1;31mHello\x1b[0m")); + } + + public function testVisibleWidthWithEmoji() + { + // Most emojis are 2 columns wide + $this->assertSame(2, AnsiUtils::visibleWidth('😀')); + $this->assertSame(4, AnsiUtils::visibleWidth('😀😀')); + } + + public function testVisibleWidthWithWideChars() + { + // CJK characters are 2 columns wide + $this->assertSame(2, AnsiUtils::visibleWidth('日')); + $this->assertSame(4, AnsiUtils::visibleWidth('日本')); + } + + public function testVisibleWidthConsistencyBetweenSlowPathAndGraphemeWidth() + { + // Ensure visibleWidth (which uses mb_strwidth directly) and graphemeWidth + // produce consistent results for single graphemes + $graphemes = ['A', '日', '本', '😀']; + + foreach ($graphemes as $grapheme) { + $this->assertSame( + AnsiUtils::graphemeWidth($grapheme), + AnsiUtils::visibleWidth($grapheme), + \sprintf('Width mismatch for grapheme "%s"', $grapheme), + ); + } + } + + public function testStripAnsiCodes() + { + $this->assertSame('Hello', AnsiUtils::stripAnsiCodes("\x1b[31mHello\x1b[0m")); + $this->assertSame('Hello', AnsiUtils::stripAnsiCodes('Hello')); + $this->assertSame('', AnsiUtils::stripAnsiCodes("\x1b[0m")); + } + + public function testStripAnsiCodesWithHyperlinks() + { + $hyperlink = "\x1b]8;;https://example.com\x07Click\x1b]8;;\x07"; + $this->assertSame('Click', AnsiUtils::stripAnsiCodes($hyperlink)); + } + + #[DataProvider('extractAnsiCodeProvider')] + public function testExtractAnsiCode(string $input, string $expectedCode, int $expectedLength) + { + $result = AnsiUtils::extractAnsiCode($input, 0); + + $this->assertSame($expectedCode, $result['code']); + $this->assertSame($expectedLength, $result['length']); + } + + /** + * @return iterable + */ + public static function extractAnsiCodeProvider(): iterable + { + yield 'CSI SGR' => ["\x1b[31mHello", "\x1b[31m", 5]; + yield 'OSC with BEL' => ["\x1b]8;;url\x07text", "\x1b]8;;url\x07", 9]; + yield 'OSC with ST' => ["\x1b]8;;url\x1b\\text", "\x1b]8;;url\x1b\\", 10]; + yield 'APC with BEL' => ["\x1b_pi:c\x07rest", "\x1b_pi:c\x07", 7]; + yield 'APC with ST' => ["\x1b_Ga=T,f=100;AAAA\x1b\\rest", "\x1b_Ga=T,f=100;AAAA\x1b\\", 19]; + yield 'DCS with ST' => ["\x1bPq;data\x1b\\rest", "\x1bPq;data\x1b\\", 10]; + yield 'DCS with BEL' => ["\x1bPdata\x07rest", "\x1bPdata\x07", 7]; + yield 'PM with ST' => ["\x1b^message\x1b\\rest", "\x1b^message\x1b\\", 11]; + yield 'PM with BEL' => ["\x1b^message\x07rest", "\x1b^message\x07", 10]; + yield 'SOS with ST' => ["\x1bXstring\x1b\\rest", "\x1bXstring\x1b\\", 10]; + yield 'Cursor Up' => ["\x1b[5A", "\x1b[5A", 4]; + yield 'Cursor Down' => ["\x1b[3B", "\x1b[3B", 4]; + yield 'Cursor Forward' => ["\x1b[2C", "\x1b[2C", 4]; + yield 'Cursor Back' => ["\x1b[1D", "\x1b[1D", 4]; + yield 'Fe IND' => ["\x1bDrest", "\x1bD", 2]; + yield 'Fe RI' => ["\x1bMrest", "\x1bM", 2]; + yield 'Fe NEL' => ["\x1bErest", "\x1bE", 2]; + yield 'Fe HTS' => ["\x1bHrest", "\x1bH", 2]; + yield 'Fe SS2' => ["\x1bNrest", "\x1bN", 2]; + yield 'Fe SS3' => ["\x1bOrest", "\x1bO", 2]; + yield 'Fp DECSC' => ["\x1b7rest", "\x1b7", 2]; + yield 'Fp DECRC' => ["\x1b8rest", "\x1b8", 2]; + yield 'Fs RIS' => ["\x1bcrest", "\x1bc", 2]; + yield 'nF G0 US ASCII' => ["\x1b(Brest", "\x1b(B", 3]; + yield 'nF G0 DEC Graphics' => ["\x1b(0rest", "\x1b(0", 3]; + yield 'nF G1 charset' => ["\x1b)Brest", "\x1b)B", 3]; + yield 'nF 7-bit C1 mode' => ["\x1b Frest", "\x1b F", 3]; + } + + #[DataProvider('extractAnsiCodeReturnsNullProvider')] + public function testExtractAnsiCodeReturnsNull(string $input) + { + $this->assertNull(AnsiUtils::extractAnsiCode($input, 0)); + } + + /** + * @return iterable + */ + public static function extractAnsiCodeReturnsNullProvider(): iterable + { + yield 'non-escape' => ['Hello']; + yield 'OSC unterminated' => ["\x1b]8;;url"]; + yield 'APC unterminated' => ["\x1b_data"]; + yield 'DCS unterminated' => ["\x1bPdata"]; + yield 'nF unterminated' => ["\x1b("]; + yield 'lone ESC' => ["\x1b"]; + yield 'ESC + control' => ["\x1b\x01"]; + } + + public function testTruncateToWidthNoTruncation() + { + $this->assertSame('Hello', AnsiUtils::truncateToWidth('Hello', 10)); + $this->assertSame('Hello', AnsiUtils::truncateToWidth('Hello', 5)); + } + + public function testTruncateToWidthWithTruncation() + { + $result = AnsiUtils::truncateToWidth('Hello World', 8); + $this->assertSame(8, AnsiUtils::visibleWidth($result)); + $this->assertStringEndsWith('...', $result); + } + + public function testTruncateToWidthPreservesAnsi() + { + $styled = "\x1b[31mHello World\x1b[0m"; + $result = AnsiUtils::truncateToWidth($styled, 8); + + // Should contain the red escape code + $this->assertStringContainsString("\x1b[31m", $result); + // And be truncated + $this->assertSame(8, AnsiUtils::visibleWidth($result)); + } + + public function testTruncateToWidthWithPadding() + { + $result = AnsiUtils::truncateToWidth('Hi', 10, '...', true); + $this->assertSame(10, AnsiUtils::visibleWidth($result)); + } + + public function testSliceByColumn() + { + $this->assertSame('llo', AnsiUtils::sliceByColumn('Hello', 2, 3)); + $this->assertSame('He', AnsiUtils::sliceByColumn('Hello', 0, 2)); + } + + public function testSliceByColumnWithAnsi() + { + $styled = "\x1b[31mHello\x1b[0m"; + $result = AnsiUtils::sliceByColumn($styled, 0, 3); + + // Should contain "Hel" with ANSI codes + $this->assertSame(3, AnsiUtils::visibleWidth($result)); + } + + /** + * @return iterable + */ + public static function isWhitespaceProvider(): iterable + { + yield 'space' => [' ', true]; + yield 'tab' => ["\t", true]; + yield 'newline' => ["\n", true]; + yield 'letter' => ['a', false]; + } + + #[DataProvider('isWhitespaceProvider')] + public function testIsWhitespace(string $char, bool $expected) + { + $this->assertSame($expected, AnsiUtils::isWhitespace($char)); + } + + /** + * @return iterable + */ + public static function isPunctuationProvider(): iterable + { + yield 'period' => ['.', true]; + yield 'comma' => [',', true]; + yield 'exclamation' => ['!', true]; + yield 'letter' => ['a', false]; + yield 'space' => [' ', false]; + } + + #[DataProvider('isPunctuationProvider')] + public function testIsPunctuation(string $char, bool $expected) + { + $this->assertSame($expected, AnsiUtils::isPunctuation($char)); + } + + /** + * @return iterable + */ + public static function visibleWidthWithCursorMovementProvider(): iterable + { + yield 'cursor up' => ["\x1b[5A", 0]; + yield 'cursor down' => ["\x1b[3B", 0]; + yield 'cursor forward' => ["\x1b[2C", 0]; + yield 'cursor back' => ["\x1b[1D", 0]; + yield 'cursor up + text' => ["\x1b[5AHello", 5]; + yield 'text + cursor down' => ["Hello\x1b[3B", 5]; + } + + #[DataProvider('visibleWidthWithCursorMovementProvider')] + public function testVisibleWidthWithCursorMovement(string $input, int $expected) + { + $this->assertSame($expected, AnsiUtils::visibleWidth($input)); + } + + /** + * @return iterable + */ + public static function stripAnsiCodesWithCursorMovementProvider(): iterable + { + yield 'cursor up only' => ["\x1b[5A", '']; + yield 'cursor up + text' => ["\x1b[5AHello", 'Hello']; + yield 'text + cursor down' => ["Hello\x1b[3B", 'Hello']; + } + + #[DataProvider('stripAnsiCodesWithCursorMovementProvider')] + public function testStripAnsiCodesWithCursorMovement(string $input, string $expected) + { + $this->assertSame($expected, AnsiUtils::stripAnsiCodes($input)); + } + + public function testVisibleWidthWithKittyImageAndCursorUp() + { + // Simulates Kitty converter output: cursor-up + Kitty graphics protocol + $moveUp = "\x1b[5A"; + $kittyPayload = "\x1b_Ga=T,f=100,q=2,c=80,r=6,i=12345,m=0;AAAA\x1b\\"; + $this->assertSame(0, AnsiUtils::visibleWidth($moveUp.$kittyPayload)); + } + + public function testContainsImage() + { + $this->assertFalse(AnsiUtils::containsImage('Hello')); + $this->assertTrue(AnsiUtils::containsImage("\x1b_Gdata\x1b\\")); + $this->assertTrue(AnsiUtils::containsImage("\x1b]1337;File=inline=1:data\x07")); + } + + public function testStripAnsiCodesWithMixedSequenceTypes() + { + // SGR + OSC hyperlink + APC cursor marker all in one string + $str = "\x1b[1;31m\x1b]8;;https://example.com\x07Click\x1b]8;;\x07\x1b[0m\x1b_pi:c\x07"; + $this->assertSame('Click', AnsiUtils::stripAnsiCodes($str)); + } + + public function testStripAnsiCodesWithOnlyEscapeSequences() + { + // String with only ANSI sequences, no visible text + $str = "\x1b[31m\x1b]8;;url\x07\x1b]8;;\x07\x1b[0m\x1b_marker\x07"; + $this->assertSame('', AnsiUtils::stripAnsiCodes($str)); + } + + public function testVisibleWidthWithMixedAnsiAndUnicode() + { + // CJK text with SGR, hyperlink, and APC sequences: forces slow path + $str = "\x1b[1;31m\x1b]8;;https://example.com\x07日本\x1b]8;;\x07\x1b[0m\x1b_pi:c\x07"; + $this->assertSame(4, AnsiUtils::visibleWidth($str)); + } + + public function testVisibleWidthWithOnlyAnsiSequences() + { + // Only escape sequences, no visible content: slow path returns 0 + $str = "\x1b[31m\x1b]8;;url\x07\x1b]8;;\x07\x1b[0m\x1b_data\x07"; + $this->assertSame(0, AnsiUtils::visibleWidth($str)); + } + + /** + * @return iterable + */ + public static function stripAnsiCodesSequenceTypeProvider(): iterable + { + yield 'DCS' => ["Hello\x1bPq;sixeldata\x1b\\World", 'HelloWorld']; + yield 'PM' => ["Hello\x1b^private\x1b\\World", 'HelloWorld']; + yield 'SOS' => ["Hello\x1bXstring\x1b\\World", 'HelloWorld']; + yield 'Fe IND+RI' => ["\x1bDHello\x1bM", 'Hello']; + yield 'Fe RIS' => ["\x1bcHello", 'Hello']; + yield 'Fp DECSC+DECRC' => ["\x1b7Hello\x1b8", 'Hello']; + yield 'nF G0 charset' => ["\x1b(0Hello\x1b(B", 'Hello']; + yield 'all ECMA-48 types' => [ + "\x1b[31m\x1b]8;;url\x07\x1b_pi:c\x07\x1bPdata\x1b\\\x1b^private\x1b\\\x1bXstring\x1b\\\x1bD\x1b7\x1b(BHello\x1b[0m", + 'Hello', + ]; + } + + #[DataProvider('stripAnsiCodesSequenceTypeProvider')] + public function testStripAnsiCodesWithSequenceTypes(string $input, string $expected) + { + $this->assertSame($expected, AnsiUtils::stripAnsiCodes($input)); + } + + /** + * @return iterable + */ + public static function visibleWidthSequenceTypeProvider(): iterable + { + yield 'DCS' => ["\x1bPq;sixeldata\x1b\\Hello", 5]; + yield 'Fe IND only' => ["\x1bD", 0]; + yield 'Fe RI only' => ["\x1bM", 0]; + yield 'Fe IND+RI wrapping text' => ["\x1bDHello\x1bM", 5]; + yield 'Fp DECSC+DECRC' => ["\x1b7Hello\x1b8", 5]; + yield 'nF G0 charset' => ["\x1b(0Hello\x1b(B", 5]; + yield 'all ECMA-48 types' => [ + "\x1b[31m\x1b]8;;url\x07\x1b_pi:c\x07\x1bPdata\x1b\\\x1b^pm\x1b\\\x1bXsos\x1b\\\x1bD\x1b7\x1b(BHello\x1b[0m", + 5, + ]; + } + + #[DataProvider('visibleWidthSequenceTypeProvider')] + public function testVisibleWidthWithSequenceTypes(string $input, int $expected) + { + $this->assertSame($expected, AnsiUtils::visibleWidth($input)); + } + + public function testSliceByColumnWithDcsAndFeTwoByteSequences() + { + // DCS + Fe sequences should be skipped as zero-width in sliceByColumn + $str = "\x1b7\x1bPdata\x1b\\Hello\x1bM"; + $result = AnsiUtils::sliceByColumn($str, 0, 3); + $this->assertSame(3, AnsiUtils::visibleWidth($result)); + } + + /** + * Data provider for accented character width tests. + * + * Regression test for: ctype_print() bug with UTF-8 characters. + * ctype_print() accepts non-ASCII UTF-8 characters (e.g., 'é'), + * causing the fast path to incorrectly return strlen() instead of mb_strwidth(). + * + * @return iterable + */ + public static function accentedCharacterWidthProvider(): iterable + { + // Text with accented characters where strlen != mb_strwidth + // This triggered the ctype_print() bug which caused footer flickering + yield 'Gérard (7 bytes, 6 columns)' => ['Gérard', 6]; + yield 'Café (5 bytes, 4 columns)' => ['Café', 4]; + yield 'Naïveté (8 bytes, 7 columns)' => ['Naïveté', 7]; + yield 'Müller (7 bytes, 6 columns)' => ['Müller', 6]; + } + + #[DataProvider('accentedCharacterWidthProvider')] + public function testVisibleWidthWithAccentedCharacters(string $text, int $expectedWidth) + { + $this->assertSame($expectedWidth, AnsiUtils::visibleWidth($text)); + } + + /** + * Regression test: width calculation must be consistent for accented text. + * + * Multiple calls to visibleWidth() must return the same value. + * This tests the caching mechanism with accented characters. + */ + public function testVisibleWidthConsistencyWithAccentedCharacters() + { + $text = 'Gérard'; + $widths = []; + + for ($i = 0; $i < 10; ++$i) { + $widths[] = AnsiUtils::visibleWidth($text); + } + + $uniqueWidths = array_unique($widths); + $this->assertCount(1, $uniqueWidths, 'visibleWidth() should return consistent results'); + $this->assertSame(6, $widths[0]); + } + + /** + * Test footer scenario: path with accent + agent name. + * + * Simulates the footer rendering in Gérard agent: + * "/path/to/project • Gérard" + */ + public function testVisibleWidthFooterScenario() + { + // Typical footer right-side text with accented agent name + $rightText = '/Users/fabien/Code/test • Gérard'; + $expectedWidth = 32; // Correct mb_strwidth result + + $this->assertSame($expectedWidth, AnsiUtils::visibleWidth($rightText)); + + // With branch name (also common in footer) + $rightWithBranch = '/Users/fabien/Code/test (main) • Gérard'; + $this->assertSame(39, AnsiUtils::visibleWidth($rightWithBranch)); + } + + /** + * Test footer with ANSI styling. + * + * The footer applies styling (colors) to the text. + * Width must be the same whether styled or plain. + */ + public function testVisibleWidthFooterScenarioWithAnsiStyling() + { + $pwd = '/Users/fabien/Code/test'; + $agentName = 'Gérard'; + $rightPlain = $pwd.' • '.$agentName; + + // Simulate footer styling: muted for pwd, colored for agent name + $muted = "\x1b[90m"; + $agentColor = "\x1b[38;5;33m"; + $reset = "\x1b[0m"; + $rightStyled = $muted.$pwd.' • '.$reset.$agentColor.$agentName.$reset; + + $plainWidth = AnsiUtils::visibleWidth($rightPlain); + $styledWidth = AnsiUtils::visibleWidth($rightStyled); + + $this->assertSame($plainWidth, $styledWidth, 'Styled and plain text should have same visible width'); + $this->assertSame(32, $plainWidth); + } + + /** + * Data provider for truncation tests with accented characters. + * + * @return iterable + */ + public static function truncateAccentedCharacterProvider(): iterable + { + yield 'simple accented text' => ['Hello Gérard, welcome!', 15, '...', '...']; + yield 'accented agent name' => ['Gérard is testing truncation', 10, '…', '…']; + yield 'long path with accent' => ['/Users/fabien/Code/Gérard/project', 25, '...', '...']; + } + + /** + * @param non-empty-string $expectedSuffix + */ + #[DataProvider('truncateAccentedCharacterProvider')] + public function testTruncateToWidthWithAccentedCharacters(string $text, int $maxWidth, string $ellipsis, string $expectedSuffix) + { + $result = AnsiUtils::truncateToWidth($text, $maxWidth, $ellipsis); + $resultWidth = AnsiUtils::visibleWidth($result); + + // Result should fit within max width + $this->assertLessThanOrEqual($maxWidth, $resultWidth); + // Should end with the specified ellipsis + $this->assertStringEndsWith($expectedSuffix, $result); + } + + /** + * Data provider for slicing tests with accented characters. + * + * @return iterable + */ + public static function sliceAccentedCharacterProvider(): iterable + { + // text, startCol, length, expectedWidth + yield 'Gérard slice first 3 cols' => ['Gérard', 0, 3, 3]; + yield 'Gérard slice from col 2' => ['Gérard', 2, 4, 4]; + yield 'Café full' => ['Café', 0, 10, 4]; + } + + #[DataProvider('sliceAccentedCharacterProvider')] + public function testSliceByColumnWithAccentedCharacters(string $text, int $startCol, int $length, int $expectedWidth) + { + $result = AnsiUtils::sliceByColumn($text, $startCol, $length); + $this->assertSame($expectedWidth, AnsiUtils::visibleWidth($result)); + } + + /** + * Test that mixed ASCII and accented text is handled correctly. + */ + public function testVisibleWidthMixedAsciiAndAccented() + { + $text = 'Hello Gérard from Café'; + // "Hello " (6) + "Gérard" (6) + " from " (6) + "Café" (4) = 22 + $this->assertSame(22, AnsiUtils::visibleWidth($text)); + } +} diff --git a/src/Symfony/Component/Tui/Tests/Ansi/ScreenBufferHtmlRendererTest.php b/src/Symfony/Component/Tui/Tests/Ansi/ScreenBufferHtmlRendererTest.php new file mode 100644 index 0000000000000..3f76dfe6c7861 --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Ansi/ScreenBufferHtmlRendererTest.php @@ -0,0 +1,262 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Ansi; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Ansi\ScreenBufferHtmlRenderer; +use Symfony\Component\Tui\Style\Color; +use Symfony\Component\Tui\Terminal\ScreenBuffer; + +class ScreenBufferHtmlRendererTest extends TestCase +{ + /** + * @param string[] $expectedCss + */ + #[DataProvider('ansiToHtmlProvider')] + public function testAnsiToHtmlConversion(string $ansi, array $expectedCss) + { + $html = $this->convert($ansi); + + foreach ($expectedCss as $css) { + $this->assertStringContainsString($css, $html); + } + } + + /** + * @return iterable + */ + public static function ansiToHtmlProvider(): iterable + { + yield 'foreground color' => ["\x1b[32mHello\x1b[0m", ['color: #00cd00']]; + yield 'bold with color' => ["\x1b[1;32mHello\x1b[0m", ['font-weight: bold', 'color: #00cd00']]; + yield 'background color' => ["\x1b[41mHello\x1b[0m", ['background-color: #cd0000']]; + yield 'bright foreground' => ["\x1b[91mHello\x1b[0m", ['color: #ff0000']]; + yield 'bright background' => ["\x1b[101mHello\x1b[0m", ['background-color: #ff0000']]; + yield '256-color foreground' => ["\x1b[38;5;196mHello\x1b[0m", ['color: #ff0000']]; + yield '256-color background' => ["\x1b[48;5;21mHello\x1b[0m", ['background-color:']]; + yield 'bold + italic + color' => ["\x1b[1;3;34mHello\x1b[0m", ['font-weight: bold', 'font-style: italic', 'color: #0000ee']]; + yield 'truecolor foreground' => ["\x1b[38;2;255;128;0mX\x1b[0m", ['color: #ff8000']]; + yield 'truecolor foreground black' => ["\x1b[38;2;0;0;0mX\x1b[0m", ['color: #000000']]; + yield 'truecolor foreground white' => ["\x1b[38;2;255;255;255mX\x1b[0m", ['color: #ffffff']]; + yield 'truecolor background' => ["\x1b[48;2;100;200;50mX\x1b[0m", ['background-color: #64c832']]; + yield 'truecolor background black' => ["\x1b[48;2;0;0;0mX\x1b[0m", ['background-color: #000000']]; + yield 'truecolor background white' => ["\x1b[48;2;255;255;255mX\x1b[0m", ['background-color: #ffffff']]; + yield 'truecolor fg + bg' => ["\x1b[38;2;255;0;0;48;2;0;0;255mX\x1b[0m", ['color: #ff0000', 'background-color: #0000ff']]; + yield 'truecolor with bold' => ["\x1b[1;38;2;128;64;32mX\x1b[0m", ['font-weight: bold', 'color: #804020']]; + } + + #[DataProvider('colorCodeMappingProvider')] + public function testColorCodeMapping(string $ansi, string $expectedCss) + { + $html = $this->convert($ansi); + + $this->assertStringContainsString($expectedCss, $html); + } + + /** + * @return iterable + */ + public static function colorCodeMappingProvider(): iterable + { + // Standard foreground colors (30-37) - xterm defaults + $standardFg = [ + 30 => '#000000', 31 => '#cd0000', 32 => '#00cd00', 33 => '#cdcd00', + 34 => '#0000ee', 35 => '#cd00cd', 36 => '#00cdcd', 37 => '#e5e5e5', + ]; + foreach ($standardFg as $code => $hex) { + yield "standard fg {$code}" => ["\x1b[{$code}mX\x1b[0m", "color: {$hex}"]; + } + + // Standard background colors (40-47) - xterm defaults + $standardBg = [ + 40 => '#000000', 41 => '#cd0000', 42 => '#00cd00', 43 => '#cdcd00', + 44 => '#0000ee', 45 => '#cd00cd', 46 => '#00cdcd', 47 => '#e5e5e5', + ]; + foreach ($standardBg as $code => $hex) { + yield "standard bg {$code}" => ["\x1b[{$code}mX\x1b[0m", "background-color: {$hex}"]; + } + + // Bright foreground colors (90-97) - xterm defaults + $brightFg = [ + 90 => '#7f7f7f', 91 => '#ff0000', 92 => '#00ff00', 93 => '#ffff00', + 94 => '#5c5cff', 95 => '#ff00ff', 96 => '#00ffff', 97 => '#ffffff', + ]; + foreach ($brightFg as $code => $hex) { + yield "bright fg {$code}" => ["\x1b[{$code}mX\x1b[0m", "color: {$hex}"]; + } + + // Bright background colors (100-107) - xterm defaults + $brightBg = [ + 100 => '#7f7f7f', 101 => '#ff0000', 102 => '#00ff00', 103 => '#ffff00', + 104 => '#5c5cff', 105 => '#ff00ff', 106 => '#00ffff', 107 => '#ffffff', + ]; + foreach ($brightBg as $code => $hex) { + yield "bright bg {$code}" => ["\x1b[{$code}mX\x1b[0m", "background-color: {$hex}"]; + } + + // 256-color: standard (0-15) + yield '256 standard black' => ["\x1b[38;5;0mX\x1b[0m", 'color: #000000']; + yield '256 standard white' => ["\x1b[38;5;15mX\x1b[0m", 'color: #ffffff']; + + // 256-color: cube (16-231) + yield '256 cube red' => ["\x1b[38;5;196mX\x1b[0m", 'color: #ff0000']; + yield '256 cube green' => ["\x1b[38;5;46mX\x1b[0m", 'color: #00ff00']; + yield '256 cube blue' => ["\x1b[38;5;21mX\x1b[0m", 'color: #0000ff']; + + // 256-color: grayscale (232-255) + yield '256 grayscale darkest' => ["\x1b[38;5;232mX\x1b[0m", 'color: #080808']; + yield '256 grayscale lightest' => ["\x1b[38;5;255mX\x1b[0m", 'color: #eeeeee']; + yield '256 grayscale mid' => ["\x1b[38;5;240mX\x1b[0m", 'color: #585858']; + } + + public function testReverseVideo() + { + $html = $this->convert("\x1b[7mX\x1b[0m"); + + $this->assertStringContainsString('color: #1e1e1e', $html); + $this->assertStringContainsString('background-color: #d4d4d4', $html); + } + + public function testReverseVideoWithCustomDefaults() + { + $screen = new ScreenBuffer(40, 10); + $screen->write("\x1b[7mX\x1b[0m"); + + $converter = new ScreenBufferHtmlRenderer(Color::hex('#ffffff'), Color::hex('#000000')); + $html = $converter->convert($screen); + + $this->assertStringContainsString('color: #000000', $html); + $this->assertStringContainsString('background-color: #ffffff', $html); + } + + /** + * @return iterable + */ + public static function textDecorationProvider(): iterable + { + yield 'italic' => ["\x1b[3mHello\x1b[0m", 'font-style: italic']; + yield 'underline' => ["\x1b[4mHello\x1b[0m", 'text-decoration: underline']; + yield 'line-through' => ["\x1b[9mHello\x1b[0m", 'text-decoration: line-through']; + yield 'dim' => ["\x1b[2mHello\x1b[0m", 'opacity: 0.7']; + } + + #[DataProvider('textDecorationProvider')] + public function testTextDecorations(string $ansi, string $expectedCss) + { + $html = $this->convert($ansi); + + $this->assertStringContainsString($expectedCss, $html); + } + + public function testUnderlineAndStrikethrough() + { + $html = $this->convert("\x1b[4;9mText\x1b[0m"); + + $this->assertStringContainsString('text-decoration: underline line-through', $html); + } + + public function testResetProducesNoStyle() + { + $html = $this->convert("\x1b[32mGreen\x1b[0m Plain"); + + $this->assertStringContainsString('Green', $html); + $this->assertStringContainsString('Plain', $html); + $this->assertStringNotContainsString('>Plain', $html); + } + + public function testPlainTextNoSpans() + { + $html = $this->convert('Hello World'); + + $this->assertSame('Hello World', $html); + $this->assertStringNotContainsString('write("Line 1\n\x1b[1mLine 2\x1b[0m\nLine 3"); + + $converter = new ScreenBufferHtmlRenderer(); + $html = $converter->convert($screen); + + $lines = explode("\n", $html); + $this->assertCount(3, $lines); + $this->assertSame('Line 1', $lines[0]); + $this->assertStringContainsString('font-weight: bold', $lines[1]); + $this->assertStringContainsString('Line 2', $lines[1]); + $this->assertSame('Line 3', $lines[2]); + } + + public function testEmptyScreen() + { + $screen = new ScreenBuffer(40, 10); + + $converter = new ScreenBufferHtmlRenderer(); + $html = $converter->convert($screen); + + $this->assertSame('', $html); + } + + public function testHtmlEntitiesEscaped() + { + $html = $this->convert(''); + + $this->assertStringContainsString('<script>', $html); + $this->assertStringContainsString('</script>', $html); + $this->assertStringNotContainsString(' + +HTML; + + return $html; + } + + /** + * Compute line-by-line diff using longest common subsequence. + * + * @param string[] $expected + * @param string[] $actual + * + * @return list + */ + private static function computeDiff(array $expected, array $actual): array + { + $m = count($expected); + $n = count($actual); + + // Build LCS table + $lcs = []; + for ($i = 0; $i <= $m; ++$i) { + $lcs[$i] = array_fill(0, $n + 1, 0); + } + + for ($i = 1; $i <= $m; ++$i) { + for ($j = 1; $j <= $n; ++$j) { + if ($expected[$i - 1] === $actual[$j - 1]) { + $lcs[$i][$j] = $lcs[$i - 1][$j - 1] + 1; + } else { + $lcs[$i][$j] = max($lcs[$i - 1][$j], $lcs[$i][$j - 1]); + } + } + } + + // Backtrack to build diff + $diff = []; + $i = $m; + $j = $n; + + while ($i > 0 || $j > 0) { + if ($i > 0 && $j > 0 && $expected[$i - 1] === $actual[$j - 1]) { + array_unshift($diff, [ + 'type' => 'unchanged', + 'line_num' => $i, + 'content' => $expected[$i - 1], + ]); + --$i; + --$j; + } elseif ($j > 0 && (0 === $i || $lcs[$i][$j - 1] >= $lcs[$i - 1][$j])) { + array_unshift($diff, [ + 'type' => 'added', + 'line_num' => $j, + 'content' => $actual[$j - 1], + ]); + --$j; + } elseif ($i > 0 && (0 === $j || $lcs[$i][$j - 1] < $lcs[$i - 1][$j])) { + array_unshift($diff, [ + 'type' => 'removed', + 'line_num' => $i, + 'content' => $expected[$i - 1], + ]); + --$i; + } + } + + // Detect "changed" lines (adjacent removed + added) + $result = []; + $diffCount = count($diff); + + for ($k = 0; $k < $diffCount; ++$k) { + $current = $diff[$k]; + + // Check for removed followed by added (marks as changed) + if ('removed' === $current['type'] && $k + 1 < $diffCount && 'added' === $diff[$k + 1]['type']) { + $result[] = [ + 'type' => 'changed', + 'line_num' => $current['line_num'], + 'content' => $current['content'], + 'new_content' => $diff[$k + 1]['content'], + ]; + ++$k; // Skip the next 'added' line + } else { + $result[] = $current; + } + } + + return $result; + } + + /** + * Compute statistics from diff. + * + * @param list $diff + * + * @return array{added: int, removed: int, changed: int, unchanged: int} + */ + private static function computeStats(array $diff): array + { + $stats = ['added' => 0, 'removed' => 0, 'changed' => 0, 'unchanged' => 0]; + + foreach ($diff as $line) { + if (isset($stats[$line['type']])) { + ++$stats[$line['type']]; + } + } + + return $stats; + } + + /** + * Format diff as unified diff text. + * + * @param list $diff + * @param string[] $expected + * @param string[] $actual + */ + private static function formatUnifiedDiff(array $diff, array $expected, array $actual): string + { + $lines = []; + $lines[] = '--- expected'; + $lines[] = '+++ actual'; + $lines[] = sprintf('@@ -%d,%d +%d,%d @@', 1, count($expected), 1, count($actual)); + + foreach ($diff as $entry) { + switch ($entry['type']) { + case 'unchanged': + $lines[] = ' '.$entry['content']; + break; + case 'removed': + $lines[] = '-'.$entry['content']; + break; + case 'added': + $lines[] = '+'.$entry['content']; + break; + case 'changed': + $lines[] = '-'.$entry['content']; + if (isset($entry['new_content'])) { + $lines[] = '+'.$entry['new_content']; + } + break; + } + } + + return implode("\n", $lines); + } + + /** + * Render terminal output to HTML with colors preserved. + */ + private static function renderToHtml(string $output, int $width = 80): string + { + // Calculate height based on content - count newlines and add buffer + // Terminal output may use cursor positioning, so we need enough height + $lineCount = substr_count($output, "\n") + 1; + $height = max(24, $lineCount + 5); // At least 24 lines, plus buffer for cursor movements + + $screen = new ScreenBuffer($width, $height); + $screen->write($output); + + return new ScreenBufferHtmlRenderer()->convert($screen); + } +} diff --git a/src/Symfony/Component/Tui/Tests/StateDiffTest.php b/src/Symfony/Component/Tui/Tests/StateDiffTest.php new file mode 100644 index 0000000000000..a5f8dc9778180 --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/StateDiffTest.php @@ -0,0 +1,145 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +class StateDiffTest extends TestCase +{ + public function testIdenticalOutputs() + { + $output = "Line 1\nLine 2\nLine 3"; + + $result = StateDiff::compare($output, $output); + + $this->assertTrue($result['identical']); + $this->assertSame('Outputs are identical', $result['summary']); + $this->assertSame([], $result['diff_lines']); + $this->assertSame('', $result['diff_text']); + $this->assertSame(['added' => 0, 'removed' => 0, 'changed' => 0, 'unchanged' => 0], $result['stats']); + } + + public function testAddedLines() + { + $expected = "Line 1\nLine 2"; + $actual = "Line 1\nLine 2\nLine 3"; + + $result = StateDiff::compare($expected, $actual); + + $this->assertFalse($result['identical']); + $this->assertSame(1, $result['stats']['added']); + $this->assertSame(0, $result['stats']['removed']); + $this->assertStringContainsString('+Line 3', $result['diff_text']); + } + + public function testRemovedLines() + { + $expected = "Line 1\nLine 2\nLine 3"; + $actual = "Line 1\nLine 2"; + + $result = StateDiff::compare($expected, $actual); + + $this->assertFalse($result['identical']); + $this->assertSame(0, $result['stats']['added']); + $this->assertSame(1, $result['stats']['removed']); + $this->assertStringContainsString('-Line 3', $result['diff_text']); + } + + public function testChangedLines() + { + $expected = "Line 1\nOld Line\nLine 3"; + $actual = "Line 1\nNew Line\nLine 3"; + + $result = StateDiff::compare($expected, $actual); + + $this->assertFalse($result['identical']); + $this->assertSame(1, $result['stats']['changed']); + $this->assertStringContainsString('-Old Line', $result['diff_text']); + $this->assertStringContainsString('+New Line', $result['diff_text']); + } + + public function testComplexDiff() + { + $expected = "Header\nLine A\nLine B\nLine C\nFooter"; + $actual = "Header\nLine A\nLine B Modified\nLine D\nFooter"; + + $result = StateDiff::compare($expected, $actual); + + $this->assertFalse($result['identical']); + // Changed: Line B -> Line B Modified + // Changed: Line C -> Line D + $this->assertGreaterThan(0, $result['stats']['changed'] + $result['stats']['added'] + $result['stats']['removed']); + } + + public function testSideBySide() + { + $expected = "Line 1\nLine 2"; + $actual = "Line 1\nDifferent"; + + $result = StateDiff::sideBySide($expected, $actual, 60); + + $this->assertStringContainsString('Expected', $result); + $this->assertStringContainsString('Actual', $result); + $this->assertStringContainsString('Line 1', $result); + $this->assertStringContainsString('Different', $result); + } + + public function testHtmlReport() + { + $failures = [ + 'test_step01' => [ + 'expected' => "Line 1\nLine 2", + 'actual' => "Line 1\nModified", + 'step' => 'initial', + ], + ]; + + $html = StateDiff::generateHtmlReport($failures, [], 2, 1, 'Test Report'); + + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('Test Report', $html); + $this->assertStringContainsString('test_step01', $html); + $this->assertStringContainsString('diff-changed', $html); + $this->assertStringContainsString('1/2 steps passed', $html); + } + + /** + * @return iterable + */ + public static function emptyInputProvider(): iterable + { + yield 'both empty' => ['', '', true]; + yield 'empty expected' => ['', "Line 1\nLine 2", false]; + yield 'empty actual' => ["Line 1\nLine 2", '', false]; + } + + #[DataProvider('emptyInputProvider')] + public function testEmptyInputEdgeCases(string $expected, string $actual, bool $identical) + { + $result = StateDiff::compare($expected, $actual); + + $this->assertSame($identical, $result['identical']); + } + + public function testUnifiedDiffFormat() + { + $expected = "A\nB\nC"; + $actual = "A\nX\nC"; + + $result = StateDiff::compare($expected, $actual); + + $this->assertStringContainsString('--- expected', $result['diff_text']); + $this->assertStringContainsString('+++ actual', $result['diff_text']); + $this->assertStringContainsString('@@', $result['diff_text']); + } +} diff --git a/src/Symfony/Component/Tui/Tests/Style/BorderPatternTest.php b/src/Symfony/Component/Tui/Tests/Style/BorderPatternTest.php new file mode 100644 index 0000000000000..29794f7b3ba9f --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Style/BorderPatternTest.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Style; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Exception\InvalidArgumentException; +use Symfony\Component\Tui\Style\BorderPattern; + +class BorderPatternTest extends TestCase +{ + #[DataProvider('allPatternsProvider')] + public function testFromNameReturnsExpectedCharsAndIsNone(string $name, ?string $method, bool $isNone) + { + $fromName = BorderPattern::fromName($name); + + if (null !== $method) { + $fromMethod = BorderPattern::$method(); + $this->assertSame($fromMethod->getChars(), $fromName->getChars()); + $this->assertSame($fromMethod->getStrategies(), $fromName->getStrategies()); + } + + $this->assertSame($isNone, $fromName->isNone()); + } + + /** + * @return iterable + */ + public static function allPatternsProvider(): iterable + { + yield 'none' => [BorderPattern::NONE, null, true]; + yield 'normal' => [BorderPattern::NORMAL, 'normal', false]; + yield 'rounded' => [BorderPattern::ROUNDED, 'rounded', false]; + yield 'double' => [BorderPattern::DOUBLE, 'double', false]; + yield 'tall' => [BorderPattern::TALL, 'tall', false]; + yield 'wide' => [BorderPattern::WIDE, 'wide', false]; + yield 'tall-medium' => [BorderPattern::TALL_MEDIUM, 'tallMedium', false]; + yield 'wide-medium' => [BorderPattern::WIDE_MEDIUM, 'wideMedium', false]; + yield 'tall-large' => [BorderPattern::TALL_LARGE, 'tallLarge', false]; + yield 'wide-large' => [BorderPattern::WIDE_LARGE, 'wideLarge', false]; + } + + public function testFromNameThrowsOnUnknown() + { + $this->expectException(InvalidArgumentException::class); + BorderPattern::fromName('unknown'); + } +} diff --git a/src/Symfony/Component/Tui/Tests/Style/BorderTest.php b/src/Symfony/Component/Tui/Tests/Style/BorderTest.php new file mode 100644 index 0000000000000..2446c878a6595 --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Style/BorderTest.php @@ -0,0 +1,302 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Style; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Exception\InvalidArgumentException; +use Symfony\Component\Tui\Style\Border; +use Symfony\Component\Tui\Style\BorderPattern; +use Symfony\Component\Tui\Style\Color; +use Symfony\Component\Tui\Style\Style; + +class BorderTest extends TestCase +{ + public function testNegativeValuesClampedToZero() + { + $border = new Border(-5, -3, -1, -2); + + $this->assertSame(0, $border->getTop()); + $this->assertSame(0, $border->getRight()); + $this->assertSame(0, $border->getBottom()); + $this->assertSame(0, $border->getLeft()); + } + + /** + * @param list $input + */ + #[DataProvider('fromArrayProvider')] + public function testFromArray(array $input, int $top, int $right, int $bottom, int $left) + { + $border = Border::from($input); + + $this->assertSame($top, $border->getTop()); + $this->assertSame($right, $border->getRight()); + $this->assertSame($bottom, $border->getBottom()); + $this->assertSame($left, $border->getLeft()); + } + + /** + * @return iterable, int, int, int, int}> + */ + public static function fromArrayProvider(): iterable + { + yield '1 element (all sides)' => [[3], 3, 3, 3, 3]; + yield '2 elements (y, x)' => [[1, 2], 1, 2, 1, 2]; + yield '3 elements (top, x, bottom)' => [[1, 2, 3], 1, 2, 3, 2]; + yield '4 elements (top, right, bottom, left)' => [[1, 2, 3, 4], 1, 2, 3, 4]; + } + + public function testFromBorderInstance() + { + $original = new Border(1, 2, 3, 4); + $result = Border::from($original); + + $this->assertSame($original, $result); + } + + public function testFromBorderInstanceWithPattern() + { + $original = new Border(1, 2, 3, 4); + $result = Border::from($original, BorderPattern::ROUNDED); + + $this->assertNotSame($original, $result); + $this->assertSame(1, $result->getTop()); + $this->assertSame(2, $result->getRight()); + $this->assertSame(3, $result->getBottom()); + $this->assertSame(4, $result->getLeft()); + $this->assertSame( + BorderPattern::rounded()->getChars(), + $result->getPattern()->getChars(), + ); + } + + public function testFromBorderInstanceWithColor() + { + $original = new Border(1, 2, 3, 4); + $result = Border::from($original, color: '#ff0000'); + + $this->assertNotSame($original, $result); + $this->assertSame(1, $result->getTop()); + $this->assertSame(2, $result->getRight()); + $this->assertSame(3, $result->getBottom()); + $this->assertSame(4, $result->getLeft()); + $this->assertSame(Color::from('#ff0000')->toRgb(), $result->getColor()->toRgb()); + } + + /** + * @param list $input + */ + #[DataProvider('invalidFromArrayProvider')] + public function testFromInvalidArray(array $input) + { + $this->expectException(InvalidArgumentException::class); + Border::from($input); + } + + /** + * @return iterable}> + */ + public static function invalidFromArrayProvider(): iterable + { + yield 'empty array' => [[]]; + yield '5 elements' => [[1, 2, 3, 4, 5]]; + } + + #[DataProvider('factoryMethodProvider')] + public function testFactoryMethods(Border $border, int $top, int $right, int $bottom, int $left) + { + $this->assertSame($top, $border->getTop()); + $this->assertSame($right, $border->getRight()); + $this->assertSame($bottom, $border->getBottom()); + $this->assertSame($left, $border->getLeft()); + } + + /** + * @return iterable + */ + public static function factoryMethodProvider(): iterable + { + yield 'all(5)' => [Border::all(5), 5, 5, 5, 5]; + yield 'xy(3, 1)' => [Border::xy(3, 1), 1, 3, 1, 3]; + yield 'xy(5) default y' => [Border::xy(5), 0, 5, 0, 5]; + } + + /** + * @param array $expectedChars + * @param array $expectedRgb + */ + #[DataProvider('factoryWithPatternAndColorProvider')] + public function testFactoryWithPatternAndColor(Border $border, int $top, int $right, int $bottom, int $left, array $expectedChars, array $expectedRgb) + { + $this->assertSame($top, $border->getTop()); + $this->assertSame($right, $border->getRight()); + $this->assertSame($bottom, $border->getBottom()); + $this->assertSame($left, $border->getLeft()); + $this->assertSame($expectedChars, $border->getPattern()->getChars()); + $this->assertSame($expectedRgb, $border->getColor()->toRgb()); + } + + /** + * @return iterable, array}> + */ + public static function factoryWithPatternAndColorProvider(): iterable + { + yield 'from() with pattern and color' => [ + Border::from([1], BorderPattern::ROUNDED, '#ff0000'), + 1, 1, 1, 1, + BorderPattern::rounded()->getChars(), + Color::from('#ff0000')->toRgb(), + ]; + yield 'all() with pattern and color' => [ + Border::all(2, BorderPattern::DOUBLE, '#00ff00'), + 2, 2, 2, 2, + BorderPattern::double()->getChars(), + Color::from('#00ff00')->toRgb(), + ]; + yield 'xy() with pattern and color' => [ + Border::xy(3, 1, BorderPattern::TALL, 'red'), + 1, 3, 1, 3, + BorderPattern::tall()->getChars(), + Color::from('red')->toRgb(), + ]; + } + + // --- wrapLines --- + + /** + * @param list $innerLines + */ + #[DataProvider('wrapLinesCountProvider')] + public function testWrapLinesLineCount(Border $border, array $innerLines, int $width, int $expectedCount) + { + $result = $border->wrapLines($innerLines, $width, new Style()); + $this->assertCount($expectedCount, $result); + } + + /** + * @return iterable, int, int}> + */ + public static function wrapLinesCountProvider(): iterable + { + yield 'all sides = 1' => [Border::all(1, BorderPattern::NONE), ['content'], 7, 3]; + yield 'no border' => [new Border(0, 0, 0, 0, BorderPattern::NONE), ['line1', 'line2'], 5, 2]; + yield 'top only' => [new Border(1, 0, 0, 0, BorderPattern::NONE), ['content'], 7, 2]; + yield 'bottom only' => [new Border(0, 0, 1, 0, BorderPattern::NONE), ['content'], 7, 2]; + yield 'multiple top rows' => [new Border(3, 0, 0, 0, BorderPattern::NONE), ['content'], 7, 4]; + yield 'multiple bottom rows' => [new Border(0, 0, 2, 0, BorderPattern::NONE), ['content'], 7, 3]; + yield 'empty inner lines' => [Border::all(1, BorderPattern::NONE), [], 5, 2]; + yield 'asymmetric (1,2,3,4)' => [new Border(1, 2, 3, 4), ['text'], 4, 5]; + yield 'zero width border' => [Border::all(0, BorderPattern::ROUNDED, '#ff0000'), ['line1', 'line2'], 5, 2]; + } + + /** + * @param string[] $expectedTopChars + * @param string[] $expectedBottomChars + */ + #[DataProvider('wrapLinesPatternProvider')] + public function testWrapLinesWithPattern(string $pattern, string $expectedSideChar, array $expectedTopChars, array $expectedBottomChars) + { + $border = Border::all(1, $pattern); + $innerStyle = new Style(); + + $result = $border->wrapLines(['hello'], 5, $innerStyle); + + $this->assertCount(3, $result); + foreach ($expectedTopChars as $char) { + $this->assertStringContainsString($char, $result[0]); + } + $this->assertStringContainsString($expectedSideChar, $result[1]); + $this->assertStringContainsString('hello', $result[1]); + foreach ($expectedBottomChars as $char) { + $this->assertStringContainsString($char, $result[2]); + } + } + + /** + * @return iterable + */ + public static function wrapLinesPatternProvider(): iterable + { + yield 'normal' => [BorderPattern::NORMAL, '│', ['─'], ['─']]; + yield 'double' => [BorderPattern::DOUBLE, '║', ['═'], ['═']]; + yield 'rounded' => [BorderPattern::ROUNDED, '│', ['╭', '╮'], ['╰', '╯']]; + } + + public function testWrapLinesWithOuterStyle() + { + $border = Border::all(1); + $innerStyle = new Style(); + $outerStyle = new Style(); + + $result = $border->wrapLines(['text'], 4, $innerStyle, $outerStyle); + + $this->assertCount(3, $result); + } + + public function testWrapLinesWithNoLeftRightBorder() + { + $border = new Border(1, 0, 1, 0); + $innerStyle = new Style(); + + $result = $border->wrapLines(['content'], 7, $innerStyle); + + // 1 top + 1 content + 1 bottom = 3 + $this->assertCount(3, $result); + // Middle row should contain content without left/right border chars + $this->assertStringContainsString('content', $result[1]); + } + + public function testWrapLinesWithBorderColor() + { + $border = Border::all(1, BorderPattern::NORMAL, '#ff0000'); + $innerStyle = new Style(); + + $result = $border->wrapLines(['text'], 4, $innerStyle); + + $this->assertCount(3, $result); + // The border color should be applied + $this->assertStringContainsString("\x1b[38;2;255;0;0m", $result[0]); + } + + public function testWrapLinesUsesInnerStyleColorWhenNoBorderColor() + { + $border = Border::all(1); + $innerStyle = new Style()->withColor('#00ff00'); + + $result = $border->wrapLines(['text'], 4, $innerStyle); + + $this->assertCount(3, $result); + } + + public function testWrapLinesWithMultipleInnerLines() + { + $border = Border::all(1); + $innerStyle = new Style(); + + $result = $border->wrapLines(['line1', 'line2', 'line3'], 5, $innerStyle); + + // 1 top + 3 content + 1 bottom = 5 + $this->assertCount(5, $result); + $this->assertStringContainsString('line1', $result[1]); + $this->assertStringContainsString('line2', $result[2]); + $this->assertStringContainsString('line3', $result[3]); + } + + public function testZeroWidthsPreservePatternAndColor() + { + $border = Border::all(0, BorderPattern::ROUNDED, '#ff0000'); + + $this->assertSame(BorderPattern::rounded()->getChars(), $border->getPattern()->getChars()); + $this->assertSame(Color::from('#ff0000')->toRgb(), $border->getColor()->toRgb()); + } +} diff --git a/src/Symfony/Component/Tui/Tests/Style/ColorTest.php b/src/Symfony/Component/Tui/Tests/Style/ColorTest.php new file mode 100644 index 0000000000000..d8ef3e801ce5a --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Style/ColorTest.php @@ -0,0 +1,306 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Style; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Exception\InvalidArgumentException; +use Symfony\Component\Tui\Style\Color; + +class ColorTest extends TestCase +{ + #[DataProvider('namedColorProvider')] + public function testNamedColor(string $name, string $expectedFg, string $expectedBg) + { + $color = Color::named($name); + $this->assertSame($expectedFg, $color->toForegroundCode()); + $this->assertSame($expectedBg, $color->toBackgroundCode()); + } + + /** + * @return iterable + */ + public static function namedColorProvider(): iterable + { + yield 'black' => ['black', "\x1b[30m", "\x1b[40m"]; + yield 'red' => ['red', "\x1b[31m", "\x1b[41m"]; + yield 'green' => ['green', "\x1b[32m", "\x1b[42m"]; + yield 'yellow' => ['yellow', "\x1b[33m", "\x1b[43m"]; + yield 'blue' => ['blue', "\x1b[34m", "\x1b[44m"]; + yield 'magenta' => ['magenta', "\x1b[35m", "\x1b[45m"]; + yield 'cyan' => ['cyan', "\x1b[36m", "\x1b[46m"]; + yield 'white' => ['white', "\x1b[37m", "\x1b[47m"]; + yield 'default' => ['default', "\x1b[39m", "\x1b[49m"]; + yield 'bright_white' => ['bright_white', "\x1b[97m", "\x1b[107m"]; + yield 'case insensitive' => ['RED', "\x1b[31m", "\x1b[41m"]; + } + + public function testColorAliases() + { + $gray = Color::named('gray'); + $grey = Color::named('grey'); + $this->assertSame($gray->toForegroundCode(), $grey->toForegroundCode()); + } + + public function testInvalidNamedColor() + { + $this->expectException(InvalidArgumentException::class); + Color::named('invalid_color'); + } + + #[DataProvider('paletteColorProvider')] + public function testPaletteColor(int $index, string $expectedFg, ?string $expectedBg) + { + $color = Color::palette($index); + $this->assertSame($expectedFg, $color->toForegroundCode()); + if (null !== $expectedBg) { + $this->assertSame($expectedBg, $color->toBackgroundCode()); + } + } + + /** + * @return iterable + */ + public static function paletteColorProvider(): iterable + { + yield 'foreground 196' => [196, "\x1b[38;5;196m", null]; + yield 'background 236' => [236, "\x1b[38;5;236m", "\x1b[48;5;236m"]; + yield 'boundary 0' => [0, "\x1b[38;5;0m", null]; + yield 'boundary 255' => [255, "\x1b[38;5;255m", null]; + } + + #[DataProvider('invalidPaletteProvider')] + public function testInvalidPaletteColor(int $index) + { + $this->expectException(InvalidArgumentException::class); + Color::palette($index); + } + + /** + * @return iterable + */ + public static function invalidPaletteProvider(): iterable + { + yield 'negative' => [-1]; + yield 'too high' => [256]; + } + + public function testHexColorForeground() + { + $color = Color::hex('#ff5500'); + $this->assertSame("\x1b[38;2;255;85;0m", $color->toForegroundCode()); + } + + public function testHexColorBackground() + { + $color = Color::hex('#ff5500'); + $this->assertSame("\x1b[48;2;255;85;0m", $color->toBackgroundCode()); + } + + /** + * @param non-empty-string $hex + */ + #[DataProvider('hexColorParsingProvider')] + public function testHexColorParsing(string $hex, string $expectedFg) + { + $this->assertSame($expectedFg, Color::hex($hex)->toForegroundCode()); + } + + /** + * @return iterable + */ + public static function hexColorParsingProvider(): iterable + { + yield 'with hash' => ['#ff5500', "\x1b[38;2;255;85;0m"]; + yield 'without hash' => ['ff5500', "\x1b[38;2;255;85;0m"]; + yield 'short form with hash' => ['#f50', "\x1b[38;2;255;85;0m"]; + yield 'short form without hash' => ['f50', "\x1b[38;2;255;85;0m"]; + } + + #[DataProvider('invalidHexColorProvider')] + public function testInvalidHexColor(string $hex) + { + $this->expectException(InvalidArgumentException::class); + Color::hex($hex); + } + + /** + * @return iterable + */ + public static function invalidHexColorProvider(): iterable + { + yield 'invalid characters' => ['#gg0000']; + yield 'invalid length' => ['#ff00']; + } + + public function testRgbColor() + { + $color = Color::rgb(255, 85, 0); + $this->assertSame("\x1b[38;2;255;85;0m", $color->toForegroundCode()); + } + + public function testInvalidRgbColor() + { + $this->expectException(InvalidArgumentException::class); + Color::rgb(256, 0, 0); + } + + #[DataProvider('colorFromProvider')] + public function testFrom(Color|string|int $input, string $expectedFg) + { + $this->assertSame($expectedFg, Color::from($input)->toForegroundCode()); + } + + /** + * @return iterable + */ + public static function colorFromProvider(): iterable + { + yield 'integer (palette)' => [196, "\x1b[38;5;196m"]; + yield 'hex string' => ['#ff5500', "\x1b[38;2;255;85;0m"]; + yield 'named string' => ['red', "\x1b[31m"]; + } + + #[DataProvider('toRgbProvider')] + public function testToRgb(Color $color, int $r, int $g, int $b) + { + $rgb = $color->toRgb(); + + $this->assertSame($r, $rgb['r']); + $this->assertSame($g, $rgb['g']); + $this->assertSame($b, $rgb['b']); + } + + /** + * @return iterable + */ + public static function toRgbProvider(): iterable + { + yield 'named red' => [Color::named('red'), 205, 0, 0]; + yield 'hex #ff8000' => [Color::hex('#ff8000'), 255, 128, 0]; + yield 'palette basic (1=red)' => [Color::palette(1), 205, 0, 0]; + yield 'palette cube first (16)' => [Color::palette(16), 0, 0, 0]; + yield 'palette cube red (196)' => [Color::palette(196), 255, 0, 0]; + yield 'palette grayscale first (232)' => [Color::palette(232), 8, 8, 8]; + yield 'palette grayscale last (255)' => [Color::palette(255), 238, 238, 238]; + } + + #[DataProvider('mixProvider')] + public function testMix(int $amount, int $r, int $g, int $b) + { + $color = Color::hex('#ff0000'); + $mixed = $color->mix('#0000ff', $amount); + $rgb = $mixed->toRgb(); + + $this->assertSame($r, $rgb['r']); + $this->assertSame($g, $rgb['g']); + $this->assertSame($b, $rgb['b']); + } + + /** + * @return iterable + */ + public static function mixProvider(): iterable + { + yield 'zero returns base' => [0, 255, 0, 0]; + yield 'hundred returns other' => [100, 0, 0, 255]; + yield 'fifty is halfway' => [50, 128, 0, 128]; + } + + public function testMixAcceptsColorInstance() + { + $color = Color::hex('#ff0000'); + $other = Color::hex('#00ff00'); + $mixed = $color->mix($other, 50); + $rgb = $mixed->toRgb(); + + $this->assertSame(128, $rgb['r']); + $this->assertSame(128, $rgb['g']); + $this->assertSame(0, $rgb['b']); + } + + #[DataProvider('invalidMixPercentageProvider')] + public function testMixInvalidPercentageThrows(int $percentage) + { + $this->expectException(InvalidArgumentException::class); + Color::hex('#ff0000')->mix('#000000', $percentage); + } + + /** + * @return iterable + */ + public static function invalidMixPercentageProvider(): iterable + { + yield 'over 100' => [101]; + yield 'negative' => [-1]; + } + + #[DataProvider('tintProvider')] + public function testTint(int $amount, string $hex, int $r, int $g, int $b) + { + $rgb = Color::hex($hex)->tint($amount)->toRgb(); + + $this->assertSame($r, $rgb['r']); + $this->assertSame($g, $rgb['g']); + $this->assertSame($b, $rgb['b']); + } + + /** + * @return iterable + */ + public static function tintProvider(): iterable + { + yield 'lightens color' => [50, '#ff0000', 255, 128, 128]; + yield 'zero is unchanged' => [0, '#336699', 51, 102, 153]; + yield 'hundred is white' => [100, '#336699', 255, 255, 255]; + } + + #[DataProvider('shadeProvider')] + public function testShade(int $amount, string $hex, int $r, int $g, int $b) + { + $rgb = Color::hex($hex)->shade($amount)->toRgb(); + + $this->assertSame($r, $rgb['r']); + $this->assertSame($g, $rgb['g']); + $this->assertSame($b, $rgb['b']); + } + + /** + * @return iterable + */ + public static function shadeProvider(): iterable + { + yield 'darkens color' => [50, '#ff0000', 128, 0, 0]; + yield 'zero is unchanged' => [0, '#336699', 51, 102, 153]; + yield 'hundred is black' => [100, '#336699', 0, 0, 0]; + } + + #[DataProvider('scaleProvider')] + public function testScale(int $amount, string $method) + { + $color = Color::hex('#ff0000'); + $scaled = $color->scale($amount); + $expected = $color->$method(abs($amount)); + + $this->assertSame($expected->toRgb(), $scaled->toRgb()); + } + + /** + * @return iterable + */ + public static function scaleProvider(): iterable + { + yield 'positive darkens (shade)' => [50, 'shade']; + yield 'negative lightens (tint)' => [-50, 'tint']; + yield 'zero is unchanged' => [0, 'shade']; + } +} diff --git a/src/Symfony/Component/Tui/Tests/Style/PaddingTest.php b/src/Symfony/Component/Tui/Tests/Style/PaddingTest.php new file mode 100644 index 0000000000000..6e5d49196e511 --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Style/PaddingTest.php @@ -0,0 +1,104 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Style; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Exception\InvalidArgumentException; +use Symfony\Component\Tui\Style\Padding; + +class PaddingTest extends TestCase +{ + /** + * @param list $input + */ + #[DataProvider('fromArrayProvider')] + public function testFromArray(array $input, int $top, int $right, int $bottom, int $left) + { + $padding = Padding::from($input); + + $this->assertSame($top, $padding->getTop()); + $this->assertSame($right, $padding->getRight()); + $this->assertSame($bottom, $padding->getBottom()); + $this->assertSame($left, $padding->getLeft()); + } + + /** + * @return iterable, int, int, int, int}> + */ + public static function fromArrayProvider(): iterable + { + yield '1 element (all sides)' => [[3], 3, 3, 3, 3]; + yield '2 elements (y, x)' => [[1, 2], 1, 2, 1, 2]; + yield '3 elements (top, x, bottom)' => [[1, 2, 3], 1, 2, 3, 2]; + yield '4 elements (top, right, bottom, left)' => [[1, 2, 3, 4], 1, 2, 3, 4]; + } + + public function testAll() + { + $padding = Padding::all(7); + + $this->assertSame(7, $padding->getTop()); + $this->assertSame(7, $padding->getRight()); + $this->assertSame(7, $padding->getBottom()); + $this->assertSame(7, $padding->getLeft()); + } + + public function testXy() + { + $padding = Padding::xy(3, 1); + + $this->assertSame(1, $padding->getTop()); + $this->assertSame(3, $padding->getRight()); + $this->assertSame(1, $padding->getBottom()); + $this->assertSame(3, $padding->getLeft()); + } + + public function testXyDefaultY() + { + $padding = Padding::xy(5); + + $this->assertSame(0, $padding->getTop()); + $this->assertSame(5, $padding->getRight()); + $this->assertSame(0, $padding->getBottom()); + $this->assertSame(5, $padding->getLeft()); + } + + public function testNegativeValuesClampedToZero() + { + $padding = new Padding(-5, -3, -1, -2); + + $this->assertSame(0, $padding->getTop()); + $this->assertSame(0, $padding->getRight()); + $this->assertSame(0, $padding->getBottom()); + $this->assertSame(0, $padding->getLeft()); + } + + /** + * @param list $input + */ + #[DataProvider('invalidFromArrayProvider')] + public function testFromInvalidArray(array $input) + { + $this->expectException(InvalidArgumentException::class); + Padding::from($input); + } + + /** + * @return iterable}> + */ + public static function invalidFromArrayProvider(): iterable + { + yield 'empty array' => [[]]; + yield '5 elements' => [[1, 2, 3, 4, 5]]; + } +} diff --git a/src/Symfony/Component/Tui/Tests/Style/StyleSheetTest.php b/src/Symfony/Component/Tui/Tests/Style/StyleSheetTest.php new file mode 100644 index 0000000000000..99e2dfdf011cd --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Style/StyleSheetTest.php @@ -0,0 +1,865 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Style; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Style\Color; +use Symfony\Component\Tui\Style\Direction; +use Symfony\Component\Tui\Style\Style; +use Symfony\Component\Tui\Style\StyleSheet; +use Symfony\Component\Tui\Widget\ContainerWidget; +use Symfony\Component\Tui\Widget\InputWidget; +use Symfony\Component\Tui\Widget\SelectListWidget; +use Symfony\Component\Tui\Widget\TextWidget; + +class StyleSheetTest extends TestCase +{ + public function testResolveWithNoRules() + { + $stylesheet = new StyleSheet(); + $widget = new TextWidget('Hello'); + + $resolved = $stylesheet->resolve($widget); + + $this->assertNull($resolved->getPadding()); + $this->assertNull($resolved->getBorder()); + $this->assertNull($resolved->getBackground()); + $this->assertNull($resolved->getColor()); + } + + public function testResolveWithUniversalSelector() + { + $stylesheet = new StyleSheet() + ->addRule('*', Style::padding([1, 2])); + $widget = new TextWidget('Hello'); + + $resolved = $stylesheet->resolve($widget); + + $this->assertSame(1, $resolved->getPadding()->getTop()); + $this->assertSame(2, $resolved->getPadding()->getRight()); + } + + public function testResolveWithFqcnSelector() + { + $stylesheet = new StyleSheet() + ->addRule(TextWidget::class, new Style()->withColor('red')); + $widget = new TextWidget('Hello'); + + $resolved = $stylesheet->resolve($widget); + + $this->assertSame(Color::named('red')->toForegroundCode(), $resolved->getColor()->toForegroundCode()); + } + + public function testResolveWithClassSelector() + { + $stylesheet = new StyleSheet() + ->addRule('.header', new Style()->withBold()); + $widget = new TextWidget('Hello') + ->addStyleClass('header'); + + $resolved = $stylesheet->resolve($widget); + + $this->assertTrue($resolved->getBold()); + } + + public function testMergePaddingInheritance() + { + // Base rule sets padding + // Override rule doesn't set padding (null) -> should inherit + $stylesheet = new StyleSheet() + ->addRule('*', Style::padding([2])) + ->addRule(TextWidget::class, new Style()->withColor('red')); + $widget = new TextWidget('Hello'); + + $resolved = $stylesheet->resolve($widget); + + // Should inherit padding from universal selector + $this->assertSame(2, $resolved->getPadding()->getTop()); + // And have color from FQCN selector + $this->assertSame(Color::named('red')->toForegroundCode(), $resolved->getColor()->toForegroundCode()); + } + + public function testMergePaddingExplicitZero() + { + // Base rule sets padding + // Override rule explicitly sets padding to zero -> should override, not inherit + $stylesheet = new StyleSheet() + ->addRule('*', Style::padding([2])) + ->addRule(TextWidget::class, Style::padding([0])); + $widget = new TextWidget('Hello'); + + $resolved = $stylesheet->resolve($widget); + + // Should use explicit zero padding, not inherit + $this->assertSame(0, $resolved->getPadding()->getTop()); + $this->assertSame(0, $resolved->getPadding()->getRight()); + $this->assertSame(0, $resolved->getPadding()->getBottom()); + $this->assertSame(0, $resolved->getPadding()->getLeft()); + } + + public function testMergeBorderInheritance() + { + // Base rule sets border + // Override rule doesn't set border (null) -> should inherit + $stylesheet = new StyleSheet() + ->addRule('*', Style::border([1])) + ->addRule(TextWidget::class, new Style()->withColor('blue')); + $widget = new TextWidget('Hello'); + + $resolved = $stylesheet->resolve($widget); + + // Should inherit border from universal selector + $this->assertSame(1, $resolved->getBorder()->getTop()); + } + + public function testMergeBorderExplicitZero() + { + // Base rule sets border + // Override rule explicitly sets border to zero -> should override, not inherit + $stylesheet = new StyleSheet() + ->addRule('*', Style::border([1])) + ->addRule(TextWidget::class, Style::border([0])); + $widget = new TextWidget('Hello'); + + $resolved = $stylesheet->resolve($widget); + + // Should use explicit zero border, not inherit + $this->assertSame(0, $resolved->getBorder()->getTop()); + $this->assertSame(0, $resolved->getBorder()->getRight()); + $this->assertSame(0, $resolved->getBorder()->getBottom()); + $this->assertSame(0, $resolved->getBorder()->getLeft()); + } + + public function testMergeColorInheritance() + { + // Base rule sets color + // Override rule doesn't set color (null) -> should inherit + $stylesheet = new StyleSheet() + ->addRule('*', new Style()->withColor('red')) + ->addRule(TextWidget::class, new Style()->withBold()); + $widget = new TextWidget('Hello'); + + $resolved = $stylesheet->resolve($widget); + + // Should inherit color and have bold + $this->assertSame(Color::named('red')->toForegroundCode(), $resolved->getColor()->toForegroundCode()); + $this->assertTrue($resolved->getBold()); + } + + public function testMergeMultipleClasses() + { + $stylesheet = new StyleSheet() + ->addRule('.card', Style::padding([1])->withBackground('blue')) + ->addRule('.highlight', new Style()->withColor('yellow')->withBold()); + $widget = new TextWidget('Hello') + ->addStyleClass('card') + ->addStyleClass('highlight'); + + $resolved = $stylesheet->resolve($widget); + + // Should have padding and background from .card + $this->assertSame(1, $resolved->getPadding()->getTop()); + $this->assertSame(Color::named('blue')->toForegroundCode(), $resolved->getBackground()->toForegroundCode()); + // And color and bold from .highlight + $this->assertSame(Color::named('yellow')->toForegroundCode(), $resolved->getColor()->toForegroundCode()); + $this->assertTrue($resolved->getBold()); + } + + public function testInstanceStyleOverridesStylesheet() + { + $stylesheet = new StyleSheet() + ->addRule('*', Style::padding([2])->withColor('red')); + $widget = new TextWidget('Hello'); + + $widget->setStyle(Style::padding([5])->withColor('blue')); + $resolved = $stylesheet->resolve($widget); + + // Instance style should override stylesheet + $this->assertSame(5, $resolved->getPadding()->getTop()); + $this->assertSame(Color::named('blue')->toForegroundCode(), $resolved->getColor()->toForegroundCode()); + } + + public function testInstanceStyleExplicitZeroOverridesStylesheet() + { + $stylesheet = new StyleSheet() + ->addRule('*', Style::padding([2])); + $widget = new TextWidget('Hello'); + + // Instance style explicitly sets padding to zero + $widget->setStyle(Style::padding([0])); + $resolved = $stylesheet->resolve($widget); + + // Instance style's explicit zero should override stylesheet's padding + $this->assertSame(0, $resolved->getPadding()->getTop()); + } + + /** + * @param \Closure(Style): ?bool $getter + */ + #[DataProvider('boolPropertyInheritanceProvider')] + public function testBoolPropertyInheritance(string $method, \Closure $getter) + { + // Base rule sets property, override doesn't -> should inherit + $stylesheet = new StyleSheet() + ->addRule('*', new Style()->$method()) + ->addRule(TextWidget::class, new Style()->withColor('red')); + + $resolved = $stylesheet->resolve(new TextWidget('Hello')); + + $this->assertTrue($getter($resolved)); + $this->assertSame(Color::named('red')->toForegroundCode(), $resolved->getColor()->toForegroundCode()); + } + + /** + * @param \Closure(Style): ?bool $getter + */ + #[DataProvider('boolPropertyInheritanceProvider')] + public function testBoolPropertyExplicitFalse(string $method, \Closure $getter) + { + // Base rule sets true, override explicitly sets false -> should override + $stylesheet = new StyleSheet() + ->addRule('*', new Style()->$method(true)) + ->addRule(TextWidget::class, new Style()->$method(false)); + + $resolved = $stylesheet->resolve(new TextWidget('Hello')); + + $this->assertFalse($getter($resolved)); + } + + /** + * @return iterable + */ + public static function boolPropertyInheritanceProvider(): iterable + { + yield 'bold' => ['withBold', static fn (Style $s) => $s->getBold()]; + yield 'italic' => ['withItalic', static fn (Style $s) => $s->getItalic()]; + yield 'dim' => ['withDim', static fn (Style $s) => $s->getDim()]; + } + + // --- Cascading Stylesheet Tests (via merge) --- + + public function testMergeSheetsRulesOverride() + { + // Default sheet + $defaultSheet = new StyleSheet() + ->addRule('*', Style::padding([2])->withColor('red')); + + // User sheet overrides + $userSheet = new StyleSheet() + ->addRule('*', Style::padding([5])->withColor('blue')); + + // Merge: user rules override default rules for the same selector + $merged = $defaultSheet->merge($userSheet); + + $widget = new TextWidget('Hello'); + $resolved = $merged->resolve($widget); + + // Should have padding from user sheet (overriding default) + $this->assertSame(5, $resolved->getPadding()->getTop()); + // Should have color from user sheet (overriding default) + $this->assertSame(Color::named('blue')->toForegroundCode(), $resolved->getColor()->toForegroundCode()); + } + + public function testMergeSheetsNewRulesAdded() + { + // Default sheet + $defaultSheet = new StyleSheet() + ->addRule('*', Style::padding([2])) + ->addRule(TextWidget::class, new Style()->withColor('red')); + + // User sheet adds new rules + $userSheet = new StyleSheet() + ->addRule('.header', new Style()->withBold()); + + $merged = $defaultSheet->merge($userSheet); + + $widget = new TextWidget('Hello') + ->addStyleClass('header'); + + $resolved = $merged->resolve($widget); + + // Should have padding from default sheet + $this->assertSame(2, $resolved->getPadding()->getTop()); + // Should have color from default sheet + $this->assertSame(Color::named('red')->toForegroundCode(), $resolved->getColor()->toForegroundCode()); + // Should have bold from user sheet + $this->assertTrue($resolved->getBold()); + } + + public function testMergeMultipleSheets() + { + // Three sheets merged in order + $base = new StyleSheet() + ->addRule('*', new Style()->withBold()); + + $theme = new StyleSheet() + ->addRule('*', new Style()->withColor('red')); + + $user = new StyleSheet() + ->addRule('*', Style::padding([1])); + + $merged = $base->merge($theme)->merge($user); + + $widget = new TextWidget('Hello'); + $resolved = $merged->resolve($widget); + + // Last sheet's universal rule wins + $this->assertSame(1, $resolved->getPadding()->getTop()); + // Previous universal rules are replaced, not property-merged + $this->assertNull($resolved->getColor()); + $this->assertNull($resolved->getBold()); + } + + public function testMergePreservesNonOverlappingSelectors() + { + $defaultSheet = new StyleSheet() + ->addRule(TextWidget::class, Style::padding([1, 2])->withColor('red')->withBold()); + + // User sheet adds a different selector + $userSheet = new StyleSheet() + ->addRule('.highlight', new Style()->withColor('yellow')); + + $merged = $defaultSheet->merge($userSheet); + + $widget = new TextWidget('Hello') + ->addStyleClass('highlight'); + + $resolved = $merged->resolve($widget); + + // Should have padding and bold from FQCN selector + $this->assertSame(1, $resolved->getPadding()->getTop()); + $this->assertTrue($resolved->getBold()); + // Color from .highlight overrides FQCN color (class selectors > FQCN) + $this->assertSame(Color::named('yellow')->toForegroundCode(), $resolved->getColor()->toForegroundCode()); + } + + public function testMergeStateSelectors() + { + // Default sheet + $defaultSheet = new StyleSheet() + ->addRule(InputWidget::class.':focused', new Style()->withBold()); + + // User sheet + $userSheet = new StyleSheet() + ->addRule(InputWidget::class.':focused', new Style()->withColor('yellow')); + + // Merge replaces the state selector rule + $merged = $defaultSheet->merge($userSheet); + + // Create a focused widget + $widget = new InputWidget(); + $widget->setFocused(true); + + $resolved = $merged->resolve($widget); + + // User sheet replaced the rule, so bold is gone + $this->assertNull($resolved->getBold()); + $this->assertSame(Color::named('yellow')->toForegroundCode(), $resolved->getColor()->toForegroundCode()); + } + + public function testMergeWithInstanceStyle() + { + $defaultSheet = new StyleSheet() + ->addRule('*', Style::padding([2])->withColor('red')); + + $userSheet = new StyleSheet() + ->addRule('*', new Style()->withBold()); + + // After merge, universal rule is from user sheet (bold only) + $merged = $defaultSheet->merge($userSheet); + + // Widget with instance style + $widget = new TextWidget('Hello'); + $widget->setStyle(new Style()->withColor('blue')); + + $resolved = $merged->resolve($widget); + + // Universal rule is now bold only (user sheet replaced it) + $this->assertTrue($resolved->getBold()); + // Color from widget's instance style + $this->assertSame(Color::named('blue')->toForegroundCode(), $resolved->getColor()->toForegroundCode()); + // Padding is gone (user sheet's universal rule doesn't have it) + $this->assertNull($resolved->getPadding()); + } + + public function testMergeDoesNotAffectSourceSheet() + { + $base = new StyleSheet() + ->addRule('*', Style::padding([2])); + + $extra = new StyleSheet() + ->addRule('.header', new Style()->withBold()); + + $base->merge($extra); + + // The source sheet should be unchanged + $this->assertCount(1, $extra->getRules()); + $this->assertArrayHasKey('.header', $extra->getRules()); + } + + public function testGetRulesReturnsAllRulesAfterMerge() + { + $sheet1 = new StyleSheet() + ->addRule('*', Style::padding([1])) + ->addRule('.a', new Style()->withBold()); + + $sheet2 = new StyleSheet() + ->addRule('.b', new Style()->withItalic()) + ->addRule('.a', new Style()->withColor('red')); // overrides + + $sheet1->merge($sheet2); + + $rules = $sheet1->getRules(); + $this->assertCount(3, $rules); + $this->assertArrayHasKey('*', $rules); + $this->assertArrayHasKey('.a', $rules); + $this->assertArrayHasKey('.b', $rules); + + // .a was overridden by sheet2's rule + $this->assertSame(Color::named('red')->toForegroundCode(), $rules['.a']->getColor()->toForegroundCode()); + $this->assertNull($rules['.a']->getBold()); + } + + // --- Sub-element (pseudo-element) resolution tests --- + + public function testResolveElementByFqcn() + { + $stylesheet = new StyleSheet([ + TextWidget::class.'::heading' => new Style()->withBold()->withColor('cyan'), + ]); + + $widget = new TextWidget('Hello'); + $style = $stylesheet->resolveElement($widget, 'heading'); + + $this->assertTrue($style->getBold()); + $this->assertSame(Color::named('cyan')->toForegroundCode(), $style->getColor()->toForegroundCode()); + } + + public function testResolveElementReturnsEmptyForNoRules() + { + $stylesheet = new StyleSheet(); + $widget = new TextWidget('Hello'); + + $style = $stylesheet->resolveElement($widget, 'heading'); + + $this->assertNull($style->getBold()); + $this->assertNull($style->getColor()); + } + + public function testResolveElementByCssClass() + { + $stylesheet = new StyleSheet([ + '.my-list::selected' => new Style()->withColor('green'), + ]); + + $items = [['value' => 'a', 'label' => 'A']]; + $widget = new SelectListWidget($items); + $widget->addStyleClass('my-list'); + + $style = $stylesheet->resolveElement($widget, 'selected'); + + $this->assertSame(Color::named('green')->toForegroundCode(), $style->getColor()->toForegroundCode()); + } + + public function testResolveElementFqcnAndClassMerge() + { + // FQCN rule sets bold, CSS class rule sets color + $stylesheet = new StyleSheet([ + SelectListWidget::class.'::selected' => new Style()->withBold(), + '.custom-list::selected' => new Style()->withColor('red'), + ]); + + $items = [['value' => 'a', 'label' => 'A']]; + $widget = new SelectListWidget($items); + $widget->addStyleClass('custom-list'); + + $style = $stylesheet->resolveElement($widget, 'selected'); + + // Both should be merged: bold from FQCN, color from class + $this->assertTrue($style->getBold()); + $this->assertSame(Color::named('red')->toForegroundCode(), $style->getColor()->toForegroundCode()); + } + + public function testResolveElementWithStateFlag() + { + $stylesheet = new StyleSheet([ + InputWidget::class.'::cursor' => new Style()->withReverse(), + InputWidget::class.'::cursor:focused' => new Style()->withColor('cyan'), + ]); + + // Unfocused: only reverse + $widget = new InputWidget(); + $style = $stylesheet->resolveElement($widget, 'cursor'); + $this->assertTrue($style->getReverse()); + $this->assertNull($style->getColor()); + + // Focused: reverse + color + $widget->setFocused(true); + $style = $stylesheet->resolveElement($widget, 'cursor'); + $this->assertTrue($style->getReverse()); + $this->assertSame(Color::named('cyan')->toForegroundCode(), $style->getColor()->toForegroundCode()); + } + + public function testResolveElementClassWithStateFlag() + { + $stylesheet = new StyleSheet([ + '.my-input::cursor' => new Style()->withReverse(), + '.my-input::cursor:focused' => new Style()->withColor('yellow'), + ]); + + $widget = new InputWidget(); + $widget->addStyleClass('my-input'); + + // Unfocused + $style = $stylesheet->resolveElement($widget, 'cursor'); + $this->assertTrue($style->getReverse()); + $this->assertNull($style->getColor()); + + // Focused + $widget->setFocused(true); + $style = $stylesheet->resolveElement($widget, 'cursor'); + $this->assertTrue($style->getReverse()); + $this->assertSame(Color::named('yellow')->toForegroundCode(), $style->getColor()->toForegroundCode()); + } + + public function testResolveElementCascadeOrder() + { + // Class rule overrides FQCN rule, state overrides both + $stylesheet = new StyleSheet([ + SelectListWidget::class.'::selected' => new Style()->withColor('red'), + '.themed::selected' => new Style()->withColor('blue'), + '.themed::selected:focused' => new Style()->withColor('green'), + ]); + + $items = [['value' => 'a', 'label' => 'A']]; + $widget = new SelectListWidget($items); + $widget->addStyleClass('themed'); + + // Unfocused: .themed::selected overrides FQCN::selected + $style = $stylesheet->resolveElement($widget, 'selected'); + // Blue from .themed overrides red from FQCN + $this->assertSame(Color::named('blue')->toForegroundCode(), $style->getColor()->toForegroundCode()); + + // Focused: .themed::selected:focused on top + $widget->setFocused(true); + $style = $stylesheet->resolveElement($widget, 'selected'); + $this->assertSame(Color::named('green')->toForegroundCode(), $style->getColor()->toForegroundCode()); + } + + public function testResolveElementDoesNotAffectWidgetResolve() + { + // Sub-element rules should NOT bleed into widget-level resolve() + $stylesheet = new StyleSheet([ + TextWidget::class => new Style()->withColor('red'), + TextWidget::class.'::heading' => new Style()->withBold()->withColor('cyan'), + ]); + + $widget = new TextWidget('Hello'); + + // Widget-level resolve should only get red color, not bold + $widgetStyle = $stylesheet->resolve($widget); + $this->assertSame(Color::named('red')->toForegroundCode(), $widgetStyle->getColor()->toForegroundCode()); + $this->assertNull($widgetStyle->getBold()); + + // Element-level should get bold + cyan + $elementStyle = $stylesheet->resolveElement($widget, 'heading'); + $this->assertTrue($elementStyle->getBold()); + $this->assertSame(Color::named('cyan')->toForegroundCode(), $elementStyle->getColor()->toForegroundCode()); + } + + // --- Responsive Breakpoint Tests --- + + public function testBreakpointAppliesAtOrAboveThreshold() + { + $stylesheet = new StyleSheet(); + $stylesheet->addRule('.panes', new Style(direction: Direction::Vertical)); + $stylesheet->addBreakpoint(120, '.panes', new Style(direction: Direction::Horizontal)); + + $widget = new ContainerWidget()->addStyleClass('panes'); + + // Below threshold: vertical + $resolved = $stylesheet->resolve($widget, 80); + $this->assertSame(Direction::Vertical, $resolved->getDirection()); + + // At threshold: horizontal + $resolved = $stylesheet->resolve($widget, 120); + $this->assertSame(Direction::Horizontal, $resolved->getDirection()); + + // Above threshold: horizontal + $resolved = $stylesheet->resolve($widget, 200); + $this->assertSame(Direction::Horizontal, $resolved->getDirection()); + } + + public function testBreakpointDoesNotApplyWithoutColumns() + { + $stylesheet = new StyleSheet(); + $stylesheet->addRule('.panes', new Style(direction: Direction::Vertical)); + $stylesheet->addBreakpoint(120, '.panes', new Style(direction: Direction::Horizontal)); + + $widget = new ContainerWidget()->addStyleClass('panes'); + + // Without columns, breakpoints are ignored + $resolved = $stylesheet->resolve($widget); + $this->assertSame(Direction::Vertical, $resolved->getDirection()); + } + + public function testMultipleBreakpointsAscendingOrder() + { + $stylesheet = new StyleSheet(); + $stylesheet->addRule('.card', new Style(gap: 0)); + $stylesheet->addBreakpoint(80, '.card', new Style(gap: 1)); + $stylesheet->addBreakpoint(120, '.card', new Style(gap: 2)); + $stylesheet->addBreakpoint(160, '.card', new Style(gap: 3)); + + $widget = new ContainerWidget()->addStyleClass('card'); + + // Below all breakpoints + $this->assertSame(0, $stylesheet->resolve($widget, 60)->getGap()); + + // First breakpoint + $this->assertSame(1, $stylesheet->resolve($widget, 80)->getGap()); + + // Second breakpoint overrides first + $this->assertSame(2, $stylesheet->resolve($widget, 120)->getGap()); + + // Third breakpoint overrides all + $this->assertSame(3, $stylesheet->resolve($widget, 200)->getGap()); + } + + public function testBreakpointMergesWithBaseRules() + { + $stylesheet = new StyleSheet(); + $stylesheet->addRule('.card', new Style(gap: 1)->withBackground('blue')); + $stylesheet->addBreakpoint(120, '.card', new Style(direction: Direction::Horizontal)); + + $widget = new ContainerWidget()->addStyleClass('card'); + + // Below breakpoint: base rules only + $resolved = $stylesheet->resolve($widget, 80); + $this->assertSame(1, $resolved->getGap()); + $this->assertSame(Color::named('blue')->toForegroundCode(), $resolved->getBackground()->toForegroundCode()); + $this->assertNull($resolved->getDirection()); + + // Above breakpoint: breakpoint merges on top (adds direction, keeps gap and background) + $resolved = $stylesheet->resolve($widget, 120); + $this->assertSame(1, $resolved->getGap()); + $this->assertSame(Color::named('blue')->toForegroundCode(), $resolved->getBackground()->toForegroundCode()); + $this->assertSame(Direction::Horizontal, $resolved->getDirection()); + } + + public function testBreakpointDoesNotOverrideInstanceStyle() + { + $stylesheet = new StyleSheet(); + $stylesheet->addBreakpoint(120, '.card', new Style(gap: 2)); + + $widget = new ContainerWidget()->addStyleClass('card'); + $widget->setStyle(new Style(gap: 5)); + + // Instance style overrides breakpoint + $resolved = $stylesheet->resolve($widget, 200); + $this->assertSame(5, $resolved->getGap()); + } + + public function testBreakpointWithUniversalSelector() + { + $stylesheet = new StyleSheet(); + $stylesheet->addBreakpoint(100, '*', new Style()->withColor('red')); + + $widget = new TextWidget('Hello'); + + // Below: no color + $this->assertNull($stylesheet->resolve($widget, 80)->getColor()); + + // Above: red + $this->assertSame(Color::named('red')->toForegroundCode(), $stylesheet->resolve($widget, 100)->getColor()->toForegroundCode()); + } + + public function testBreakpointWithFqcnSelector() + { + $stylesheet = new StyleSheet(); + $stylesheet->addBreakpoint(100, ContainerWidget::class, new Style(direction: Direction::Horizontal)); + + $widget = new ContainerWidget(); + + // Below: default + $this->assertNull($stylesheet->resolve($widget, 80)->getDirection()); + + // Above: horizontal + $this->assertSame(Direction::Horizontal, $stylesheet->resolve($widget, 100)->getDirection()); + } + + public function testBreakpointWithStateSelector() + { + $stylesheet = new StyleSheet(); + $stylesheet->addBreakpoint(100, InputWidget::class.':focused', new Style()->withBold()); + + $widget = new InputWidget(); + + // Unfocused at any width: no bold + $this->assertNull($stylesheet->resolve($widget, 200)->getBold()); + + // Focused below threshold: no bold + $widget->setFocused(true); + $this->assertNull($stylesheet->resolve($widget, 80)->getBold()); + + // Focused above threshold: bold + $this->assertTrue($stylesheet->resolve($widget, 100)->getBold()); + } + + public function testBreakpointMergeWithOtherStylesheet() + { + $base = new StyleSheet(); + $base->addBreakpoint(100, '.card', new Style(gap: 1)); + + $override = new StyleSheet(); + $override->addBreakpoint(100, '.card', new Style(gap: 2)); + $override->addBreakpoint(150, '.card', new Style(gap: 3)); + + $merged = $base->merge($override); + + $widget = new ContainerWidget()->addStyleClass('card'); + + // The override's rule for minColumns=100 replaces base's + $this->assertSame(2, $merged->resolve($widget, 100)->getGap()); + + // The override's rule for minColumns=150 is added + $this->assertSame(3, $merged->resolve($widget, 150)->getGap()); + } + + public function testHiddenMergesCorrectly() + { + $stylesheet = new StyleSheet([ + '.base' => new Style(hidden: true), + ]); + + $widget = new ContainerWidget()->addStyleClass('base'); + $this->assertTrue($stylesheet->resolve($widget)->getHidden()); + + // Instance style with hidden=false overrides stylesheet + $widget->setStyle(new Style(hidden: false)); + $this->assertFalse($stylesheet->resolve($widget)->getHidden()); + } + + public function testHiddenNullDoesNotOverride() + { + $stylesheet = new StyleSheet([ + '.base' => new Style(hidden: true), + '.extra' => new Style(bold: true), + ]); + + // Widget with both classes; .extra has hidden=null, should not override .base's hidden=true + $widget = new ContainerWidget()->addStyleClass('base')->addStyleClass('extra'); + $this->assertTrue($stylesheet->resolve($widget)->getHidden()); + $this->assertTrue($stylesheet->resolve($widget)->getBold()); + } + + public function testMergeFontInheritance() + { + // Base rule sets font, override rule doesn't -> should inherit + $stylesheet = new StyleSheet() + ->addRule('*', new Style(font: 'big')) + ->addRule(TextWidget::class, new Style()->withColor('red')); + $widget = new TextWidget('Hello'); + + $resolved = $stylesheet->resolve($widget); + + $this->assertSame('big', $resolved->getFont()); + $this->assertSame(Color::named('red')->toForegroundCode(), $resolved->getColor()->toForegroundCode()); + } + + public function testMergeFontOverride() + { + // Base rule sets font, override rule sets different font -> should override + $stylesheet = new StyleSheet() + ->addRule('*', new Style(font: 'big')) + ->addRule(TextWidget::class, new Style(font: 'small')); + $widget = new TextWidget('Hello'); + + $resolved = $stylesheet->resolve($widget); + + $this->assertSame('small', $resolved->getFont()); + } + + public function testFontFromClassSelector() + { + $stylesheet = new StyleSheet() + ->addRule('.title', new Style(font: 'slant')); + + $widget = new TextWidget('Hello') + ->addStyleClass('title'); + + $resolved = $stylesheet->resolve($widget); + + $this->assertSame('slant', $resolved->getFont()); + } + + public function testInstanceStyleFontOverridesStylesheet() + { + $stylesheet = new StyleSheet() + ->addRule('*', new Style(font: 'big')); + + $widget = new TextWidget('Hello'); + $widget->setStyle(new Style(font: 'mini')); + + $resolved = $stylesheet->resolve($widget); + + $this->assertSame('mini', $resolved->getFont()); + } + + public function testSubclassOverriddenMergeStylesIsCalledByResolve() + { + $stylesheet = new AlwaysDimStyleSheet([ + TextWidget::class => new Style(bold: true), + ]); + + $widget = new TextWidget('Hello'); + $resolved = $stylesheet->resolve($widget); + + // AlwaysDimStyleSheet::mergeStyles() forces dim=true + $this->assertTrue($resolved->getDim(), 'resolve() should call overridden mergeStyles() via static::'); + } + + public function testSubclassOverriddenMergeStylesIsCalledByResolveElement() + { + $stylesheet = new AlwaysDimStyleSheet([ + TextWidget::class.'::label' => new Style(bold: true), + ]); + + $widget = new TextWidget('Hello'); + $resolved = $stylesheet->resolveElement($widget, 'label'); + + // AlwaysDimStyleSheet::mergeStyles() forces dim=true + $this->assertTrue($resolved->getDim(), 'resolveElement() should call overridden mergeStyles() via static::'); + } +} + +/** + * @internal + */ +class AlwaysDimStyleSheet extends StyleSheet +{ + protected static function mergeStyles(array $styles): Style + { + $result = parent::mergeStyles($styles); + + return new Style( + padding: $result->getPadding(), + border: $result->getBorder(), + background: $result->getBackground(), + color: $result->getColor(), + bold: $result->getBold(), + dim: true, + italic: $result->getItalic(), + ); + } +} diff --git a/src/Symfony/Component/Tui/Tests/Style/StyleTest.php b/src/Symfony/Component/Tui/Tests/Style/StyleTest.php new file mode 100644 index 0000000000000..ddaabfb361fda --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Style/StyleTest.php @@ -0,0 +1,424 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Style; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Style\Align; +use Symfony\Component\Tui\Style\Border; +use Symfony\Component\Tui\Style\BorderPattern; +use Symfony\Component\Tui\Style\Color; +use Symfony\Component\Tui\Style\CursorShape; +use Symfony\Component\Tui\Style\Direction; +use Symfony\Component\Tui\Style\Padding; +use Symfony\Component\Tui\Style\Style; +use Symfony\Component\Tui\Style\TextAlign; +use Symfony\Component\Tui\Style\VerticalAlign; + +class StyleTest extends TestCase +{ + public function testBorderWithSingleElementArray() + { + $style = Style::border([2], BorderPattern::DOUBLE, 'red'); + $border = $style->getBorder(); + + $this->assertSame(2, $border->getTop()); + $this->assertSame(2, $border->getRight()); + $this->assertSame(2, $border->getBottom()); + $this->assertSame(2, $border->getLeft()); + $this->assertSame(BorderPattern::double()->getChars(), $border->getPattern()->getChars()); + $this->assertSame(BorderPattern::double()->getStrategies(), $border->getPattern()->getStrategies()); + $this->assertSame( + Color::from('red')->toForegroundCode(), + $border->getColor()?->toForegroundCode(), + ); + } + + public function testWithBorderPatternAndColor() + { + $style = Style::border([1]) + ->withBorderPattern(BorderPattern::WIDE) + ->withBorderColor('yellow'); + + $this->assertSame(BorderPattern::wide()->getChars(), $style->getBorder()->getPattern()->getChars()); + $this->assertSame( + Color::from('yellow')->toForegroundCode(), + $style->getBorder()->getColor()?->toForegroundCode(), + ); + } + + public function testApplyWithNoStyles() + { + $style = new Style(); + $this->assertSame('hello', $style->apply('hello')); + } + + /** + * @return iterable + */ + public static function applyStyleProvider(): iterable + { + yield 'bold' => [new Style()->withBold(), "\x1b[1m"]; + yield 'color red' => [new Style()->withColor('red'), "\x1b[31m"]; + yield 'background blue' => [new Style()->withBackground('blue'), "\x1b[44m"]; + } + + #[DataProvider('applyStyleProvider')] + public function testApplyEmitsExpectedAnsiCode(Style $style, string $expectedCode) + { + $result = $style->apply('hello'); + $this->assertStringContainsString($expectedCode, $result); + $this->assertStringContainsString('hello', $result); + } + + /** + * @return iterable + */ + public static function explicitFalseResetCodeProvider(): iterable + { + yield 'bold false → SGR 22' => [new Style()->withBold(false), "\x1b[22m"]; + yield 'italic false → SGR 23' => [new Style()->withItalic(false), "\x1b[23m"]; + yield 'underline false → SGR 24' => [new Style()->withUnderline(false), "\x1b[24m"]; + yield 'strikethrough false → SGR 29' => [new Style()->withStrikethrough(false), "\x1b[29m"]; + } + + #[DataProvider('explicitFalseResetCodeProvider')] + public function testApplyWithExplicitFalseEmitsResetCode(Style $style, string $expectedCode) + { + $result = $style->apply('hello'); + + $this->assertStringContainsString($expectedCode, $result); + $this->assertStringContainsString('hello', $result); + } + + public function testApplyWithBoldNullDoesNotEmitBoldCodes() + { + // When bold is null (not set), apply() should not emit any bold codes + $style = new Style(); + $result = $style->apply('hello'); + + // Should not contain bold-on or bold-off codes + $this->assertStringNotContainsString("\x1b[1m", $result); + $this->assertStringNotContainsString("\x1b[22m", $result); + $this->assertSame('hello', $result); + } + + public function testApplyWithDim() + { + $style = new Style()->withDim(); + $result = $style->apply('hello'); + + // Should contain the dim-on code (SGR 2) + $this->assertStringContainsString("\x1b[2m", $result); + $this->assertStringContainsString('hello', $result); + } + + public function testApplyWithDimNullDoesNotEmitDimCodes() + { + $style = new Style(); + $result = $style->apply('hello'); + + // Should not contain dim-on code + $this->assertStringNotContainsString("\x1b[2m", $result); + $this->assertSame('hello', $result); + } + + public function testApplyWithDimFalseEmitsResetCode() + { + // When dim is explicitly false, apply() should emit the + // bold/dim-off code (SGR 22) to cancel any inherited dim + $style = new Style()->withDim(false); + $result = $style->apply('hello'); + + $this->assertStringContainsString("\x1b[22m", $result); + $this->assertStringContainsString('hello', $result); + } + + public function testBoldAndDimCanCoexist() + { + // Bold and dim are independent attributes; both can be enabled + $style = new Style()->withBold()->withDim(); + $result = $style->apply('hello'); + + // Should contain both SGR 1 (bold) and SGR 2 (dim) + $this->assertStringContainsString("\x1b[1m", $result); + $this->assertStringContainsString("\x1b[2m", $result); + $this->assertTrue($style->getBold()); + $this->assertTrue($style->getDim()); + } + + /** + * SGR 22 resets both bold and dim. When one is true and the other explicitly + * false, the reset must come before the re-enable so the active attribute + * is not cancelled. + * + * @return iterable + */ + public static function boldDimInteractionProvider(): iterable + { + yield 'dim=true, bold=false → prefix: reset then dim' => [ + new Style(bold: false, dim: true), + "\x1b[22m\x1b[2m", + ]; + yield 'bold=true, dim=false → prefix: reset then bold' => [ + new Style(bold: true, dim: false), + "\x1b[22m\x1b[1m", + ]; + } + + /** + * @param non-empty-string $expectedPrefix + */ + #[DataProvider('boldDimInteractionProvider')] + public function testBoldDimResetOrderInPrefix(Style $style, string $expectedPrefix) + { + $result = $style->apply('hello'); + + $this->assertStringStartsWith($expectedPrefix, $result); + $this->assertStringEndsWith("\x1b[22m", $result); + } + + public function testBoldFalseAndDimFalseEmitsSingleReset() + { + // When both bold and dim are explicitly false, only one SGR 22 is needed + $style = new Style(bold: false, dim: false); + $result = $style->apply('hello'); + + // Should contain exactly one SGR 22 reset + $this->assertSame(1, substr_count($result, "\x1b[22m")); + } + + public function testBoldAndDimBothTrueNoReset() + { + // When both are true, no SGR 22 reset should appear in the prefix + $style = new Style(bold: true, dim: true); + $result = $style->apply('hello'); + + // Prefix should have SGR 1 and SGR 2, suffix should have SGR 22 + $this->assertStringStartsWith("\x1b[1m\x1b[2m", $result); + $this->assertStringEndsWith("\x1b[22m", $result); + + // Only one SGR 22 (in the suffix), not in the prefix + $this->assertSame(1, substr_count($result, "\x1b[22m")); + } + + public function testRestoreWithBoldTrueAndDimFalse() + { + // getAnsiRestore() should re-enable bold after a child's reset + $style = new Style(bold: true, dim: false); + $restore = $style->getAnsiRestore(); + + // Restore should contain reset then bold-on + $this->assertStringContainsString("\x1b[22m", $restore); + $this->assertStringContainsString("\x1b[1m", $restore); + + // Reset must come before bold enable + $pos22 = strpos($restore, "\x1b[22m"); + $pos1 = strpos($restore, "\x1b[1m"); + $this->assertLessThan($pos1, $pos22); + } + + public function testDimAndColorCombination() + { + $style = new Style()->withDim()->withColor('cyan'); + $result = $style->apply('hello'); + + // Should contain both dim (SGR 2) and cyan foreground (SGR 36) + $this->assertStringContainsString("\x1b[2m", $result); + $this->assertStringContainsString("\x1b[36m", $result); + } + + public function testWithoutLayoutPropertiesStripsAllLayoutProperties() + { + $style = new Style() + ->withPadding([2, 4]) + ->withBorder([1]) + ->withGap(2) + ->withDirection(Direction::Horizontal) + ->withHidden() + ->withCursorShape(CursorShape::Block) + ->withTextAlign(TextAlign::Center) + ->withAlign(Align::Center) + ->withVerticalAlign(VerticalAlign::Center) + ->withFlex(1) + ->withFont('big') + ->withColor('red') + ->withBold(); + $stripped = $style->withoutLayoutProperties(); + + // Layout properties stripped + $this->assertNull($stripped->getPadding()); + $this->assertNull($stripped->getBorder()); + $this->assertNull($stripped->getGap()); + $this->assertNull($stripped->getDirection()); + $this->assertNull($stripped->getHidden()); + $this->assertNull($stripped->getCursorShape()); + $this->assertNull($stripped->getTextAlign()); + $this->assertNull($stripped->getAlign()); + $this->assertNull($stripped->getVerticalAlign()); + $this->assertNull($stripped->getFlex()); + + // Content and visual properties preserved + $this->assertSame(Color::named('red')->toForegroundCode(), $stripped->getColor()->toForegroundCode()); + $this->assertTrue($stripped->getBold()); + $this->assertSame('big', $stripped->getFont()); + } + + public function testWithoutLayoutPropertiesPreservesApplyBehavior() + { + $style = new Style()->withPadding([2])->withGap(1)->withColor('red'); + $stripped = $style->withoutLayoutProperties(); + + $original = $style->apply('hello'); + $strippedResult = $stripped->apply('hello'); + + // Both should produce the same ANSI formatting (color codes) + $this->assertSame($original, $strippedResult); + } + + public function testMergeAllEmpty() + { + $result = Style::mergeAll([]); + + $this->assertNull($result->getPadding()); + $this->assertNull($result->getBold()); + $this->assertNull($result->getColor()); + } + + public function testMergeAllSingleStyle() + { + $style = new Style(bold: true, color: 'red'); + $result = Style::mergeAll([$style]); + + $this->assertTrue($result->getBold()); + $this->assertNotNull($result->getColor()); + } + + public function testMergeAllLaterOverridesEarlier() + { + $base = new Style(bold: true, italic: true, color: 'red'); + $override = new Style(bold: false, color: 'blue'); + + $result = Style::mergeAll([$base, $override]); + + $this->assertFalse($result->getBold()); + $this->assertTrue($result->getItalic()); + // Color overridden from red to blue + $this->assertNotNull($result->getColor()); + } + + public function testMergeAllMergesDisjointProperties() + { + $a = new Style( + padding: Padding::from([1, 2]), + bold: true, + color: 'red', + direction: Direction::Horizontal, + gap: 2, + align: Align::Center, + ); + $b = new Style( + border: Border::from([1]), + italic: true, + background: 'blue', + maxColumns: 80, + verticalAlign: VerticalAlign::Bottom, + ); + + $result = Style::mergeAll([$a, $b]); + + // Properties from $a + $this->assertNotNull($result->getPadding()); + $this->assertTrue($result->getBold()); + $this->assertNotNull($result->getColor()); + $this->assertSame(Direction::Horizontal, $result->getDirection()); + $this->assertSame(2, $result->getGap()); + $this->assertSame(Align::Center, $result->getAlign()); + + // Properties from $b + $this->assertNotNull($result->getBorder()); + $this->assertTrue($result->getItalic()); + $this->assertNotNull($result->getBackground()); + $this->assertSame(80, $result->getMaxColumns()); + $this->assertSame(VerticalAlign::Bottom, $result->getVerticalAlign()); + } + + public function testMergeAllThreeStylesCascade() + { + $a = new Style(bold: true, color: 'red'); + $b = new Style(italic: true, background: 'blue'); + $c = new Style(bold: false, underline: true, color: 'green'); + + $result = Style::mergeAll([$a, $b, $c]); + + // bold: true (a) → overridden to false (c) + $this->assertFalse($result->getBold()); + // italic: from b, untouched + $this->assertTrue($result->getItalic()); + // underline: from c + $this->assertTrue($result->getUnderline()); + // color: red (a) → overridden to green (c) + $this->assertNotNull($result->getColor()); + // background: from b + $this->assertNotNull($result->getBackground()); + } + + public function testMergeAllPreservesAllProperties() + { + $style = new Style( + padding: Padding::from([1]), + border: Border::from([1]), + background: '#ff0000', + color: '#00ff00', + bold: true, + dim: true, + italic: true, + strikethrough: true, + underline: true, + reverse: true, + direction: Direction::Horizontal, + gap: 3, + hidden: true, + cursorShape: CursorShape::Block, + textAlign: TextAlign::Center, + font: 'big', + maxColumns: 80, + align: Align::Right, + verticalAlign: VerticalAlign::Center, + flex: 2, + ); + + // Merge with an empty style; all properties should survive + $result = Style::mergeAll([new Style(), $style]); + + $this->assertNotNull($result->getPadding()); + $this->assertNotNull($result->getBorder()); + $this->assertNotNull($result->getBackground()); + $this->assertNotNull($result->getColor()); + $this->assertTrue($result->getBold()); + $this->assertTrue($result->getDim()); + $this->assertTrue($result->getItalic()); + $this->assertTrue($result->getStrikethrough()); + $this->assertTrue($result->getUnderline()); + $this->assertTrue($result->getReverse()); + $this->assertSame(Direction::Horizontal, $result->getDirection()); + $this->assertSame(3, $result->getGap()); + $this->assertTrue($result->getHidden()); + $this->assertNotNull($result->getCursorShape()); + $this->assertSame(TextAlign::Center, $result->getTextAlign()); + $this->assertSame('big', $result->getFont()); + $this->assertSame(80, $result->getMaxColumns()); + $this->assertSame(Align::Right, $result->getAlign()); + $this->assertSame(VerticalAlign::Center, $result->getVerticalAlign()); + $this->assertSame(2, $result->getFlex()); + } +} diff --git a/src/Symfony/Component/Tui/Tests/Style/TailwindStylesheetTest.php b/src/Symfony/Component/Tui/Tests/Style/TailwindStylesheetTest.php new file mode 100644 index 0000000000000..9a3bae81922f0 --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Style/TailwindStylesheetTest.php @@ -0,0 +1,1099 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Style; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Render\Renderer; +use Symfony\Component\Tui\Style\Align; +use Symfony\Component\Tui\Style\Color; +use Symfony\Component\Tui\Style\CursorShape; +use Symfony\Component\Tui\Style\Direction; +use Symfony\Component\Tui\Style\Style; +use Symfony\Component\Tui\Style\TailwindStylesheet; +use Symfony\Component\Tui\Style\TextAlign; +use Symfony\Component\Tui\Style\VerticalAlign; +use Symfony\Component\Tui\Widget\ContainerWidget; +use Symfony\Component\Tui\Widget\InputWidget; +use Symfony\Component\Tui\Widget\TextWidget; + +class TailwindStylesheetTest extends TestCase +{ + // --- Padding --- + + #[DataProvider('paddingProvider')] + public function testPadding(string $class, int $top, int $right, int $bottom, int $left) + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass($class); + + $resolved = $stylesheet->resolve($widget); + + $this->assertSame($top, $resolved->getPadding()->getTop()); + $this->assertSame($right, $resolved->getPadding()->getRight()); + $this->assertSame($bottom, $resolved->getPadding()->getBottom()); + $this->assertSame($left, $resolved->getPadding()->getLeft()); + } + + /** + * @return iterable + */ + public static function paddingProvider(): iterable + { + yield 'all' => ['p-2', 2, 2, 2, 2]; + yield 'zero' => ['p-0', 0, 0, 0, 0]; + yield 'horizontal' => ['px-3', 0, 3, 0, 3]; + yield 'vertical' => ['py-1', 1, 0, 1, 0]; + } + + public function testPaddingIndividualSides() + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass('pt-1'); + $widget->addStyleClass('pr-2'); + $widget->addStyleClass('pb-3'); + $widget->addStyleClass('pl-4'); + + $resolved = $stylesheet->resolve($widget); + + $this->assertSame(1, $resolved->getPadding()->getTop()); + $this->assertSame(2, $resolved->getPadding()->getRight()); + $this->assertSame(3, $resolved->getPadding()->getBottom()); + $this->assertSame(4, $resolved->getPadding()->getLeft()); + } + + public function testPaddingLastWins() + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass('p-2'); + $widget->addStyleClass('p-4'); + + $resolved = $stylesheet->resolve($widget); + + $this->assertSame(4, $resolved->getPadding()->getTop()); + $this->assertSame(4, $resolved->getPadding()->getRight()); + } + + public function testPaddingPartialOverride() + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass('p-2'); + $widget->addStyleClass('pl-5'); + + $resolved = $stylesheet->resolve($widget); + + $this->assertSame(2, $resolved->getPadding()->getTop()); + $this->assertSame(2, $resolved->getPadding()->getRight()); + $this->assertSame(2, $resolved->getPadding()->getBottom()); + $this->assertSame(5, $resolved->getPadding()->getLeft()); + } + + // --- Border --- + + #[DataProvider('borderWidthProvider')] + public function testBorderWidth(string $class, int $top, int $right, int $bottom, int $left) + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass($class); + + $resolved = $stylesheet->resolve($widget); + + $this->assertSame($top, $resolved->getBorder()->getTop()); + $this->assertSame($right, $resolved->getBorder()->getRight()); + $this->assertSame($bottom, $resolved->getBorder()->getBottom()); + $this->assertSame($left, $resolved->getBorder()->getLeft()); + } + + /** + * @return iterable + */ + public static function borderWidthProvider(): iterable + { + yield 'default' => ['border', 1, 1, 1, 1]; + yield 'width 2' => ['border-2', 2, 2, 2, 2]; + yield 'individual side' => ['border-t', 1, 0, 0, 0]; + yield 'individual side with width' => ['border-l-3', 0, 0, 0, 3]; + } + + public function testBorderNone() + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass('border-none'); + + $resolved = $stylesheet->resolve($widget); + + $this->assertSame(0, $resolved->getBorder()->getTop()); + $this->assertSame(0, $resolved->getBorder()->getRight()); + $this->assertSame(0, $resolved->getBorder()->getBottom()); + $this->assertSame(0, $resolved->getBorder()->getLeft()); + $this->assertTrue($resolved->getBorder()->getPattern()->isNone()); + } + + public function testBorderPattern() + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass('border'); + $widget->addStyleClass('border-rounded'); + + $resolved = $stylesheet->resolve($widget); + + $this->assertSame(1, $resolved->getBorder()->getTop()); + $this->assertFalse($resolved->getBorder()->getPattern()->isNone()); + } + + public function testBorderColor() + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass('border'); + $widget->addStyleClass('border-red-500'); + + $resolved = $stylesheet->resolve($widget); + + $this->assertSame(1, $resolved->getBorder()->getTop()); + $this->assertSame(Color::hex('#ef4444')->toRgb(), $resolved->getBorder()->getColor()->toRgb()); + } + + public function testBorderComposition() + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass('border-2'); + $widget->addStyleClass('border-rounded'); + $widget->addStyleClass('border-cyan-400'); + + $resolved = $stylesheet->resolve($widget); + + $this->assertSame(2, $resolved->getBorder()->getTop()); + $this->assertSame(2, $resolved->getBorder()->getRight()); + $this->assertSame(2, $resolved->getBorder()->getBottom()); + $this->assertSame(2, $resolved->getBorder()->getLeft()); + $this->assertSame(Color::hex('#06b6d4')->tint(20)->toRgb(), $resolved->getBorder()->getColor()->toRgb()); + $this->assertFalse($resolved->getBorder()->getPattern()->isNone()); + } + + public function testBorderHexColor() + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass('border'); + $widget->addStyleClass('border-[#ff5500]'); + + $resolved = $stylesheet->resolve($widget); + + $this->assertSame(Color::hex('#ff5500')->toRgb(), $resolved->getBorder()->getColor()->toRgb()); + } + + public function testBorderPaletteColor() + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass('border'); + $widget->addStyleClass('border-42'); + + // border-42 should be parsed as palette color, not border width + // (border-{n} matches first for plain digits, so this is width 42) + $resolved = $stylesheet->resolve($widget); + + // border-42 matches border-{n} → width 42 + $this->assertSame(42, $resolved->getBorder()->getTop()); + } + + // --- Background color --- + + #[DataProvider('backgroundColorProvider')] + public function testBackgroundColor(string $class, string $expectedFgCode) + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass($class); + + $resolved = $stylesheet->resolve($widget); + + $this->assertSame($expectedFgCode, $resolved->getBackground()->toForegroundCode()); + } + + /** + * @return iterable + */ + public static function backgroundColorProvider(): iterable + { + yield 'tailwind color' => ['bg-red-500', Color::hex('#ef4444')->toForegroundCode()]; + yield 'hex color' => ['bg-[#1e1e2e]', Color::hex('#1e1e2e')->toForegroundCode()]; + yield 'short hex color' => ['bg-[#f50]', Color::hex('#f50')->toForegroundCode()]; + yield 'palette color' => ['bg-236', Color::palette(236)->toForegroundCode()]; + } + + // --- Text color --- + + #[DataProvider('textColorProvider')] + public function testTextColor(string $class, string $expectedFgCode) + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass($class); + + $resolved = $stylesheet->resolve($widget); + + $this->assertSame($expectedFgCode, $resolved->getColor()->toForegroundCode()); + } + + /** + * @return iterable + */ + public static function textColorProvider(): iterable + { + yield 'tailwind color' => ['text-cyan-500', Color::hex('#06b6d4')->toForegroundCode()]; + yield 'hex color' => ['text-[#e0e0e0]', Color::hex('#e0e0e0')->toForegroundCode()]; + yield 'palette color' => ['text-245', Color::palette(245)->toForegroundCode()]; + } + + // --- Text decorations --- + + /** + * @return iterable + */ + public static function textDecorationProvider(): iterable + { + yield 'bold' => ['bold', 'getBold', true]; + yield 'not-bold' => ['not-bold', 'getBold', false]; + yield 'dim' => ['dim', 'getDim', true]; + yield 'not-dim' => ['not-dim', 'getDim', false]; + yield 'italic' => ['italic', 'getItalic', true]; + yield 'not-italic' => ['not-italic', 'getItalic', false]; + yield 'underline' => ['underline', 'getUnderline', true]; + yield 'no-underline' => ['no-underline', 'getUnderline', false]; + yield 'line-through' => ['line-through', 'getStrikethrough', true]; + yield 'no-line-through' => ['no-line-through', 'getStrikethrough', false]; + yield 'reverse' => ['reverse', 'getReverse', true]; + yield 'no-reverse' => ['no-reverse', 'getReverse', false]; + } + + #[DataProvider('textDecorationProvider')] + public function testTextDecoration(string $utilityClass, string $getter, bool $expected) + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass($utilityClass); + + $this->assertSame($expected, $stylesheet->resolve($widget)->$getter()); + } + + // --- Layout --- + + #[DataProvider('layoutUtilityProvider')] + public function testLayoutUtility(string $class, string $getter, mixed $expected) + { + $stylesheet = new TailwindStylesheet(); + $widget = new ContainerWidget(); + $widget->addStyleClass($class); + + $this->assertSame($expected, $stylesheet->resolve($widget)->$getter()); + } + + /** + * @return iterable + */ + public static function layoutUtilityProvider(): iterable + { + yield 'flex-row' => ['flex-row', 'getDirection', Direction::Horizontal]; + yield 'flex-col' => ['flex-col', 'getDirection', Direction::Vertical]; + yield 'flex-0' => ['flex-0', 'getFlex', 0]; + yield 'flex-1' => ['flex-1', 'getFlex', 1]; + yield 'flex-2' => ['flex-2', 'getFlex', 2]; + yield 'gap' => ['gap-2', 'getGap', 2]; + yield 'hidden' => ['hidden', 'getHidden', true]; + yield 'visible' => ['visible', 'getHidden', false]; + } + + // --- Text alignment --- + + /** + * @return iterable + */ + public static function textAlignProvider(): iterable + { + yield 'left' => ['text-left', TextAlign::Left]; + yield 'center' => ['text-center', TextAlign::Center]; + yield 'right' => ['text-right', TextAlign::Right]; + } + + #[DataProvider('textAlignProvider')] + public function testTextAlign(string $utilityClass, TextAlign $expected) + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass($utilityClass); + + $this->assertSame($expected, $stylesheet->resolve($widget)->getTextAlign()); + } + + public function testTextCenterDoesNotConflictWithTextColor() + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass('text-center'); + $widget->addStyleClass('text-red-500'); + + $resolved = $stylesheet->resolve($widget); + $this->assertSame(TextAlign::Center, $resolved->getTextAlign()); + $this->assertSame(Color::hex('#ef4444')->toRgb(), $resolved->getColor()->toRgb()); + } + + // --- Font --- + + /** + * @return iterable + */ + public static function fontProvider(): iterable + { + yield 'big' => ['font-big', 'big']; + yield 'small' => ['font-small', 'small']; + yield 'slant' => ['font-slant', 'slant']; + yield 'path' => ['font-/path/to/custom.flf', '/path/to/custom.flf']; + } + + #[DataProvider('fontProvider')] + public function testFont(string $utilityClass, string $expectedFont) + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass($utilityClass); + + $this->assertSame($expectedFont, $stylesheet->resolve($widget)->getFont()); + } + + public function testFontOverridesStylesheetRule() + { + $stylesheet = new TailwindStylesheet(); + $stylesheet->addRule(TextWidget::class, new Style(font: 'big')); + + $widget = new TextWidget('Hello'); + $widget->addStyleClass('font-small'); + + $this->assertSame('small', $stylesheet->resolve($widget)->getFont()); + } + + public function testInstanceStyleFontOverridesUtility() + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass('font-big'); + $widget->setStyle(new Style(font: 'mini')); + + $this->assertSame('mini', $stylesheet->resolve($widget)->getFont()); + } + + // --- Align --- + + /** + * @return iterable + */ + public static function alignmentProvider(): iterable + { + yield 'align-left' => ['align-left', 'getAlign', Align::Left]; + yield 'align-center' => ['align-center', 'getAlign', Align::Center]; + yield 'align-right' => ['align-right', 'getAlign', Align::Right]; + yield 'valign-top' => ['valign-top', 'getVerticalAlign', VerticalAlign::Top]; + yield 'valign-center' => ['valign-center', 'getVerticalAlign', VerticalAlign::Center]; + yield 'valign-bottom' => ['valign-bottom', 'getVerticalAlign', VerticalAlign::Bottom]; + } + + #[DataProvider('alignmentProvider')] + public function testAlignment(string $utilityClass, string $getter, Align|VerticalAlign $expected) + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass($utilityClass); + + $this->assertSame($expected, $stylesheet->resolve($widget)->$getter()); + } + + // --- Combination of multiple utilities --- + + public function testMultipleUtilities() + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass('p-1'); + $widget->addStyleClass('bg-blue-500'); + $widget->addStyleClass('text-neutral-50'); + $widget->addStyleClass('bold'); + $widget->addStyleClass('italic'); + + $resolved = $stylesheet->resolve($widget); + + $this->assertSame(1, $resolved->getPadding()->getTop()); + $this->assertSame(Color::hex('#3b82f6')->toRgb(), $resolved->getBackground()->toRgb()); + $this->assertSame(Color::hex('#737373')->tint(95)->toRgb(), $resolved->getColor()->toRgb()); + $this->assertTrue($resolved->getBold()); + $this->assertTrue($resolved->getItalic()); + } + + // --- Coexistence with regular CSS class rules --- + + public function testUtilityCoexistsWithRegularClasses() + { + $stylesheet = new TailwindStylesheet(); + $stylesheet->addRule('.card', new Style()->withBackground('blue')); + + $widget = new TextWidget('Hello'); + $widget->addStyleClass('card'); + $widget->addStyleClass('p-2'); + $widget->addStyleClass('bold'); + + $resolved = $stylesheet->resolve($widget); + + // Background from .card rule + $this->assertSame(Color::named('blue')->toForegroundCode(), $resolved->getBackground()->toForegroundCode()); + // Padding from utility class + $this->assertSame(2, $resolved->getPadding()->getTop()); + // Bold from utility class + $this->assertTrue($resolved->getBold()); + } + + public function testUtilityOverridesRegularClassRules() + { + $stylesheet = new TailwindStylesheet(); + $stylesheet->addRule('.card', Style::padding([5])->withBold()); + + $widget = new TextWidget('Hello'); + $widget->addStyleClass('card'); + $widget->addStyleClass('p-1'); + $widget->addStyleClass('not-bold'); + + $resolved = $stylesheet->resolve($widget); + + // Utility p-1 overrides .card's padding 5 (utilities are immutable) + $this->assertSame(1, $resolved->getPadding()->getTop()); + // Utility not-bold overrides .card's bold + $this->assertFalse($resolved->getBold()); + } + + public function testUtilityOverridesFqcnRules() + { + $stylesheet = new TailwindStylesheet(); + $stylesheet->addRule(TextWidget::class, new Style()->withColor('red')); + + $widget = new TextWidget('Hello'); + $widget->addStyleClass('text-blue-500'); + + $resolved = $stylesheet->resolve($widget); + + // Utility text-blue-500 overrides FQCN's red color + $this->assertSame(Color::hex('#3b82f6')->toRgb(), $resolved->getColor()->toRgb()); + } + + public function testUtilityOverridesUniversalRule() + { + $stylesheet = new TailwindStylesheet(); + $stylesheet->addRule('*', Style::padding([5])->withBold()); + + $widget = new TextWidget('Hello'); + $widget->addStyleClass('p-0'); + $widget->addStyleClass('not-bold'); + + $resolved = $stylesheet->resolve($widget); + + $this->assertSame(0, $resolved->getPadding()->getTop()); + $this->assertFalse($resolved->getBold()); + } + + public function testUtilityOverridesStateSelectors() + { + $stylesheet = new TailwindStylesheet(); + $stylesheet->addRule(InputWidget::class.':focused', new Style()->withBackground('blue')); + + $widget = new InputWidget(); + $widget->setFocused(true); + $widget->addStyleClass('bg-red-500'); + + $resolved = $stylesheet->resolve($widget); + + // Utility bg-red-500 overrides :focused background + $this->assertSame(Color::hex('#ef4444')->toRgb(), $resolved->getBackground()->toRgb()); + } + + public function testInstanceStyleOverridesUtility() + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass('p-2'); + $widget->addStyleClass('bold'); + $widget->setStyle(Style::padding([5])->withBold(false)); + + $resolved = $stylesheet->resolve($widget); + + // Instance style overrides utility classes + $this->assertSame(5, $resolved->getPadding()->getTop()); + $this->assertFalse($resolved->getBold()); + } + + // --- Regular classes still work normally --- + + public function testRegularClassRulesStillWork() + { + $stylesheet = new TailwindStylesheet(); + $stylesheet->addRule('.header', new Style()->withColor('cyan')->withBold()); + $stylesheet->addRule('.muted', new Style()->withColor('gray')); + + $widget = new TextWidget('Hello'); + $widget->addStyleClass('header'); + + $resolved = $stylesheet->resolve($widget); + + $this->assertSame(Color::named('cyan')->toForegroundCode(), $resolved->getColor()->toForegroundCode()); + $this->assertTrue($resolved->getBold()); + } + + public function testRegularStateSelectorsStillWork() + { + $stylesheet = new TailwindStylesheet(); + $stylesheet->addRule('.input-field', new Style()->withBackground('black')); + $stylesheet->addRule('.input-field:focused', new Style()->withBackground('blue')); + + $widget = new InputWidget(); + $widget->addStyleClass('input-field'); + + // Unfocused: black background + $resolved = $stylesheet->resolve($widget); + $this->assertSame(Color::named('black')->toForegroundCode(), $resolved->getBackground()->toForegroundCode()); + + // Focused: blue background + $widget->setFocused(true); + $resolved = $stylesheet->resolve($widget); + $this->assertSame(Color::named('blue')->toForegroundCode(), $resolved->getBackground()->toForegroundCode()); + } + + public function testUnrecognizedClassTreatedAsRegular() + { + $stylesheet = new TailwindStylesheet(); + $stylesheet->addRule('.my-custom-class', new Style()->withItalic()); + + $widget = new TextWidget('Hello'); + $widget->addStyleClass('my-custom-class'); + + $resolved = $stylesheet->resolve($widget); + + $this->assertTrue($resolved->getItalic()); + } + + // --- No utility classes = empty style --- + + public function testNoUtilityClassesNoEffect() + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + + $resolved = $stylesheet->resolve($widget); + + $this->assertNull($resolved->getPadding()); + $this->assertNull($resolved->getBorder()); + $this->assertNull($resolved->getBackground()); + $this->assertNull($resolved->getColor()); + $this->assertNull($resolved->getBold()); + } + + // --- Border patterns --- + + #[DataProvider('borderPatternProvider')] + public function testBorderPatterns(string $utilityClass) + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass('border'); + $widget->addStyleClass($utilityClass); + + $resolved = $stylesheet->resolve($widget); + + $this->assertSame(1, $resolved->getBorder()->getTop()); + } + + /** + * @return iterable + */ + public static function borderPatternProvider(): iterable + { + yield 'normal' => ['border-normal']; + yield 'rounded' => ['border-rounded']; + yield 'double' => ['border-double']; + yield 'tall' => ['border-tall']; + yield 'wide' => ['border-wide']; + yield 'tall-medium' => ['border-tall-medium']; + yield 'wide-medium' => ['border-wide-medium']; + yield 'tall-large' => ['border-tall-large']; + yield 'wide-large' => ['border-wide-large']; + } + + // --- Tailwind color families --- + + #[DataProvider('tailwindFamilyProvider')] + public function testTailwindFamilies(string $family, string $hex) + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass('text-'.$family.'-500'); + + $resolved = $stylesheet->resolve($widget); + + // shade 500 = base color (scale 0), so exact hex match + $this->assertSame(Color::hex($hex)->toRgb(), $resolved->getColor()->toRgb()); + } + + /** + * @return iterable + */ + public static function tailwindFamilyProvider(): iterable + { + yield 'slate' => ['slate', '#64748b']; + yield 'gray' => ['gray', '#6b7280']; + yield 'zinc' => ['zinc', '#71717a']; + yield 'neutral' => ['neutral', '#737373']; + yield 'stone' => ['stone', '#78716c']; + yield 'red' => ['red', '#ef4444']; + yield 'orange' => ['orange', '#f97316']; + yield 'amber' => ['amber', '#f59e0b']; + yield 'yellow' => ['yellow', '#eab308']; + yield 'lime' => ['lime', '#84cc16']; + yield 'green' => ['green', '#22c55e']; + yield 'emerald' => ['emerald', '#10b981']; + yield 'teal' => ['teal', '#14b8a6']; + yield 'cyan' => ['cyan', '#06b6d4']; + yield 'sky' => ['sky', '#0ea5e9']; + yield 'blue' => ['blue', '#3b82f6']; + yield 'indigo' => ['indigo', '#6366f1']; + yield 'violet' => ['violet', '#8b5cf6']; + yield 'purple' => ['purple', '#a855f7']; + yield 'fuchsia' => ['fuchsia', '#d946ef']; + yield 'pink' => ['pink', '#ec4899']; + yield 'rose' => ['rose', '#f43f5e']; + } + + // --- Sub-element (resolveElement) with utility classes --- + + public function testResolveElementDoesNotMatchUtilityClassNames() + { + $stylesheet = new TailwindStylesheet(); + // A rule targeting ".bold::cursor": "bold" is a utility class name + $stylesheet->addRule('.bold::cursor', new Style()->withBackground('red')); + + $widget = new InputWidget(); + $widget->addStyleClass('bold'); + + $style = $stylesheet->resolveElement($widget, 'cursor'); + + // "bold" is a utility class, not a CSS class; ".bold::cursor" must not match + $this->assertNull($style->getBackground()); + } + + public function testResolveElementMatchesCssClassesNotUtilityClasses() + { + $stylesheet = new TailwindStylesheet(); + $stylesheet->addRule('.my-input::cursor', new Style()->withReverse()); + + $widget = new InputWidget(); + $widget->addStyleClass('my-input'); + $widget->addStyleClass('bold'); + + $style = $stylesheet->resolveElement($widget, 'cursor'); + + // .my-input is a real CSS class; should match + $this->assertTrue($style->getReverse()); + } + + public function testResolveElementWithUtilityAndCssClassCombination() + { + $stylesheet = new TailwindStylesheet(); + $stylesheet->addRule('.my-input::cursor', new Style()->withReverse()); + $stylesheet->addRule('.p-2::cursor', new Style()->withBackground('blue')); + + $widget = new InputWidget(); + $widget->addStyleClass('my-input'); + $widget->addStyleClass('p-2'); + + $style = $stylesheet->resolveElement($widget, 'cursor'); + + // .my-input::cursor should match (real CSS class) + $this->assertTrue($style->getReverse()); + // .p-2::cursor should NOT match (p-2 is a utility class) + $this->assertNull($style->getBackground()); + } + + public function testResolveElementWithStateDoesNotMatchUtilityClassNames() + { + $stylesheet = new TailwindStylesheet(); + $stylesheet->addRule('.bold::cursor:focused', new Style()->withColor('cyan')); + + $widget = new InputWidget(); + $widget->addStyleClass('bold'); + $widget->setFocused(true); + + $style = $stylesheet->resolveElement($widget, 'cursor'); + + // "bold" is a utility class; ".bold::cursor:focused" must not match + $this->assertNull($style->getColor()); + } + + // --- Breakpoints still work --- + + public function testBreakpointsStillWork() + { + $stylesheet = new TailwindStylesheet(); + $stylesheet->addRule('.panes', new Style(direction: Direction::Vertical)); + $stylesheet->addBreakpoint(120, '.panes', new Style(direction: Direction::Horizontal)); + + $widget = new ContainerWidget(); + $widget->addStyleClass('panes'); + + // Below threshold: vertical + $this->assertSame(Direction::Vertical, $stylesheet->resolve($widget, 80)->getDirection()); + // Above threshold: horizontal + $this->assertSame(Direction::Horizontal, $stylesheet->resolve($widget, 120)->getDirection()); + } + + public function testUtilityOverridesBreakpoints() + { + $stylesheet = new TailwindStylesheet(); + $stylesheet->addBreakpoint(120, '*', new Style(gap: 5)); + + $widget = new ContainerWidget(); + $widget->addStyleClass('gap-1'); + + // Utility gap-1 overrides breakpoint gap 5 + $resolved = $stylesheet->resolve($widget, 200); + + $this->assertSame(1, $resolved->getGap()); + } + + public function testBreakpointsDoNotMatchUtilityClassNames() + { + $stylesheet = new TailwindStylesheet(); + // A breakpoint rule targeting ".bold" as a CSS class selector + $stylesheet->addBreakpoint(80, '.bold', new Style()->withBackground('red')); + + $widget = new TextWidget('Hello'); + // "bold" is a utility class, not a CSS class; it should NOT match ".bold" breakpoint selector + $widget->addStyleClass('bold'); + + $resolved = $stylesheet->resolve($widget, 200); + + $this->assertTrue($resolved->getBold()); + // Background should NOT be set; ".bold" breakpoint must not match utility class names + $this->assertNull($resolved->getBackground()); + } + + public function testBreakpointsMatchCssClassesNotUtilityClasses() + { + $stylesheet = new TailwindStylesheet(); + $stylesheet->addRule('.card', Style::padding([3])); + $stylesheet->addBreakpoint(80, '.card', new Style()->withBackground('blue')); + + $widget = new TextWidget('Hello'); + $widget->addStyleClass('card'); + $widget->addStyleClass('bold'); + + $resolved = $stylesheet->resolve($widget, 200); + + // .card breakpoint should match (it's a real CSS class) + $this->assertSame(Color::named('blue')->toForegroundCode(), $resolved->getBackground()->toForegroundCode()); + // bold utility should still work + $this->assertTrue($resolved->getBold()); + // padding from .card base rule + $this->assertSame(3, $resolved->getPadding()->getTop()); + } + + // --- Merge with other stylesheets --- + + public function testMergeWithRegularStylesheet() + { + $stylesheet = new TailwindStylesheet(); + $stylesheet->addRule('.card', Style::padding([3])->withBackground('blue')); + + $widget = new TextWidget('Hello'); + $widget->addStyleClass('card'); + $widget->addStyleClass('bold'); + $widget->addStyleClass('text-cyan-500'); + + $resolved = $stylesheet->resolve($widget); + + // .card provides padding and background + $this->assertSame(3, $resolved->getPadding()->getTop()); + $this->assertSame(Color::named('blue')->toForegroundCode(), $resolved->getBackground()->toForegroundCode()); + // Utility provides bold and text color + $this->assertTrue($resolved->getBold()); + $this->assertSame(Color::hex('#06b6d4')->toRgb(), $resolved->getColor()->toRgb()); + } + + // --- Edge cases --- + + public function testGapZero() + { + $stylesheet = new TailwindStylesheet(); + $widget = new ContainerWidget(); + $widget->addStyleClass('gap-0'); + + $this->assertSame(0, $stylesheet->resolve($widget)->getGap()); + } + + public function testBorderAllPatterns() + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass('border'); + $widget->addStyleClass('border-double'); + $widget->addStyleClass('border-green-500'); + + $resolved = $stylesheet->resolve($widget); + + $this->assertSame(1, $resolved->getBorder()->getTop()); + $this->assertSame(Color::hex('#22c55e')->toRgb(), $resolved->getBorder()->getColor()->toRgb()); + } + + public function testOnlyBorderColorWithoutWidth() + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass('border-red-500'); + + $resolved = $stylesheet->resolve($widget); + + // Border is created but with zero width (not visible, like Tailwind) + $this->assertSame(0, $resolved->getBorder()->getTop()); + $this->assertSame(Color::hex('#ef4444')->toRgb(), $resolved->getBorder()->getColor()->toRgb()); + } + + public function testInvalidColorIsNotUtility() + { + $stylesheet = new TailwindStylesheet(); + $stylesheet->addRule('.bg-foobar', new Style()->withItalic()); + + $widget = new TextWidget('Hello'); + $widget->addStyleClass('bg-foobar'); + + $resolved = $stylesheet->resolve($widget); + + // bg-foobar is not a valid color, so treated as regular class + $this->assertTrue($resolved->getItalic()); + $this->assertNull($resolved->getBackground()); + } + + public function testFullCascadeExample() + { + $stylesheet = new TailwindStylesheet(); + $stylesheet + ->addRule('*', new Style()->withColor('gray')) + ->addRule(TextWidget::class, new Style()->withDim()) + ->addRule('.card', Style::padding([3])); + + $widget = new TextWidget('Hello'); + $widget->addStyleClass('card'); + $widget->addStyleClass('p-1'); + $widget->addStyleClass('text-cyan-500'); + $widget->addStyleClass('bold'); + + $resolved = $stylesheet->resolve($widget); + + // p-1 overrides .card's padding 3 + $this->assertSame(1, $resolved->getPadding()->getTop()); + // text-cyan-500 overrides * color gray + $this->assertSame(Color::hex('#06b6d4')->toRgb(), $resolved->getColor()->toRgb()); + // bold from utility + $this->assertTrue($resolved->getBold()); + // dim inherited from TextWidget FQCN rule (not overridden by utilities) + $this->assertTrue($resolved->getDim()); + } + + public function testBorderSidesComposition() + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass('border-t'); + $widget->addStyleClass('border-b-2'); + + $resolved = $stylesheet->resolve($widget); + + $this->assertSame(1, $resolved->getBorder()->getTop()); + $this->assertSame(0, $resolved->getBorder()->getRight()); + $this->assertSame(2, $resolved->getBorder()->getBottom()); + $this->assertSame(0, $resolved->getBorder()->getLeft()); + } + + // --- Color shades --- + + public function testBackgroundColorShadeUsesTailwindPalette() + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass('bg-red-300'); + + $resolved = $stylesheet->resolve($widget); + + // Tailwind red-500 = #ef4444 → red-300 tinted 40% toward white + $rgb = $resolved->getBackground()->toRgb(); + $expected = Color::hex('#ef4444')->tint(40)->toRgb(); + $this->assertSame($expected, $rgb); + } + + public function testTextColorShadeUsesTailwindPalette() + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass('text-blue-700'); + + $resolved = $stylesheet->resolve($widget); + + // Tailwind blue-500 = #3b82f6 → blue-700 shaded 40% toward black + $rgb = $resolved->getColor()->toRgb(); + $expected = Color::hex('#3b82f6')->shade(40)->toRgb(); + $this->assertSame($expected, $rgb); + } + + public function testBorderColorShade() + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass('border'); + $widget->addStyleClass('border-green-600'); + + $resolved = $stylesheet->resolve($widget); + + $rgb = $resolved->getBorder()->getColor()->toRgb(); + $expected = Color::hex('#22c55e')->shade(20)->toRgb(); + $this->assertSame($expected, $rgb); + } + + public function testShade500IsTailwindBaseColor() + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass('text-cyan-500'); + + $resolved = $stylesheet->resolve($widget); + + // 500 = base Tailwind color, scale(0) = unchanged + $expected = Color::hex('#06b6d4')->toRgb(); + $rgb = $resolved->getColor()->toRgb(); + $this->assertSame($expected, $rgb); + } + + public function testTailwindOnlyFamilyWorks() + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass('bg-emerald-400'); + + $resolved = $stylesheet->resolve($widget); + + $rgb = $resolved->getBackground()->toRgb(); + $expected = Color::hex('#10b981')->tint(20)->toRgb(); + $this->assertSame($expected, $rgb); + } + + public function testShade50IsVeryLight() + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass('bg-red-50'); + + $resolved = $stylesheet->resolve($widget); + + $rgb = $resolved->getBackground()->toRgb(); + // 50 = tint 95% → very close to white + $this->assertGreaterThan(240, $rgb['r']); + $this->assertGreaterThan(240, $rgb['g']); + $this->assertGreaterThan(240, $rgb['b']); + } + + public function testShade950IsVeryDark() + { + $stylesheet = new TailwindStylesheet(); + $widget = new TextWidget('Hello'); + $widget->addStyleClass('bg-red-950'); + + $resolved = $stylesheet->resolve($widget); + + $rgb = $resolved->getBackground()->toRgb(); + // 950 = shade 90% → very close to black + $this->assertLessThan(30, $rgb['r']); + $this->assertLessThan(10, $rgb['g']); + $this->assertLessThan(10, $rgb['b']); + } + + public function testNonTailwindFamilyIsNotUtility() + { + $stylesheet = new TailwindStylesheet(); + $stylesheet->addRule('.text-bright-red-300', new Style()->withItalic()); + + $widget = new TextWidget('Hello'); + $widget->addStyleClass('text-bright-red-300'); + + $resolved = $stylesheet->resolve($widget); + + // bright-red is not a Tailwind family → treated as regular class + $this->assertTrue($resolved->getItalic()); + $this->assertNull($resolved->getColor()); + } + + public function testInvalidShadeNumberIsNotUtility() + { + $stylesheet = new TailwindStylesheet(); + $stylesheet->addRule('.text-red-999', new Style()->withItalic()); + + $widget = new TextWidget('Hello'); + $widget->addStyleClass('text-red-999'); + + $resolved = $stylesheet->resolve($widget); + + // 999 is not a valid shade → treated as regular class + $this->assertTrue($resolved->getItalic()); + $this->assertNull($resolved->getColor()); + } + + // --- Renderer integration --- + + public function testRendererMergesDefaultsUnderneathUtilities() + { + $stylesheet = new TailwindStylesheet(); + $renderer = new Renderer($stylesheet); + + // DefaultStyleSheet defines InputWidget::cursor with CursorShape::Block + // Verify the default style is available through the merged stylesheet + $input = new InputWidget(); + $resolved = $renderer->getStyleSheet()->resolveElement($input, 'cursor'); + + $this->assertSame(CursorShape::Block, $resolved->getCursorShape()); + } + + public function testRendererResolvesUtilityClassesThroughStylesheet() + { + $stylesheet = new TailwindStylesheet(); + $renderer = new Renderer($stylesheet); + + $widget = new TextWidget('Hello'); + $widget->addStyleClass('bold'); + $widget->addStyleClass('text-cyan-500'); + $widget->addStyleClass('p-1'); + + $resolved = $renderer->getStyleSheet()->resolve($widget); + + $this->assertTrue($resolved->getBold()); + $this->assertSame(Color::hex('#06b6d4')->toRgb(), $resolved->getColor()->toRgb()); + $this->assertSame(1, $resolved->getPadding()->getTop()); + } +} diff --git a/src/Symfony/Component/Tui/Tests/Terminal/BellTest.php b/src/Symfony/Component/Tui/Tests/Terminal/BellTest.php new file mode 100644 index 0000000000000..b2b3d8ab5997d --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Terminal/BellTest.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Terminal; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Terminal\TeeTerminal; +use Symfony\Component\Tui\Terminal\VirtualTerminal; + +class BellTest extends TestCase +{ + public function testBellWritesBelCharacter() + { + $terminal = new VirtualTerminal(); + $terminal->bell(); + + $this->assertSame("\x07", $terminal->getOutput()); + } + + public function testBellOnTeeTerminalWritesToBothTerminals() + { + $primary = new VirtualTerminal(); + $secondary = new VirtualTerminal(); + $tee = new TeeTerminal($primary, $secondary); + + $tee->bell(); + + $this->assertSame("\x07", $primary->getOutput()); + $this->assertSame("\x07", $secondary->getOutput()); + } +} diff --git a/src/Symfony/Component/Tui/Tests/Terminal/ScreenBufferTest.php b/src/Symfony/Component/Tui/Tests/Terminal/ScreenBufferTest.php new file mode 100644 index 0000000000000..702190473d056 --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Terminal/ScreenBufferTest.php @@ -0,0 +1,590 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Terminal; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Ansi\ScreenBufferHtmlRenderer; +use Symfony\Component\Tui\Terminal\ScreenBuffer; + +class ScreenBufferTest extends TestCase +{ + #[DataProvider('writeAndGetScreenProvider')] + public function testWriteAndGetScreen(int $width, int $height, string $input, string $expected) + { + $screen = new ScreenBuffer($width, $height); + $screen->write($input); + + $this->assertSame($expected, $screen->getScreen()); + } + + /** + * @return iterable + */ + public static function writeAndGetScreenProvider(): iterable + { + yield 'simple text' => [40, 10, 'Hello, World!', 'Hello, World!']; + yield 'newlines' => [40, 10, "Line 1\nLine 2\nLine 3", "Line 1\nLine 2\nLine 3"]; + yield 'carriage return' => [40, 10, "First\rSecond", 'Second']; + yield 'clear screen' => [40, 10, "Old content\x1b[2J\x1b[HNew content", 'New content']; + yield 'UTF-8 characters' => [40, 10, "→ Option 1\n Option 2\n✓ Selected", "→ Option 1\n Option 2\n✓ Selected"]; + yield 'scroll up' => [20, 3, "Line 1\nLine 2\nLine 3\nLine 4", "Line 2\nLine 3\nLine 4"]; + yield 'scroll up multiple' => [20, 3, "A\nB\nC\nD\nE\nF", "D\nE\nF"]; + yield 'wide character plain text' => [40, 10, '你好世界', '你好世界']; + yield 'wide character multiline' => [40, 10, "你好\n世界", "你好\n世界"]; + } + + public function testCursorMovement() + { + $screen = new ScreenBuffer(40, 10); + // Write "Hello", move cursor back 3, overwrite with "XYZ" + $screen->write("Hello\x1b[3DXYZ"); + + $this->assertSame('HeXYZ', $screen->getScreen()); + } + + public function testClearLine() + { + $screen = new ScreenBuffer(40, 10); + $screen->write("Line 1\nLine 2\nLine 3"); + // Move up one line (from line 3 to line 2) and clear it + $screen->write("\x1b[A\x1b[2K"); + + // Line 2 is now cleared (empty), cursor stays on line 2 + // getScreen trims trailing whitespace, so empty line 2 becomes "" + $lines = $screen->getLines(); + $this->assertSame('Line 1', rtrim($lines[0])); + $this->assertSame('', rtrim($lines[1])); + $this->assertSame('Line 3', rtrim($lines[2])); + } + + public function testCursorPositioning() + { + $screen = new ScreenBuffer(40, 10); + // Move cursor to row 3, column 5 (1-indexed) and write + $screen->write("\x1b[3;5HHello"); + + $lines = $screen->getLines(); + $this->assertSame('', $lines[0]); + $this->assertSame('', $lines[1]); + $this->assertSame(' Hello', $lines[2]); + } + + public function testDifferentialRenderingSimulation() + { + $screen = new ScreenBuffer(20, 5); + + // Simulate what TUI does: full render first, then differential updates + // First render - after this, cursor is at row 3, col 10 (after " Option 3") + $screen->write("Menu:\n Option 1\n Option 2\n Option 3"); + + // Move cursor up 2 lines (from row 3 to row 1), go to start of line, clear, and write + // Row 1 is " Option 1", so we're updating that line + $screen->write("\x1b[2A\r\x1b[2K> Option 1 (selected)"); + + $lines = $screen->getLines(); + $this->assertSame('Menu:', rtrim($lines[0])); + $this->assertSame('> Option 1 (selected)', rtrim($lines[1])); + $this->assertSame(' Option 2', rtrim($lines[2])); + $this->assertSame(' Option 3', rtrim($lines[3])); + } + + public function testSgrCodesInPlainText() + { + $screen = new ScreenBuffer(40, 10); + // SGR codes (colors, bold, etc.) should be stripped in plain text output + $screen->write("\x1b[1m\x1b[32mGreen Bold\x1b[0m Normal"); + + $this->assertSame('Green Bold Normal', $screen->getScreen()); + } + + public function testSgrCodesPreservedInStyledOutput() + { + $screen = new ScreenBuffer(40, 10); + $screen->write("\x1b[1;32mGreen Bold\x1b[0m Normal"); + + $styled = $screen->getStyledScreen(); + $this->assertStringContainsString("\x1b[1;32m", $styled); + $this->assertStringContainsString("\x1b[0m", $styled); + $this->assertStringContainsString('Green Bold', $styled); + } + + public function testHtmlConversion() + { + $screen = new ScreenBuffer(40, 10); + $screen->write("\x1b[1;32mGreen\x1b[0m \x1b[41mRed BG\x1b[0m"); + + $html = new ScreenBufferHtmlRenderer()->convert($screen); + $this->assertStringContainsString('assertSame('Hello', $screen->getScreen()); + } + + public function testBackspace() + { + $screen = new ScreenBuffer(40, 10); + $screen->write("Hello\x08"); // Backspace moves cursor back but doesn't delete + + // Backspace only moves cursor, so if we write something it overwrites + $screen->write('!'); + + $this->assertSame('Hell!', $screen->getScreen()); + } + + public function testDelCharacter() + { + $screen = new ScreenBuffer(40, 10); + $screen->write("Hello\x7f\x7f"); // DEL deletes previous characters + + $this->assertSame('Hel', $screen->getScreen()); + } + + public function testDelWithCursor() + { + $screen = new ScreenBuffer(40, 10); + // Simulate typing, then deleting, then showing cursor + $screen->write("Content: First\x7f\x7f\x7f\x7f\x7f▌"); + + $this->assertSame('Content: ▌', $screen->getScreen()); + } + + public function testReverseVideo() + { + $screen = new ScreenBuffer(40, 10); + // Reverse video is used for cursor display + $screen->write("Hello \x1b[7mW\x1b[27morld"); + + // Plain text should show the character normally + $this->assertSame('Hello World', $screen->getScreen()); + + // HTML should show reversed colors + $html = new ScreenBufferHtmlRenderer()->convert($screen); + $this->assertStringContainsString('background-color:', $html); + $this->assertStringContainsString('>W', $html); + } + + public function testApcSequenceSkipped() + { + $screen = new ScreenBuffer(40, 10); + // APC sequence (used for cursor marker) should be skipped + $cursorMarker = "\x1b_pi:c\x07"; + $screen->write("Hello{$cursorMarker}World"); + + $this->assertSame('HelloWorld', $screen->getScreen()); + } + + public function testCursorWithMarkerAndReverseVideo() + { + $screen = new ScreenBuffer(40, 10); + // This is how the Editor component renders a cursor + $cursorMarker = "\x1b_pi:c\x07"; + $screen->write("Hello, {$cursorMarker}\x1b[7mW\x1b[27morld!"); + + // Plain text shows the character + $this->assertSame('Hello, World!', $screen->getScreen()); + + // HTML shows the cursor with reversed colors + $html = new ScreenBufferHtmlRenderer()->convert($screen); + $this->assertStringContainsString('background-color:', $html); + } + + public function testEraseFromStartOfLineToCursor() + { + $screen = new ScreenBuffer(40, 10); + $screen->write('Hello World'); + $screen->write("\x1b[6G\x1b[1K"); // Move to column 6, erase from start to cursor + + $this->assertSame(' World', $screen->getScreen()); + } + + public function testEraseInLineModes0And1ProduceSameInternalState() + { + // Mode 0 (erase cursor to end) + mode 1 (erase start to cursor) should leave an empty line + $screen = new ScreenBuffer(40, 10); + $screen->write('Hello World'); + $screen->write("\x1b[6G"); // Move to column 6 + $screen->write("\x1b[0K"); // Erase from cursor to end + $screen->write("\x1b[1K"); // Erase from start to cursor + + $this->assertSame('', $screen->getScreen()); + } + + public function testEraseInLineMode1DoesNotExtendLine() + { + // After erasing from start to cursor, the erased cells should not + // make the line appear longer than the remaining content + $screen = new ScreenBuffer(40, 10); + $screen->write('ABCDE'); + $screen->write("\x1b[3G"); // Move to column 3 (0-indexed: col 2) + $screen->write("\x1b[1K"); // Erase from start to cursor (cols 0-2) + + // Cells 0-2 are erased, cells 3-4 remain ('D', 'E') + // The line should show spaces for erased positions then DE + $this->assertSame(' DE', $screen->getScreen()); + + // Verify that the erased cells are truly removed (not space-filled) + $cells = $screen->getCells(); + $this->assertArrayNotHasKey(0, $cells[0]); + $this->assertArrayNotHasKey(1, $cells[0]); + $this->assertArrayNotHasKey(2, $cells[0]); + $this->assertSame('D', $cells[0][3]['char']); + $this->assertSame('E', $cells[0][4]['char']); + } + + public function testGetCellsAndHeight() + { + $screen = new ScreenBuffer(40, 10); + $screen->write("\x1b[32mHello\x1b[0m"); + + $cells = $screen->getCells(); + $this->assertSame(10, $screen->getHeight()); + $this->assertSame('H', $cells[0][0]['char']); + $this->assertSame("\x1b[32m", $cells[0][0]['style']); + } + + public function testScrollUpPreservesRowKeys() + { + $screen = new ScreenBuffer(20, 3); + $screen->write("Line 1\nLine 2\nLine 3\nLine 4"); + + $cells = $screen->getCells(); + $this->assertSame([0, 1, 2], array_keys($cells)); + } + + public function testScrollUpWithStyledContent() + { + $screen = new ScreenBuffer(20, 3); + $screen->write("\x1b[31mRed\x1b[0m\n\x1b[32mGreen\x1b[0m\nPlain\n\x1b[34mBlue\x1b[0m"); + + // Red line scrolled off, Green, Plain, Blue remain + $cells = $screen->getCells(); + $this->assertSame("\x1b[32m", $cells[0][0]['style']); + $this->assertSame('G', $cells[0][0]['char']); + $this->assertSame('', $cells[1][0]['style']); + $this->assertSame('P', $cells[1][0]['char']); + $this->assertSame("\x1b[34m", $cells[2][0]['style']); + $this->assertSame('B', $cells[2][0]['char']); + } + + public function testScrollUpStyleStatePersists() + { + $screen = new ScreenBuffer(20, 3); + // Start green style, write 3 lines to fill screen, then scroll and write on new line + $screen->write("\x1b[32m"); + $screen->write("Line 1\nLine 2\nLine 3\nNew"); + + // "New" should still have the green style since it was never reset + $cells = $screen->getCells(); + $this->assertSame("\x1b[32m", $cells[2][0]['style']); + $this->assertSame('N', $cells[2][0]['char']); + } + + public function testScrollUpNewRowIsEmpty() + { + $screen = new ScreenBuffer(20, 3); + $screen->write("Line 1\nLine 2\nLine 3\n"); + + // After scrolling, the last row should be empty + $cells = $screen->getCells(); + $this->assertSame([], $cells[2]); + } + + public function testWideCharacterOccupiesTwoCells() + { + $screen = new ScreenBuffer(40, 10); + $screen->write('你好'); + + $cells = $screen->getCells(); + // Column 0: wide character '你' + $this->assertSame('你', $cells[0][0]['char']); + // Column 1: placeholder for the second half of '你' + $this->assertSame('', $cells[0][1]['char']); + // Column 2: wide character '好' + $this->assertSame('好', $cells[0][2]['char']); + // Column 3: placeholder for the second half of '好' + $this->assertSame('', $cells[0][3]['char']); + } + + public function testWideCharacterCursorAdvancesByTwo() + { + $screen = new ScreenBuffer(40, 10); + // Write one wide char then an ASCII char + $screen->write('你A'); + + $cells = $screen->getCells(); + $this->assertSame('你', $cells[0][0]['char']); + $this->assertSame('', $cells[0][1]['char']); + $this->assertSame('A', $cells[0][2]['char']); + } + + public function testWideCharacterMixedWithAscii() + { + $screen = new ScreenBuffer(40, 10); + $screen->write('Hi你好OK'); + + $cells = $screen->getCells(); + // H(0) i(1) 你(2,3) 好(4,5) O(6) K(7) + $this->assertSame('H', $cells[0][0]['char']); + $this->assertSame('i', $cells[0][1]['char']); + $this->assertSame('你', $cells[0][2]['char']); + $this->assertSame('', $cells[0][3]['char']); + $this->assertSame('好', $cells[0][4]['char']); + $this->assertSame('', $cells[0][5]['char']); + $this->assertSame('O', $cells[0][6]['char']); + $this->assertSame('K', $cells[0][7]['char']); + + $this->assertSame('Hi你好OK', $screen->getScreen()); + } + + public function testWideCharacterWithStyle() + { + $screen = new ScreenBuffer(40, 10); + $screen->write("\x1b[31m你好\x1b[0m"); + + $styled = $screen->getStyledScreen(); + $this->assertStringContainsString('你好', $styled); + $this->assertStringContainsString("\x1b[31m", $styled); + } + + public function testWideCharacterWithCursorPositioning() + { + $screen = new ScreenBuffer(40, 10); + // Position cursor at column 4 and write a wide character + $screen->write("\x1b[1;5H你"); + + $cells = $screen->getCells(); + $this->assertSame('你', $cells[0][4]['char']); + $this->assertSame('', $cells[0][5]['char']); + } + + public function testWideCharacterOverwrittenByAscii() + { + $screen = new ScreenBuffer(40, 10); + $screen->write('你'); + // Move back to column 0 and overwrite with ASCII + $screen->write("\r".'AB'); + + $cells = $screen->getCells(); + $this->assertSame('A', $cells[0][0]['char']); + $this->assertSame('B', $cells[0][1]['char']); + } + + public function testFullwidthForms() + { + $screen = new ScreenBuffer(40, 10); + // Fullwidth Latin A (U+FF21) + $screen->write('A'); + + $cells = $screen->getCells(); + $this->assertSame('A', $cells[0][0]['char']); + $this->assertSame('', $cells[0][1]['char']); + } + + public function testOverwriteContinuationCellClearsWideChar() + { + $screen = new ScreenBuffer(40, 10); + $screen->write('你'); + // Move cursor to column 1 (the continuation cell) and overwrite + $screen->write("\x1b[1;2HA"); + + $cells = $screen->getCells(); + // The wide char at column 0 must be replaced with a space + $this->assertSame(' ', $cells[0][0]['char']); + $this->assertSame('A', $cells[0][1]['char']); + } + + public function testOverwriteMainCellClearsContinuation() + { + $screen = new ScreenBuffer(40, 10); + $screen->write('你'); + // Move cursor back to column 0 and overwrite with a narrow char + $screen->write("\x1b[1;1HA"); + + $cells = $screen->getCells(); + $this->assertSame('A', $cells[0][0]['char']); + // The orphaned continuation cell must be replaced with a space + $this->assertSame(' ', $cells[0][1]['char']); + } + + public function testWideCharAtScreenEdgeIsSkipped() + { + $screen = new ScreenBuffer(5, 2); + $screen->write('ABCD'); + // Cursor is now at column 4 (last column), wide char needs 2 cells + $screen->write('你'); + + $cells = $screen->getCells(); + // The wide char should NOT be placed, it doesn't fit + $this->assertSame('D', $cells[0][3]['char']); + $this->assertArrayNotHasKey(4, $cells[0]); + } + + public function testEraseFromCursorToEndClearsWideCharOnBoundary() + { + $screen = new ScreenBuffer(40, 10); + $screen->write('你好'); + // Move cursor to column 1 (continuation cell of 你) + $screen->write("\x1b[1;2H"); + // Erase from cursor to end of line + $screen->write("\x1b[0K"); + + $cells = $screen->getCells(); + // The wide char 你 at col 0 must also be erased since its continuation was erased + $this->assertArrayNotHasKey(0, $cells[0]); + $this->assertArrayNotHasKey(1, $cells[0]); + $this->assertArrayNotHasKey(2, $cells[0]); + $this->assertArrayNotHasKey(3, $cells[0]); + } + + public function testEraseFromStartToCursorClearsWideCharOnBoundary() + { + $screen = new ScreenBuffer(40, 10); + $screen->write('AB你好CD'); + // Move cursor to column 2 (main cell of 你) + $screen->write("\x1b[1;3H"); + // Erase from start of line to cursor + $screen->write("\x1b[1K"); + + $cells = $screen->getCells(); + // Cols 0-2 erased, and col 3 (continuation of 你) must also be erased + $this->assertArrayNotHasKey(0, $cells[0]); + $this->assertArrayNotHasKey(1, $cells[0]); + $this->assertArrayNotHasKey(2, $cells[0]); + $this->assertArrayNotHasKey(3, $cells[0]); + // 好 and CD remain + $this->assertSame('好', $cells[0][4]['char']); + $this->assertSame('', $cells[0][5]['char']); + $this->assertSame('C', $cells[0][6]['char']); + $this->assertSame('D', $cells[0][7]['char']); + } + + /** + * @return iterable + */ + public static function underlineColorPreservedProvider(): iterable + { + yield '256-color index' => ["\x1b[4;58;5;196mHello\x1b[0m", '58;5;196']; + yield 'true-color RGB' => ["\x1b[4;58;2;255;0;0mHello\x1b[0m", '58;2;255;0;0']; + } + + #[DataProvider('underlineColorPreservedProvider')] + public function testUnderlineColorPreservedInStyledOutput(string $input, string $expectedCode) + { + $screen = new ScreenBuffer(40, 10); + $screen->write($input); + + $styled = $screen->getStyledScreen(); + $this->assertStringContainsString($expectedCode, $styled); + $this->assertStringContainsString('Hello', $styled); + } + + public function testUnderlineColorDoesNotCorruptFollowingCodes() + { + $screen = new ScreenBuffer(40, 10); + // This was the original bug: 58;2;R;G;B sub-parameters were + // misinterpreted as separate SGR codes (2 → dim, 0 → reset) + $screen->write("\x1b[58;2;255;0;0mHello\x1b[0m World"); + + // "Hello" must NOT have dim set (code 2 was a sub-parameter, not SGR 2) + $cells = $screen->getCells(); + $style = $cells[0][0]['style']; + $this->assertStringNotContainsString("\x1b[2", $style); + + // "World" must be rendered (code 0 was a sub-parameter, not SGR 0 reset) + $this->assertSame('Hello World', $screen->getScreen()); + } + + public function testDefaultUnderlineColorResets() + { + $screen = new ScreenBuffer(40, 10); + // Set underline color, then reset it with SGR 59 + $screen->write("\x1b[4;58;5;196mRed\x1b[59m Normal\x1b[0m"); + + $cells = $screen->getCells(); + // 'R' should have underline color + $this->assertStringContainsString('58;5;196', $cells[0][0]['style']); + // ' ' (space before "Normal") should NOT have underline color + $this->assertStringNotContainsString('58;', $cells[0][3]['style']); + } + + public function testGetScreenAndGetStyledScreenTrimConsistently() + { + $screen = new ScreenBuffer(40, 5); + $screen->write("Line 1\n\x1b[32mLine 3\x1b[0m"); + + $plainLines = explode("\n", $screen->getScreen()); + $styledLines = explode("\n", $screen->getStyledScreen()); + + $this->assertCount(\count($plainLines), $styledLines); + } + + public function testEmptyScreenReturnsEmptyString() + { + $screen = new ScreenBuffer(40, 5); + + $this->assertSame('', $screen->getScreen()); + $this->assertSame('', $screen->getStyledScreen()); + } + + public function testClearAndSgrResetProduceSameStyleState() + { + // Write styled text, then SGR reset, then a character + $screenA = new ScreenBuffer(40, 10); + $screenA->write("\x1b[1;3;4;31;42mStyled\x1b[0mA"); + + // Write styled text, then clear(), then a character + $screenB = new ScreenBuffer(40, 10); + $screenB->write("\x1b[1;3;4;31;42mStyled"); + $screenB->clear(); + $screenB->write('A'); + + $cellsA = $screenA->getCells(); + $cellsB = $screenB->getCells(); + + // Both 'A' characters should have the same (empty) style + $this->assertSame($cellsB[0][0]['style'], $cellsA[0][6]['style']); + $this->assertSame('', $cellsA[0][6]['style']); + } + + #[DataProvider('ignoredSequenceProvider')] + public function testEscapeSequenceIsIgnored(string $input, string $expectedScreen) + { + $screen = new ScreenBuffer(40, 5); + $screen->write($input); + + $this->assertSame($expectedScreen, $screen->getScreen()); + } + + /** + * @return iterable + */ + public static function ignoredSequenceProvider(): iterable + { + yield 'DCS' => ["AB\x1bPq;sixeldata\x1b\\CD", 'ABCD']; + yield 'PM' => ["AB\x1b^private\x1b\\CD", 'ABCD']; + yield 'SOS' => ["AB\x1bXstring\x1b\\CD", 'ABCD']; + yield 'Fe (IND + RI)' => ["\x1bDAB\x1bMCD", 'ABCD']; + yield 'Fp (DECSC + DECRC)' => ["\x1b7AB\x1b8CD", 'ABCD']; + yield 'nF charset designation' => ["\x1b(0AB\x1b(BCD", 'ABCD']; + yield 'private mode set/reset' => ["Hello\x1b[?25h\x1b[?25l World", 'Hello World']; + yield 'standard mode set/reset' => ["Hello\x1b[25h\x1b[25l World", 'Hello World']; + } +} diff --git a/src/Symfony/Component/Tui/Tests/Terminal/TerminalTest.php b/src/Symfony/Component/Tui/Tests/Terminal/TerminalTest.php new file mode 100644 index 0000000000000..8c53f92c79511 --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Terminal/TerminalTest.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Terminal; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Terminal\Terminal; + +class TerminalTest extends TestCase +{ + public function testFireAndForgetDoesNotBlock() + { + $terminal = new Terminal(); + + $start = microtime(true); + $terminal->fireAndForget(['sleep', '10']); + $elapsed = microtime(true) - $start; + + // Should return nearly instantly, not wait 10 seconds + $this->assertLessThan(1.0, $elapsed); + } + + public function testFireAndForgetProcessSurvivesCallerScope() + { + $marker = tempnam(sys_get_temp_dir(), 'faf_'); + unlink($marker); + + $terminal = new Terminal(); + + // Start a background command that creates a marker file after a short delay + $terminal->fireAndForget(['sh', '-c', \sprintf('sleep 1 && touch %s', escapeshellarg($marker))]); + + // The process must still be running after fireAndForget returns + $this->assertFileDoesNotExist($marker); + + // Wait for the background process to finish + $deadline = microtime(true) + 5; + while (!file_exists($marker) && microtime(true) < $deadline) { + usleep(100_000); + } + + $this->assertFileExists($marker); + @unlink($marker); + } +} diff --git a/src/Symfony/Component/Tui/Tests/Terminal/VirtualTerminalTest.php b/src/Symfony/Component/Tui/Tests/Terminal/VirtualTerminalTest.php new file mode 100644 index 0000000000000..0b9f455b01bd6 --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Terminal/VirtualTerminalTest.php @@ -0,0 +1,126 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Terminal; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Terminal\VirtualTerminal; + +class VirtualTerminalTest extends TestCase +{ + public function testSimulateInputForwardsKeySequences() + { + $terminal = new VirtualTerminal(); + $received = []; + + $terminal->start( + function (string $data) use (&$received) { $received[] = $data; }, + function () {}, + function () {}, + ); + + $terminal->simulateInput('abc'); + + $this->assertSame(['a', 'b', 'c'], $received); + + $terminal->stop(); + } + + public function testSimulateInputForwardsPasteContent() + { + $terminal = new VirtualTerminal(); + $received = []; + + $terminal->start( + function (string $data) use (&$received) { $received[] = $data; }, + function () {}, + function () {}, + ); + + $terminal->simulateInput("\x1b[200~Hello World\x1b[201~"); + + $this->assertSame(["\x1b[200~Hello World\x1b[201~"], $received); + + $terminal->stop(); + } + + public function testSimulateInputForwardsPasteMixedWithKeys() + { + $terminal = new VirtualTerminal(); + $received = []; + + $terminal->start( + function (string $data) use (&$received) { $received[] = $data; }, + function () {}, + function () {}, + ); + + $terminal->simulateInput("a\x1b[200~pasted\x1b[201~b"); + + $this->assertSame(['a', "\x1b[200~pasted\x1b[201~", 'b'], $received); + + $terminal->stop(); + } + + #[DataProvider('setTitleProvider')] + public function testSetTitleSanitizesInput(string $input, string $expectedOutput) + { + $terminal = new VirtualTerminal(); + $terminal->setTitle($input); + + $this->assertSame($expectedOutput, $terminal->getOutput()); + } + + /** + * @return iterable + */ + public static function setTitleProvider(): iterable + { + yield 'strips BEL character' => ["evil\x07injected", "\x1b]0;evilinjected\x07"]; + yield 'strips ESC character' => ["evil\x1b[31mred", "\x1b]0;evil[31mred\x07"]; + yield 'strips all control characters' => ["a\x00b\x01c\x1fd\x7fe", "\x1b]0;abcde\x07"]; + yield 'preserves unicode' => ['✓ Complete! 🎉', "\x1b]0;✓ Complete! 🎉\x07"]; + } + + public function testConsumeOutputReturnsAndClearsBuffer() + { + $terminal = new VirtualTerminal(); + $terminal->write('first'); + $terminal->write(' batch'); + + $this->assertSame('first batch', $terminal->consumeOutput()); + $this->assertSame('', $terminal->consumeOutput()); + + $terminal->write('second batch'); + + $this->assertSame('second batch', $terminal->consumeOutput()); + $this->assertSame('', $terminal->getOutput()); + } + + public function testSimulateInputPasteWithMultilineContent() + { + $terminal = new VirtualTerminal(); + $received = []; + + $terminal->start( + function (string $data) use (&$received) { $received[] = $data; }, + function () {}, + function () {}, + ); + + $terminal->simulateInput("\x1b[200~line1\nline2\nline3\x1b[201~"); + + $this->assertSame(["\x1b[200~line1\nline2\nline3\x1b[201~"], $received); + + $terminal->stop(); + } +} diff --git a/src/Symfony/Component/Tui/Tests/TuiCascadingStylesheetTest.php b/src/Symfony/Component/Tui/Tests/TuiCascadingStylesheetTest.php new file mode 100644 index 0000000000000..4869e0f05526e --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/TuiCascadingStylesheetTest.php @@ -0,0 +1,131 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Render\Renderer; +use Symfony\Component\Tui\Style\Color; +use Symfony\Component\Tui\Style\Style; +use Symfony\Component\Tui\Style\StyleSheet; +use Symfony\Component\Tui\Widget\TextWidget; + +/** + * Tests for cascading stylesheets integration with Tui. + * + * @author Fabien Potencier + */ +class TuiCascadingStylesheetTest extends TestCase +{ + public function testWithoutUserStylesheet() + { + // When no stylesheets are provided, defaults are used + $renderer = new Renderer(); + + $stylesheet = $renderer->getStyleSheet(); + + // Default stylesheet should resolve a basic widget + $widget = new TextWidget('test'); + $style = $stylesheet->resolve($widget); + // Style should have some defaults from the stylesheet + $this->assertInstanceOf(Style::class, $style); + } + + public function testWithUserStylesheet() + { + // When user stylesheets are provided, they are merged on top of defaults + $userSheet = new StyleSheet() + ->addRule('*', new Style()->withColor('blue')); + + $renderer = new Renderer($userSheet); + + $widget = new TextWidget('test'); + $style = $renderer->getStyleSheet()->resolve($widget); + + // The user's universal color rule is applied + $this->assertSame(Color::named('blue')->toForegroundCode(), $style->getColor()->toForegroundCode()); + } + + public function testUserStylesheetCanOverrideDefaults() + { + // User can override default styles for the same selector + $userSheet = new StyleSheet() + ->addRule(TextWidget::class, new Style()->withColor('red')); + + $renderer = new Renderer($userSheet); + + $widget = new TextWidget('test'); + $style = $renderer->getStyleSheet()->resolve($widget); + + $this->assertSame(Color::named('red')->toForegroundCode(), $style->getColor()->toForegroundCode()); + } + + public function testUserStylesheetAddsNewRules() + { + // User can add rules for selectors not in defaults + $userSheet = new StyleSheet() + ->addRule('.custom-widget', new Style()->withColor('yellow')); + + $renderer = new Renderer($userSheet); + + // Custom rule is available + $widget = new TextWidget('test'); + $widget->addStyleClass('custom-widget'); + $customStyle = $renderer->getStyleSheet()->resolve($widget); + $this->assertSame(Color::named('yellow')->toForegroundCode(), $customStyle->getColor()->toForegroundCode()); + } + + public function testAddStyleSheetAfterConstruction() + { + // addStyleSheet merges user rules on top of defaults + $renderer = new Renderer(); + + $newSheet = new StyleSheet() + ->addRule('*', new Style()->withColor('green')); + + $renderer->addStyleSheet($newSheet); + + $widget = new TextWidget('test'); + $style = $renderer->getStyleSheet()->resolve($widget); + + // Universal color rule applies + $this->assertSame(Color::named('green')->toForegroundCode(), $style->getColor()->toForegroundCode()); + } + + public function testMultipleUserStylesheets() + { + // Multiple user stylesheets are merged in order (last wins) + + // Sheet 1: application theme + $themeSheet = new StyleSheet() + ->addRule('*', new Style()->withColor('red')) + ->addRule('.header', new Style()->withBold()); + + // Sheet 2: user preferences (overrides theme's universal) + $userSheet = new StyleSheet() + ->addRule('*', new Style()->withColor('blue')); + + $renderer = new Renderer($themeSheet); + $renderer->addStyleSheet($userSheet); + + $widget = new TextWidget('test'); + $style = $renderer->getStyleSheet()->resolve($widget); + + // Universal rule from last sheet wins + $this->assertSame(Color::named('blue')->toForegroundCode(), $style->getColor()->toForegroundCode()); + + // .header from theme sheet is still available (different selector) + $headerWidget = new TextWidget('test'); + $headerWidget->addStyleClass('header'); + $headerStyle = $renderer->getStyleSheet()->resolve($headerWidget); + $this->assertTrue($headerStyle->getBold()); + } +} diff --git a/src/Symfony/Component/Tui/Tests/TuiTest.php b/src/Symfony/Component/Tui/Tests/TuiTest.php new file mode 100644 index 0000000000000..2c8606c1a7222 --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/TuiTest.php @@ -0,0 +1,489 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Event\TickEvent; +use Symfony\Component\Tui\Exception\InvalidArgumentException; +use Symfony\Component\Tui\Input\Key; +use Symfony\Component\Tui\Input\Keybindings; +use Symfony\Component\Tui\Render\Renderer; +use Symfony\Component\Tui\Style\Style; +use Symfony\Component\Tui\Style\StyleSheet; +use Symfony\Component\Tui\Terminal\VirtualTerminal; +use Symfony\Component\Tui\Tui; +use Symfony\Component\Tui\Widget\ContainerWidget; +use Symfony\Component\Tui\Widget\InputWidget; +use Symfony\Component\Tui\Widget\TextWidget; + +class TuiTest extends TestCase +{ + public function testBasicRender() + { + $terminal = new VirtualTerminal(40, 10); + $tui = new Tui(terminal: $terminal); + + $tui->add(new TextWidget('Hello World')); + + $tui->start(); + $tui->processRender(); + + $output = $terminal->getOutput(); + $this->assertStringContainsString('Hello World', $output); + } + + public function testMultipleComponents() + { + $terminal = new VirtualTerminal(40, 10); + $tui = new Tui(terminal: $terminal); + + $tui->add(new TextWidget('First')); + $tui->add(new TextWidget('Second')); + + $tui->start(); + $tui->processRender(); + + $output = $terminal->getOutput(); + $this->assertStringContainsString('First', $output); + $this->assertStringContainsString('Second', $output); + } + + public function testContainerOperations() + { + $terminal = new VirtualTerminal(40, 10); + $tui = new Tui(terminal: $terminal); + $tui->start(); + + $text = new TextWidget('Hello'); + $tui->add($text); + $tui->processRender(); + + $this->assertStringContainsString('Hello', $terminal->getOutput()); + + $terminal->clearOutput(); + $tui->remove($text); + $tui->requestRender(); + $tui->processRender(); + + $this->assertStringNotContainsString('Hello', $terminal->getOutput()); + + $tui->stop(); + } + + public function testClear() + { + $terminal = new VirtualTerminal(40, 10); + $tui = new Tui(terminal: $terminal); + $tui->start(); + + $tui->add(new TextWidget('One')); + $tui->add(new TextWidget('Two')); + $tui->processRender(); + + $output = $terminal->getOutput(); + $this->assertStringContainsString('One', $output); + $this->assertStringContainsString('Two', $output); + + $terminal->clearOutput(); + $tui->clear(); + $tui->requestRender(); + $tui->processRender(); + + $output = $terminal->getOutput(); + $this->assertStringNotContainsString('One', $output); + $this->assertStringNotContainsString('Two', $output); + + $tui->stop(); + } + + public function testFocus() + { + $terminal = new VirtualTerminal(40, 10); + $tui = new Tui(terminal: $terminal); + + $text = new TextWidget('Hello'); + $tui->add($text); + + $this->assertNull($tui->getFocus()); + + $tui->setFocus($text); + $this->assertSame($text, $tui->getFocus()); + + $tui->setFocus(null); + $this->assertNull($tui->getFocus()); + } + + public function testRootAutoFocusesFirstFocusable() + { + $terminal = new VirtualTerminal(40, 10); + $tui = new Tui(terminal: $terminal); + + $tui->add(new TextWidget('Hello')); + + $input = new InputWidget(); + $tui->add($input); + + $this->assertSame($input, $tui->getFocus()); + } + + public function testRequestRenderForce() + { + $terminal = new VirtualTerminal(40, 10); + $tui = new Tui(terminal: $terminal); + + $tui->add(new TextWidget('Hello')); + + $tui->start(); + $tui->processRender(); + + // Force re-render should work + $tui->requestRender(true); + $tui->processRender(); + + $this->assertStringContainsString('Hello', $terminal->getOutput()); + } + + public function testTickInvalidationQueuesRenderWithoutExplicitRequest() + { + $terminal = new VirtualTerminal(40, 10); + $tui = new Tui(terminal: $terminal); + $text = new TextWidget('0'); + $ticks = 0; + + $tui->add($text); + $tui->onTick(function () use ($text, &$ticks): void { + if (0 === $ticks) { + $text->setText('1'); + } + ++$ticks; + }); + + $tui->start(); + $tui->processRender(); + $terminal->clearOutput(); + + $tui->tick(); + $this->assertSame('', $terminal->getOutput()); + + $tui->tick(); + $this->assertStringContainsString('1', $terminal->getOutput()); + + $tui->stop(); + } + + public function testOnTickReceivesDeltaTime() + { + $terminal = new VirtualTerminal(40, 10); + $tui = new Tui(terminal: $terminal); + $deltas = []; + + $tui->onTick(function (TickEvent $event) use (&$deltas): void { + $deltas[] = $event->getDeltaTime(); + }); + + $tui->start(); + $tui->tick(); + usleep(2_000); + $tui->tick(); + $tui->stop(); + + $this->assertCount(2, $deltas); + $this->assertSame(0.0, $deltas[0]); + $this->assertGreaterThan(0.0, $deltas[1]); + } + + public function testRenderAllLinesRespectWidth() + { + $renderer = new Renderer(new StyleSheet(['.root' => new Style(gap: 1)])); + + $root = new ContainerWidget(); + $root->addStyleClass('root'); + $root->add(new TextWidget('Short')); + $root->add(new TextWidget('This is a longer text that should wrap properly.')->setStyle(Style::padding([0, 1]))); + $root->add(new TextWidget('End')); + + $lines = $renderer->render($root, 40, 24); + + foreach ($lines as $i => $line) { + $width = AnsiUtils::visibleWidth($line); + $this->assertLessThanOrEqual( + 40, + $width, + \sprintf('Line %d exceeds width: %d', $i, $width), + ); + } + } + + public function testSimulateInput() + { + $terminal = new VirtualTerminal(40, 10); + $tui = new Tui(terminal: $terminal); + + $input = new InputWidget(); + $tui->add($input); + $tui->setFocus($input); + + $tui->start(); + + // Simulate typing + $terminal->simulateInput('H'); + $terminal->simulateInput('i'); + + $this->assertSame('Hi', $input->getValue()); + } + + public function testSimulateInputAfterStop() + { + $terminal = new VirtualTerminal(40, 10); + $tui = new Tui(terminal: $terminal); + + $input = new InputWidget(); + $tui->add($input); + $tui->setFocus($input); + + $tui->start(); + $terminal->simulateInput('A'); + $tui->stop(); + + // After stop, simulateInput should be a no-op + $terminal->simulateInput('B'); + + $this->assertSame('A', $input->getValue()); + } + + public function testSimulateResize() + { + $terminal = new VirtualTerminal(80, 24); + $tui = new Tui(terminal: $terminal); + + $tui->add(new TextWidget('Hello')); + $tui->start(); + + $this->assertSame(80, $terminal->getColumns()); + $this->assertSame(24, $terminal->getRows()); + + // Simulate resize + $terminal->simulateResize(120, 40); + + $this->assertSame(120, $terminal->getColumns()); + $this->assertSame(40, $terminal->getRows()); + } + + public function testDefaultRendersContentAtTop() + { + $terminal = new VirtualTerminal(40, 10); + $tui = new Tui(terminal: $terminal); + + $tui->add(new TextWidget('Header')); + $tui->start(); + $tui->processRender(); + + $output = $terminal->getOutput(); + $lines = explode("\r\n", $output); + $stripped = array_map(fn (string $l) => AnsiUtils::stripAnsiCodes($l), $lines); + + // Content should start at the first line, not be pushed to the bottom + $headerLineIndex = null; + foreach ($stripped as $i => $line) { + if (str_contains($line, 'Header')) { + $headerLineIndex = $i; + break; + } + } + + $this->assertSame(0, $headerLineIndex, 'Content should be on the first line by default (top-aligned)'); + } + + public function testSimulateResizeTriggersRender() + { + $terminal = new VirtualTerminal(40, 10); + $tui = new Tui(terminal: $terminal); + + $tui->add(new TextWidget('Hello')); + $tui->start(); + $tui->processRender(); + + // Clear output to detect new render + $terminal->clearOutput(); + + // Simulate resize - should trigger render request + $terminal->simulateResize(60, 20); + $tui->processRender(); + + $this->assertStringContainsString('Hello', $terminal->getOutput()); + } + + public function testOnInputCanConsumeGlobalInputBeforeFocusedWidget() + { + $terminal = new VirtualTerminal(40, 10); + $tui = new Tui(terminal: $terminal); + + $input = new InputWidget(); + $tui->add($input); + $tui->setFocus($input); + + $called = false; + $globalKeys = new Keybindings(['quit' => [Key::ctrl('c')]]); + $tui->onInput(static function (string $data) use (&$called, $globalKeys): bool { + if (!$globalKeys->matches($data, 'quit')) { + return false; + } + + $called = true; + + return true; + }); + + $tui->handleInput("\x03"); + + $this->assertTrue($called); + $this->assertSame('', $input->getValue()); + } + + public function testOnInputCanPassThroughToFocusedWidget() + { + $terminal = new VirtualTerminal(40, 10); + $tui = new Tui(terminal: $terminal); + + $input = new InputWidget(); + $tui->add($input); + $tui->setFocus($input); + + $globalKeys = new Keybindings(['quit' => [Key::ctrl('c')]]); + $tui->onInput(static fn (string $data): bool => $globalKeys->matches($data, 'quit')); + + $tui->handleInput('a'); + + $this->assertSame('a', $input->getValue()); + } + + public function testOnInputCanConsumeKittyCtrlCSequence() + { + $terminal = new VirtualTerminal(40, 10); + $tui = new Tui(terminal: $terminal); + + $input = new InputWidget(); + $tui->add($input); + $tui->setFocus($input); + + $called = false; + $globalKeys = new Keybindings(['quit' => [Key::ctrl('c')]]); + $tui->onInput(static function (string $data) use (&$called, $globalKeys): bool { + if (!$globalKeys->matches($data, 'quit')) { + return false; + } + + $called = true; + + return true; + }); + + $tui->handleInput("\x1b[99;5u"); + + $this->assertTrue($called); + $this->assertSame('', $input->getValue()); + } + + public function testQuitOnStopsTuiWhenMatchingKeyIsPressed() + { + $terminal = new VirtualTerminal(40, 10); + $tui = new Tui(terminal: $terminal); + + $input = new InputWidget(); + $tui->add($input); + $tui->setFocus($input); + $tui->quitOn('ctrl+c'); + + $tui->start(); + $this->assertTrue($tui->isRunning()); + + $tui->handleInput("\x03"); + + $this->assertFalse($tui->isRunning()); + $this->assertSame('', $input->getValue()); + } + + public function testQuitOnDoesNotConsumeNonMatchingKeys() + { + $terminal = new VirtualTerminal(40, 10); + $tui = new Tui(terminal: $terminal); + + $input = new InputWidget(); + $tui->add($input); + $tui->setFocus($input); + $tui->quitOn('ctrl+c'); + + $tui->handleInput('a'); + + $this->assertSame('a', $input->getValue()); + } + + public function testQuitOnRunsBeforeOnInput() + { + $terminal = new VirtualTerminal(40, 10); + $tui = new Tui(terminal: $terminal); + $tui->quitOn('ctrl+c'); + + $onInputCalled = false; + $tui->onInput(function () use (&$onInputCalled): bool { + $onInputCalled = true; + + return true; + }); + + $tui->start(); + $tui->handleInput("\x03"); + + $this->assertFalse($tui->isRunning()); + $this->assertFalse($onInputCalled); + } + + public function testGetById() + { + $terminal = new VirtualTerminal(40, 10); + $tui = new Tui(terminal: $terminal); + + $text = new TextWidget('Hello'); + $text->setId('greeting'); + $tui->add($text); + + $found = $tui->getById('greeting'); + $this->assertSame($text, $found); + } + + public function testGetByIdThrowsWhenNotFound() + { + $terminal = new VirtualTerminal(40, 10); + $tui = new Tui(terminal: $terminal); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No widget found with id "missing"'); + + $tui->getById('missing'); + } + + public function testGetByIdFindsNestedWidget() + { + $terminal = new VirtualTerminal(40, 10); + $tui = new Tui(terminal: $terminal); + + $container = new ContainerWidget(); + $inner = new TextWidget('Deep'); + $inner->setId('deep'); + $container->add($inner); + $tui->add($container); + + $found = $tui->getById('deep'); + $this->assertSame($inner, $found); + } +} diff --git a/src/Symfony/Component/Tui/Tests/Widget/BracketedPasteTraitTest.php b/src/Symfony/Component/Tui/Tests/Widget/BracketedPasteTraitTest.php new file mode 100644 index 0000000000000..24a560a24ea45 --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Widget/BracketedPasteTraitTest.php @@ -0,0 +1,152 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Widget; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Widget\BracketedPasteTrait; + +class BracketedPasteHandler +{ + use BracketedPasteTrait { + processBracketedPaste as public; + isBufferingPaste as public; + } +} + +class BracketedPasteTraitTest extends TestCase +{ + public function testSingleChunkPaste() + { + $handler = $this->createHandler(); + + $data = "\x1b[200~hello world\x1b[201~"; + $result = $handler->processBracketedPaste($data); + + $this->assertSame('hello world', $result); + $this->assertSame('', $data); + $this->assertFalse($handler->isBufferingPaste()); + } + + public function testMultiChunkPaste() + { + $handler = $this->createHandler(); + + // First chunk: start marker + partial content + $data = "\x1b[200~hello "; + $result = $handler->processBracketedPaste($data); + $this->assertNull($result); + $this->assertSame('', $data); + $this->assertTrue($handler->isBufferingPaste()); + + // Second chunk: more content + $data = 'world'; + $result = $handler->processBracketedPaste($data); + $this->assertNull($result); + $this->assertSame('', $data); + $this->assertTrue($handler->isBufferingPaste()); + + // Third chunk: end marker + $data = "!\x1b[201~"; + $result = $handler->processBracketedPaste($data); + $this->assertSame('hello world!', $result); + $this->assertSame('', $data); + $this->assertFalse($handler->isBufferingPaste()); + } + + public function testDataAfterEndMarkerIsPreserved() + { + $handler = $this->createHandler(); + + $data = "\x1b[200~pasted\x1b[201~extra input"; + $result = $handler->processBracketedPaste($data); + + $this->assertSame('pasted', $result); + $this->assertSame('extra input', $data); + } + + public function testNoPasteMarkers() + { + $handler = $this->createHandler(); + + $data = 'regular input'; + $result = $handler->processBracketedPaste($data); + + $this->assertNull($result); + $this->assertSame('regular input', $data); + $this->assertFalse($handler->isBufferingPaste()); + } + + public function testEmptyPaste() + { + $handler = $this->createHandler(); + + $data = "\x1b[200~\x1b[201~"; + $result = $handler->processBracketedPaste($data); + + $this->assertSame('', $result); + $this->assertSame('', $data); + $this->assertFalse($handler->isBufferingPaste()); + } + + public function testPasteWithNewlines() + { + $handler = $this->createHandler(); + + $data = "\x1b[200~line1\nline2\nline3\x1b[201~"; + $result = $handler->processBracketedPaste($data); + + $this->assertSame("line1\nline2\nline3", $result); + $this->assertSame('', $data); + } + + public function testBufferingClearsDataWhileInPaste() + { + $handler = $this->createHandler(); + + // Start paste + $data = "\x1b[200~partial"; + $result = $handler->processBracketedPaste($data); + $this->assertNull($result); + $this->assertSame('', $data); + + // Still buffering - data should be emptied + $data = ' more content'; + $result = $handler->processBracketedPaste($data); + $this->assertNull($result); + $this->assertSame('', $data); + + // End paste + $data = " end\x1b[201~"; + $result = $handler->processBracketedPaste($data); + $this->assertSame('partial more content end', $result); + } + + public function testConsecutivePastes() + { + $handler = $this->createHandler(); + + // First paste + $data = "\x1b[200~first\x1b[201~"; + $result = $handler->processBracketedPaste($data); + $this->assertSame('first', $result); + + // Second paste + $data = "\x1b[200~second\x1b[201~"; + $result = $handler->processBracketedPaste($data); + $this->assertSame('second', $result); + } + + private function createHandler(): BracketedPasteHandler + { + return new BracketedPasteHandler(); + } +} diff --git a/src/Symfony/Component/Tui/Tests/Widget/CancellableLoaderWidgetTest.php b/src/Symfony/Component/Tui/Tests/Widget/CancellableLoaderWidgetTest.php new file mode 100644 index 0000000000000..48ce99f723896 --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Widget/CancellableLoaderWidgetTest.php @@ -0,0 +1,170 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Widget; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Event\CancelEvent; +use Symfony\Component\Tui\Terminal\VirtualTerminal; +use Symfony\Component\Tui\Tui; +use Symfony\Component\Tui\Widget\CancellableLoaderWidget; + +class CancellableLoaderWidgetTest extends TestCase +{ + public function testCancelViaEscapeKey() + { + [$loader, $tui] = $this->createLoaderWithTui(); + $cancelCalled = false; + + $tui->on(CancelEvent::class, function (CancelEvent $event) use (&$cancelCalled, $loader): void { + $cancelCalled = true; + $this->assertSame($loader, $event->getTarget()); + }); + + // Escape key = \x1b + $loader->handleInput("\x1b"); + + $this->assertTrue($loader->isCancelled()); + $this->assertTrue($cancelCalled); + } + + public function testCancelViaCtrlC() + { + [$loader, $tui] = $this->createLoaderWithTui(); + + $cancelCalled = false; + $tui->on(CancelEvent::class, function () use (&$cancelCalled): void { + $cancelCalled = true; + }); + + // Ctrl+C = \x03 + $loader->handleInput("\x03"); + + $this->assertTrue($loader->isCancelled()); + $this->assertTrue($cancelCalled); + } + + public function testCancelWithoutListener() + { + [$loader, $tui] = $this->createLoaderWithTui(); + + // Should not throw even without any listener + $loader->handleInput("\x1b"); + + $this->assertTrue($loader->isCancelled()); + } + + public function testNonCancelInputDoesNotCancel() + { + $loader = new CancellableLoaderWidget(); + + $loader->handleInput('a'); + + $this->assertFalse($loader->isCancelled()); + } + + public function testReset() + { + [$loader, $tui] = $this->createLoaderWithTui(); + + $loader->handleInput("\x1b"); + $this->assertTrue($loader->isCancelled()); + + $loader->reset(); + $this->assertFalse($loader->isCancelled()); + } + + public function testStartResetsCancelledState() + { + [$loader, $tui] = $this->createLoaderWithTui(); + + $loader->handleInput("\x1b"); + $this->assertTrue($loader->isCancelled()); + + $loader->stop(); + $loader->start(); + + $this->assertFalse($loader->isCancelled()); + $this->assertTrue($loader->isRunning()); + } + + public function testMultipleListenersAllCalled() + { + [$loader, $tui] = $this->createLoaderWithTui(); + $firstCalled = false; + $secondCalled = false; + + $tui->on(CancelEvent::class, function () use (&$firstCalled): void { + $firstCalled = true; + }); + + $tui->on(CancelEvent::class, function () use (&$secondCalled): void { + $secondCalled = true; + }); + + $loader->handleInput("\x1b"); + + $this->assertTrue($firstCalled); + $this->assertTrue($secondCalled); + } + + public function testMultipleCancellationsCallListenerEachTime() + { + [$loader, $tui] = $this->createLoaderWithTui(); + $callCount = 0; + + $tui->on(CancelEvent::class, function () use (&$callCount): void { + ++$callCount; + }); + + $loader->handleInput("\x1b"); + $loader->handleInput("\x1b"); + + $this->assertSame(2, $callCount); + $this->assertTrue($loader->isCancelled()); + } + + public function testListenersRemovedOnDetach() + { + [$loader, $tui] = $this->createLoaderWithTui(); + $callCount = 0; + + $loader->onCancel(function () use (&$callCount): void { + ++$callCount; + }); + + $loader->handleInput("\x1b"); + $this->assertSame(1, $callCount); + + // Remove and re-add the widget (simulates screen transition) + $tui->remove($loader); + + $loader2 = new CancellableLoaderWidget(); + $tui->add($loader2); + + // The old listener should NOT fire for the new widget + $loader2->handleInput("\x1b"); + $this->assertSame(1, $callCount, 'Detached widget listener should not fire'); + } + + /** + * @return array{CancellableLoaderWidget, Tui} + */ + private function createLoaderWithTui(): array + { + $terminal = new VirtualTerminal(80, 24); + $tui = new Tui(terminal: $terminal); + $loader = new CancellableLoaderWidget(); + $tui->add($loader); + + return [$loader, $tui]; + } +} diff --git a/src/Symfony/Component/Tui/Tests/Widget/ContainerTest.php b/src/Symfony/Component/Tui/Tests/Widget/ContainerTest.php new file mode 100644 index 0000000000000..799da49eca828 --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Widget/ContainerTest.php @@ -0,0 +1,605 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Widget; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Render\Renderer; +use Symfony\Component\Tui\Style\Border; +use Symfony\Component\Tui\Style\BorderPattern; +use Symfony\Component\Tui\Style\Color; +use Symfony\Component\Tui\Style\Direction; +use Symfony\Component\Tui\Style\Padding; +use Symfony\Component\Tui\Style\Style; +use Symfony\Component\Tui\Style\StyleSheet; +use Symfony\Component\Tui\Widget\ContainerWidget; +use Symfony\Component\Tui\Widget\TextWidget; + +class ContainerTest extends TestCase +{ + public function testRender() + { + $container = new ContainerWidget(); + $container->add(new TextWidget('First')); + $container->add(new TextWidget('Second')); + + $lines = $this->renderContainer($container); + + $content = implode("\n", $lines); + $this->assertStringContainsString('First', $content); + $this->assertStringContainsString('Second', $content); + } + + public function testRenderEmpty() + { + $container = new ContainerWidget(); + $lines = $this->renderContainer($container); + + $this->assertSame([], $lines); + } + + public function testRenderEmptyWithBorder() + { + $container = new ContainerWidget(); + $container->setStyle(new Style(border: Border::all(1))); + + $lines = $this->renderContainer($container); + + $stripped = array_map(fn ($l) => AnsiUtils::stripAnsiCodes($l), $lines); + // Should have at least a top and bottom border line + $this->assertGreaterThanOrEqual(2, \count($stripped)); + $this->assertStringContainsString('┌', $stripped[0]); + $this->assertStringContainsString('└', $stripped[\count($stripped) - 1]); + } + + public function testRenderEmptyWithPadding() + { + $container = new ContainerWidget(); + $container->setStyle(new Style(padding: new Padding(1, 0, 1, 0))); + + $lines = $this->renderContainer($container); + + // An empty container with vertical padding should still render padding lines + $this->assertCount(2, $lines); + } + + public function testRenderAllChildrenHiddenWithBorder() + { + $container = new ContainerWidget(); + $container->setStyle(new Style(border: Border::all(1))); + $container->add(new TextWidget('Hidden')->setStyle(new Style(hidden: true))); + + $lines = $this->renderContainer($container); + + $stripped = array_map(fn ($l) => AnsiUtils::stripAnsiCodes($l), $lines); + $this->assertStringContainsString('┌', $stripped[0]); + $this->assertStringContainsString('└', $stripped[\count($stripped) - 1]); + } + + public function testRenderWithGap() + { + $container = new ContainerWidget(); + $container->setStyle(new Style(gap: 2)); + $container->add(new TextWidget('Top')); + $container->add(new TextWidget('Bottom')); + + $lines = $this->renderContainer($container); + + // Should have at least 4 lines (Top + 2 gap + Bottom) + $this->assertGreaterThanOrEqual(4, \count($lines)); + } + + public function testNestedContainers() + { + $outer = new ContainerWidget(); + $inner = new ContainerWidget(); + + $inner->add(new TextWidget('Nested')); + $outer->add(new TextWidget('Outer')); + $outer->add($inner); + + $lines = $this->renderContainer($outer); + $content = implode("\n", $lines); + + $this->assertStringContainsString('Outer', $content); + $this->assertStringContainsString('Nested', $content); + } + + public function testGapBetweenChildren() + { + $container = new ContainerWidget()->setStyle(new Style(gap: 2)); + $container->add(new TextWidget('First')); + $container->add(new TextWidget('Second')); + $container->add(new TextWidget('Third')); + + $lines = $this->renderContainer($container); + + // With gap=2, we expect: First, 2 empty lines, Second, 2 empty lines, Third + // That's 3 text lines + 4 gap lines = 7 total (assuming each Text renders 1 line) + $this->assertCount(7, $lines); + $this->assertStringContainsString('First', $lines[0]); + $this->assertSame('', trim($lines[1])); + $this->assertSame('', trim($lines[2])); + $this->assertStringContainsString('Second', $lines[3]); + $this->assertSame('', trim($lines[4])); + $this->assertSame('', trim($lines[5])); + $this->assertStringContainsString('Third', $lines[6]); + } + + public function testGapWithSingleChild() + { + $container = new ContainerWidget()->setStyle(new Style(gap: 2)); + $container->add(new TextWidget('Only')); + + $lines = $this->renderContainer($container); + + // With single child, no gap should be added + $this->assertCount(1, $lines); + $this->assertStringContainsString('Only', $lines[0]); + } + + public function testGapWithNoChildren() + { + $container = new ContainerWidget()->setStyle(new Style(gap: 2)); + $lines = $this->renderContainer($container); + + $this->assertSame([], $lines); + } + + public function testHorizontalDirectionRendersSingleLine() + { + $container = new ContainerWidget()->setStyle(new Style(direction: Direction::Horizontal)); + $container->add(new TextWidget('Left')); + $container->add(new TextWidget('Right')); + + $lines = $this->renderContainer($container); + + $this->assertCount(1, $lines); + $this->assertStringContainsString('Left', $lines[0]); + $this->assertStringContainsString('Right', $lines[0]); + } + + public function testHorizontalDirectionWithGap() + { + $container = new ContainerWidget()->setStyle(new Style(direction: Direction::Horizontal, gap: 2)); + $container->add(new TextWidget('Left')); + $container->add(new TextWidget('Right')); + + $lines = $this->renderContainer($container); + + $this->assertCount(1, $lines); + $line = AnsiUtils::stripAnsiCodes($lines[0]); + $this->assertMatchesRegularExpression('/Left\s{2,}Right/', $line); + } + + public function testStyleWithPadding() + { + // [1, 2] = top/bottom: 1, left/right: 2 + $container = new ContainerWidget()->setStyle(Style::padding([1, 2])); + $container->add(new TextWidget('Hello')); + + $lines = $this->renderContainer($container); + + // Should have: 1 top padding + 1 content + 1 bottom padding = 3 lines + $this->assertCount(3, $lines); + // Content line should contain 'Hello' + $this->assertStringContainsString('Hello', $lines[1]); + } + + public function testStyleWithBackground() + { + $container = new ContainerWidget()->setStyle(Style::padding([0, 1])->withBackground('blue')); + $container->add(new TextWidget('Hi')); + + $lines = $this->renderContainer($container, 20); + + // Should contain background color code + $this->assertStringContainsString("\x1b[44m", $lines[0]); + } + + public function testStyleWithGap() + { + $container = new ContainerWidget()->setStyle(Style::padding([0])->withGap(1)); + $container->add(new TextWidget('First')); + $container->add(new TextWidget('Second')); + + $lines = $this->renderContainer($container); + + // Should have: First, 1 gap line, Second = 3 lines + $this->assertCount(3, $lines); + $this->assertStringContainsString('First', $lines[0]); + $this->assertSame('', trim($lines[1])); + $this->assertStringContainsString('Second', $lines[2]); + } + + public function testRenderWithinWidth() + { + // [1, 2] = top/bottom: 1, left/right: 2 + $container = new ContainerWidget()->setStyle(Style::padding([1, 2])); + $container->add(new TextWidget('This is some longer text that should wrap')); + + $width = 30; + $lines = $this->renderContainer($container, $width); + + foreach ($lines as $i => $line) { + $lineWidth = AnsiUtils::visibleWidth($line); + $this->assertLessThanOrEqual( + $width, + $lineWidth, + \sprintf('Line %d exceeds width: %d > %d', $i, $lineWidth, $width), + ); + } + } + + public function testGapViaStylesheet() + { + $container = new ContainerWidget()->addStyleClass('spaced'); + $container->add(new TextWidget('First')); + $container->add(new TextWidget('Second')); + + $root = new ContainerWidget(); + $root->expandVertically(true); + $root->add($container); + + $stylesheet = new StyleSheet([ + '.spaced' => new Style(gap: 2), + ]); + $renderer = new Renderer($stylesheet); + $lines = $renderer->render($root, 40, 24); + + // With gap=2: First, 2 empty lines, Second = 4 total + $this->assertCount(4, $lines); + $this->assertStringContainsString('First', $lines[0]); + $this->assertStringContainsString('Second', $lines[3]); + } + + public function testDirectionViaStylesheet() + { + $container = new ContainerWidget()->addStyleClass('horizontal'); + $container->add(new TextWidget('Left')); + $container->add(new TextWidget('Right')); + + $root = new ContainerWidget(); + $root->expandVertically(true); + $root->add($container); + + $stylesheet = new StyleSheet([ + '.horizontal' => new Style(direction: Direction::Horizontal), + ]); + $renderer = new Renderer($stylesheet); + $lines = $renderer->render($root, 40, 24); + + $this->assertCount(1, $lines); + $this->assertStringContainsString('Left', $lines[0]); + $this->assertStringContainsString('Right', $lines[0]); + } + + public function testResponsiveDirectionViaBreakpoint() + { + $container = new ContainerWidget()->addStyleClass('panes'); + $container->add(new TextWidget('A')); + $container->add(new TextWidget('B')); + + $root = new ContainerWidget(); + $root->expandVertically(true); + $root->add($container); + + $stylesheet = new StyleSheet([ + '.panes' => new Style(direction: Direction::Vertical), + ]); + $stylesheet->addBreakpoint(100, '.panes', new Style(direction: Direction::Horizontal)); + + $renderer = new Renderer($stylesheet); + + // Narrow terminal: vertical (2 lines) + $lines = $renderer->render($root, 60, 24); + $this->assertCount(2, $lines); + $this->assertStringContainsString('A', $lines[0]); + $this->assertStringContainsString('B', $lines[1]); + + // Wide terminal: horizontal (1 line) + $lines = $renderer->render($root, 120, 24); + $this->assertCount(1, $lines); + $this->assertStringContainsString('A', $lines[0]); + $this->assertStringContainsString('B', $lines[0]); + } + + /** + * @return iterable + */ + public static function hiddenWidgetProvider(): iterable + { + yield 'via instance style' => [null]; + yield 'via stylesheet' => [new StyleSheet(['.hide-me' => new Style(hidden: true)])]; + } + + #[DataProvider('hiddenWidgetProvider')] + public function testHiddenWidgetIsNotRendered(?StyleSheet $stylesheet) + { + $container = new ContainerWidget(); + $container->add(new TextWidget('Visible')); + $hiddenWidget = new TextWidget('Hidden'); + if (null !== $stylesheet) { + $hiddenWidget->addStyleClass('hide-me'); + } else { + $hiddenWidget->setStyle(new Style(hidden: true)); + } + $container->add($hiddenWidget); + $container->add(new TextWidget('Also Visible')); + + $root = new ContainerWidget(); + $root->expandVertically(true); + $root->add($container); + + $renderer = new Renderer($stylesheet); + $lines = $renderer->render($root, 40, 24); + + $text = implode("\n", array_map(fn ($l) => AnsiUtils::stripAnsiCodes($l), $lines)); + $this->assertStringContainsString('Visible', $text); + $this->assertStringNotContainsString('Hidden', $text); + $this->assertStringContainsString('Also Visible', $text); + } + + public function testHiddenWidgetTakesNoVerticalSpace() + { + $container = new ContainerWidget()->setStyle(new Style(gap: 1)); + $container->add(new TextWidget('A')); + $container->add(new TextWidget('B')->setStyle(new Style(hidden: true))); + $container->add(new TextWidget('C')); + + $root = new ContainerWidget(); + $root->expandVertically(true); + $root->add($container); + + $renderer = new Renderer(); + $lines = $renderer->render($root, 40, 24); + + // A + gap + C = 3 lines (no gap for the hidden widget) + $this->assertCount(3, $lines); + $this->assertStringContainsString('A', AnsiUtils::stripAnsiCodes($lines[0])); + $this->assertStringContainsString('C', AnsiUtils::stripAnsiCodes($lines[2])); + } + + public function testHiddenWidgetTakesNoHorizontalSpace() + { + $container = new ContainerWidget()->setStyle(new Style(direction: Direction::Horizontal)); + $container->add(new TextWidget('Left')); + $container->add(new TextWidget('Middle')->setStyle(new Style(hidden: true))); + $container->add(new TextWidget('Right')); + + $root = new ContainerWidget(); + $root->expandVertically(true); + $root->add($container); + + $renderer = new Renderer(); + $lines = $renderer->render($root, 40, 24); + + // Hidden widget should not take column space: the visible widgets should share the full width + $this->assertCount(1, $lines); + $stripped = AnsiUtils::stripAnsiCodes($lines[0]); + $this->assertStringContainsString('Left', $stripped); + $this->assertStringNotContainsString('Middle', $stripped); + $this->assertStringContainsString('Right', $stripped); + + // Each visible child gets 20 columns (40/2) + $leftPos = mb_strpos($stripped, 'Left'); + $rightPos = mb_strpos($stripped, 'Right'); + $this->assertSame(0, $leftPos); + $this->assertSame(20, $rightPos); + } + + public function testHiddenWidgetViaBreakpoint() + { + $container = new ContainerWidget(); + $container->add(new TextWidget('Always')); + $container->add(new TextWidget('Mobile Only')->addStyleClass('mobile-hint')); + + $root = new ContainerWidget(); + $root->expandVertically(true); + $root->add($container); + + $stylesheet = new StyleSheet(); + // Hide the hint on wide terminals + $stylesheet->addBreakpoint(100, '.mobile-hint', new Style(hidden: true)); + + $renderer = new Renderer($stylesheet); + + // Narrow: both visible + $lines = $renderer->render($root, 60, 24); + $text = implode("\n", array_map(fn ($l) => AnsiUtils::stripAnsiCodes($l), $lines)); + $this->assertStringContainsString('Always', $text); + $this->assertStringContainsString('Mobile Only', $text); + + // Wide: hint hidden + $lines = $renderer->render($root, 120, 24); + $text = implode("\n", array_map(fn ($l) => AnsiUtils::stripAnsiCodes($l), $lines)); + $this->assertStringContainsString('Always', $text); + $this->assertStringNotContainsString('Mobile Only', $text); + } + + public function testHiddenFalseOverridesInheritedHidden() + { + $container = new ContainerWidget(); + $container->add(new TextWidget('Visible')->addStyleClass('item')->setStyle(new Style(hidden: false))); + + $root = new ContainerWidget(); + $root->expandVertically(true); + $root->add($container); + + $stylesheet = new StyleSheet([ + '.item' => new Style(hidden: true), + ]); + $renderer = new Renderer($stylesheet); + $lines = $renderer->render($root, 40, 24); + + $text = implode("\n", array_map(fn ($l) => AnsiUtils::stripAnsiCodes($l), $lines)); + $this->assertStringContainsString('Visible', $text); + } + + public function testHiddenContainerHidesAllChildren() + { + $inner = new ContainerWidget(); + $inner->add(new TextWidget('Child A')); + $inner->add(new TextWidget('Child B')); + $inner->setStyle(new Style(hidden: true)); + + $container = new ContainerWidget(); + $container->add(new TextWidget('Before')); + $container->add($inner); + $container->add(new TextWidget('After')); + + $root = new ContainerWidget(); + $root->expandVertically(true); + $root->add($container); + + $renderer = new Renderer(); + $lines = $renderer->render($root, 40, 24); + + $text = implode("\n", array_map(fn ($l) => AnsiUtils::stripAnsiCodes($l), $lines)); + $this->assertStringContainsString('Before', $text); + $this->assertStringNotContainsString('Child A', $text); + $this->assertStringNotContainsString('Child B', $text); + $this->assertStringContainsString('After', $text); + } + + public function testBeforeRenderIsCalledByRenderer() + { + $text = new TextWidget('initial'); + $container = new BeforeRenderTestContainer(); + $container->targetText = $text; + $container->add($text); + + $lines = $this->renderContainer($container); + + $content = implode("\n", $lines); + // beforeRender() should have updated the text before rendering + $this->assertStringContainsString('updated by beforeRender', $content); + $this->assertStringNotContainsString('initial', $content); + } + + public function testChildrenStylesAppliedByRenderer() + { + // Child has padding: the Renderer should apply it as chrome + $child = new TextWidget('Padded'); + $child->setStyle(new Style(padding: new Padding(0, 0, 0, 4))); + + $container = new ContainerWidget(); + $container->add($child); + + $lines = $this->renderContainer($container); + + // The text should be indented by 4 characters (left padding) + $content = implode("\n", array_map(fn ($l) => AnsiUtils::stripAnsiCodes($l), $lines)); + $this->assertStringContainsString(' Padded', $content); + } + + public function testBorderOuterStyleInheritsFromGrandparent() + { + // Grandparent has a green background; intermediate container has no + // background (only gap). The border of the leaf widget should use + // the green background from the grandparent as the outer style, + // so border characters reset to green (42) rather than default (49). + $innerStyle = new Style() + ->withBackground(Color::from('black')) + ->withBorder([1], BorderPattern::fromName(BorderPattern::NORMAL)) + ; + + $child = new TextWidget('Hello'); + $child->setStyle($innerStyle); + + // Intermediate container with gap only (no color/background) + $middle = new ContainerWidget(); + $middle->setStyle(new Style(gap: 1)); + $middle->add($child); + + // Outer container with green background + $outer = new ContainerWidget(); + $outer->setStyle(new Style()->withBackground(Color::from('green'))); + $outer->add($middle); + + $root = new ContainerWidget(); + $root->expandVertically(true); + $root->add($outer); + + $renderer = new Renderer(); + $lines = $renderer->render($root, 30, 10); + + $borderLine = $lines[0]; + + // After the border corner "┌" with black bg (\e[40m), the border should + // reset to green bg (\e[42m); not default bg (\e[49m); because the + // outer style inherits the green background from the grandparent + $this->assertStringContainsString("\x1b[40m┌\x1b[39m\x1b[42m", $borderLine, 'Border should reset to green (grandparent) background, not default'); + $this->assertStringNotContainsString("\x1b[40m┌\x1b[39m\x1b[49m", $borderLine, 'Border should not reset to default background'); + } + + public function testContainerWithGapAndBeforeRender() + { + $text1 = new TextWidget('First'); + $text2 = new TextWidget(''); + $container = new BeforeRenderTestContainer(); + $container->targetText = $text2; + $container->setStyle(new Style(gap: 1)); + $container->add($text1); + $container->add($text2); + + $lines = $this->renderContainer($container, 40); + + $stripped = array_map(fn ($l) => AnsiUtils::stripAnsiCodes($l), $lines); + + // First and "updated" text should both be present with a gap between them + $firstIdx = null; + $updatedIdx = null; + for ($i = 0; $i < \count($stripped); ++$i) { + if (str_contains($stripped[$i], 'First')) { + $firstIdx = $i; + } + if (str_contains($stripped[$i], 'updated by beforeRender')) { + $updatedIdx = $i; + } + } + + // Gap of 1 means there should be exactly 1 line between them + $this->assertSame(2, $updatedIdx - $firstIdx, 'Gap of 1 should produce 1 blank line between children'); + } + + /** + * @return string[] + */ + private function renderContainer(ContainerWidget $container, int $columns = 40, int $rows = 24): array + { + $root = new ContainerWidget(); + $root->expandVertically(true); + $root->add($container); + $renderer = new Renderer(); + + return $renderer->render($root, $columns, $rows); + } +} + +/** + * Test helper: a ContainerWidget subclass that updates child state in beforeRender(). + * + * @internal + */ +class BeforeRenderTestContainer extends ContainerWidget +{ + public ?TextWidget $targetText = null; + + public function beforeRender(): void + { + if (null !== $this->targetText) { + $this->targetText->setText('updated by beforeRender'); + } + } +} diff --git a/src/Symfony/Component/Tui/Tests/Widget/Editor/EditorDocumentTest.php b/src/Symfony/Component/Tui/Tests/Widget/Editor/EditorDocumentTest.php new file mode 100644 index 0000000000000..6e145a27451d7 --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Widget/Editor/EditorDocumentTest.php @@ -0,0 +1,531 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Widget\Editor; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Widget\Editor\EditorDocument; + +class EditorDocumentTest extends TestCase +{ + // --- Text Operations --- + + public function testSetTextReturnsWhetherStateChanged() + { + $doc = new EditorDocument(); + + $this->assertTrue($doc->setText('hello'), 'setText should return true when text changes'); + $this->assertFalse($doc->setText('hello'), 'setText on identical text+cursor should return false'); + } + + public function testSetTextNormalizesLineEndings() + { + $doc = new EditorDocument(); + $doc->setText("a\r\nb\rc"); + + $this->assertSame("a\nb\nc", $doc->getText()); + } + + public function testSetTextResetsCursorPosition() + { + $doc = new EditorDocument(); + $doc->setText("line 1\nline 2\nline 3"); + $doc->moveCursorDown(); + $doc->moveCursorDown(); + + $doc->setText('new'); + + $this->assertSame(0, $doc->getCursorLine()); + $this->assertSame(0, $doc->getCursorCol()); + } + + public function testSetTextClearsUndoRedoStacks() + { + $doc = new EditorDocument(); + $doc->insertText('A'); + $doc->insertText('B'); + $doc->undo(); + + $doc->setText('new'); + + $this->assertFalse($doc->undo(), 'undo stack should be cleared after setText'); + $this->assertFalse($doc->redo(), 'redo stack should be cleared after setText'); + } + + public function testInsertText() + { + $doc = new EditorDocument(); + $doc->insertText('Hello'); + + $this->assertSame('Hello', $doc->getText()); + $this->assertSame(5, $doc->getCursorCol()); + } + + public function testInsertTextMultiline() + { + $doc = new EditorDocument(); + $doc->insertText("Hello\nWorld"); + + $this->assertSame("Hello\nWorld", $doc->getText()); + $this->assertSame(1, $doc->getCursorLine()); + $this->assertSame(5, $doc->getCursorCol()); + } + + public function testInsertNewLine() + { + $doc = new EditorDocument(); + $doc->insertText('Hello'); + $doc->insertNewLine(); + $doc->insertText('World'); + + $this->assertSame("Hello\nWorld", $doc->getText()); + $this->assertSame(1, $doc->getCursorLine()); + } + + public function testInsertNewLineSplitsAtCursor() + { + $doc = new EditorDocument(); + $doc->insertText('HelloWorld'); + // Move cursor to position 5 (after "Hello") + $doc->moveToLineStart(); + for ($i = 0; $i < 5; ++$i) { + $doc->moveCursorRight(); + } + $doc->insertNewLine(); + + $this->assertSame("Hello\nWorld", $doc->getText()); + } + + // --- Deletion --- + + public function testDeleteCharBackward() + { + $doc = new EditorDocument(); + $doc->insertText('Hello'); + + $this->assertTrue($doc->deleteCharBackward()); + $this->assertSame('Hell', $doc->getText()); + } + + public function testDeleteCharBackwardAtStartOfLine() + { + $doc = new EditorDocument(); + $doc->setText("Hello\nWorld"); + $doc->moveCursorDown(); + $doc->moveToLineStart(); + + $this->assertTrue($doc->deleteCharBackward()); + $this->assertSame('HelloWorld', $doc->getText()); + } + + public function testDeleteCharBackwardAtStartOfDocument() + { + $doc = new EditorDocument(); + $doc->setText('Hello'); + $doc->moveToLineStart(); + + $this->assertFalse($doc->deleteCharBackward()); + $this->assertSame('Hello', $doc->getText()); + } + + public function testDeleteCharForward() + { + $doc = new EditorDocument(); + $doc->setText('Hello'); + $doc->moveToLineStart(); + + $this->assertTrue($doc->deleteCharForward()); + $this->assertSame('ello', $doc->getText()); + } + + public function testDeleteCharForwardMergesLines() + { + $doc = new EditorDocument(); + $doc->setText("Hello\nWorld"); + $doc->moveToLineEnd(); + + $this->assertTrue($doc->deleteCharForward()); + $this->assertSame('HelloWorld', $doc->getText()); + } + + public function testDeleteCharForwardAtEndOfDocument() + { + $doc = new EditorDocument(); + $doc->setText('Hello'); + $doc->moveToLineEnd(); + + $this->assertFalse($doc->deleteCharForward()); + } + + public function testDeleteLine() + { + $doc = new EditorDocument(); + $doc->setText("Line 1\nLine 2\nLine 3"); + $doc->moveCursorDown(); + + $this->assertTrue($doc->deleteLine()); + $this->assertSame("Line 1\nLine 3", $doc->getText()); + } + + public function testDeleteLineSingleEmptyLine() + { + $doc = new EditorDocument(); + + $this->assertFalse($doc->deleteLine()); + $this->assertSame('', $doc->getText()); + } + + public function testDeleteToLineEnd() + { + $doc = new EditorDocument(); + $doc->setText('Hello World'); + $doc->moveToLineStart(); + for ($i = 0; $i < 5; ++$i) { + $doc->moveCursorRight(); + } + + $this->assertTrue($doc->deleteToLineEnd()); + $this->assertSame('Hello', $doc->getText()); + } + + public function testDeleteToLineEndAtEnd() + { + $doc = new EditorDocument(); + $doc->setText('Hello'); + $doc->moveToLineEnd(); + + $this->assertFalse($doc->deleteToLineEnd()); + } + + public function testDeleteToLineStart() + { + $doc = new EditorDocument(); + $doc->setText('Hello World'); + $doc->moveToLineEnd(); + + $this->assertTrue($doc->deleteToLineStart()); + $this->assertSame('', $doc->getText()); + } + + public function testDeleteToLineStartAtStart() + { + $doc = new EditorDocument(); + $doc->setText('Hello'); + $doc->moveToLineStart(); + + $this->assertFalse($doc->deleteToLineStart()); + } + + public function testDeleteWordBackward() + { + $doc = new EditorDocument(); + $doc->setText('hello world'); + $doc->moveToLineEnd(); + + $this->assertTrue($doc->deleteWordBackward()); + $this->assertSame('hello ', $doc->getText()); + } + + public function testDeleteWordForward() + { + $doc = new EditorDocument(); + $doc->setText('hello world'); + $doc->moveToLineStart(); + + $this->assertTrue($doc->deleteWordForward()); + $this->assertSame(' world', $doc->getText()); + } + + // --- Cursor Navigation --- + + public function testMoveCursorUpDown() + { + $doc = new EditorDocument(); + $doc->setText("Line 1\nLine 2\nLine 3"); + + $this->assertTrue($doc->moveCursorDown()); + $this->assertSame(1, $doc->getCursorLine()); + + $this->assertTrue($doc->moveCursorDown()); + $this->assertSame(2, $doc->getCursorLine()); + + $this->assertFalse($doc->moveCursorDown()); + + $this->assertTrue($doc->moveCursorUp()); + $this->assertSame(1, $doc->getCursorLine()); + + $this->assertTrue($doc->moveCursorUp()); + $this->assertSame(0, $doc->getCursorLine()); + + $this->assertFalse($doc->moveCursorUp()); + } + + public function testMoveCursorLeftRight() + { + $doc = new EditorDocument(); + $doc->setText('Hi'); + $doc->moveToLineStart(); + + $this->assertTrue($doc->moveCursorRight()); + $this->assertSame(1, $doc->getCursorCol()); + + $this->assertTrue($doc->moveCursorRight()); + $this->assertSame(2, $doc->getCursorCol()); + + // At end of line but not last line; would cross if there were another line + $this->assertFalse($doc->moveCursorRight()); + + $this->assertTrue($doc->moveCursorLeft()); + $this->assertSame(1, $doc->getCursorCol()); + } + + public function testMoveCursorLeftCrossesLineBoundary() + { + $doc = new EditorDocument(); + $doc->setText("AB\nCD"); + $doc->moveCursorDown(); + $doc->moveToLineStart(); + + $this->assertTrue($doc->moveCursorLeft()); + $this->assertSame(0, $doc->getCursorLine()); + $this->assertSame(2, $doc->getCursorCol()); + } + + public function testMoveCursorRightCrossesLineBoundary() + { + $doc = new EditorDocument(); + $doc->setText("AB\nCD"); + $doc->moveToLineEnd(); + + $this->assertTrue($doc->moveCursorRight()); + $this->assertSame(1, $doc->getCursorLine()); + $this->assertSame(0, $doc->getCursorCol()); + } + + public function testMoveToLineStartEnd() + { + $doc = new EditorDocument(); + $doc->setText('Hello'); + $doc->moveToLineEnd(); + $this->assertSame(5, $doc->getCursorCol()); + + $this->assertTrue($doc->moveToLineStart()); + $this->assertSame(0, $doc->getCursorCol()); + + $this->assertFalse($doc->moveToLineStart()); + + $this->assertTrue($doc->moveToLineEnd()); + $this->assertFalse($doc->moveToLineEnd()); + } + + public function testMoveWordBackwards() + { + $doc = new EditorDocument(); + $doc->setText('hello world'); + $doc->moveToLineEnd(); + + $this->assertTrue($doc->moveWordBackwards()); + $this->assertSame(6, $doc->getCursorCol()); + } + + public function testMoveWordForwards() + { + $doc = new EditorDocument(); + $doc->setText('hello world'); + $doc->moveToLineStart(); + + $this->assertTrue($doc->moveWordForwards()); + $this->assertSame(5, $doc->getCursorCol()); + } + + public function testIsOnFirstLastLine() + { + $doc = new EditorDocument(); + $doc->setText("A\nB\nC"); + + $this->assertTrue($doc->isOnFirstLine()); + $this->assertFalse($doc->isOnLastLine()); + + $doc->moveCursorDown(); + $this->assertFalse($doc->isOnFirstLine()); + $this->assertFalse($doc->isOnLastLine()); + + $doc->moveCursorDown(); + $this->assertFalse($doc->isOnFirstLine()); + $this->assertTrue($doc->isOnLastLine()); + } + + // --- Jump To Character --- + + #[DataProvider('jumpToCharProvider')] + public function testJumpToChar(string $text, string $char, string $direction, int $expectedLine, int $expectedCol, bool $expectedResult) + { + $doc = new EditorDocument(); + $doc->setText($text); + $doc->moveToLineStart(); + + $result = $doc->jumpToChar($char, $direction); + + $this->assertSame($expectedResult, $result); + if ($expectedResult) { + $this->assertSame($expectedLine, $doc->getCursorLine()); + $this->assertSame($expectedCol, $doc->getCursorCol()); + } + } + + /** + * @return iterable + */ + public static function jumpToCharProvider(): iterable + { + yield 'forward to char' => ['Hello World', 'W', 'forward', 0, 6, true]; + yield 'forward skips current position' => ['aabaa', 'a', 'forward', 0, 1, true]; + yield 'forward across lines' => ["Hello\nWorld", 'W', 'forward', 1, 0, true]; + yield 'no match' => ['Hello', 'Z', 'forward', 0, 0, false]; + } + + // --- Undo/Redo --- + + public function testUndoRedo() + { + $doc = new EditorDocument(); + $doc->insertText('A'); + $doc->insertText('B'); + $doc->insertText('C'); + + $this->assertSame('ABC', $doc->getText()); + + $this->assertTrue($doc->undo()); + $this->assertSame('AB', $doc->getText()); + + $this->assertTrue($doc->undo()); + $this->assertSame('A', $doc->getText()); + + $this->assertTrue($doc->redo()); + $this->assertSame('AB', $doc->getText()); + + $this->assertTrue($doc->redo()); + $this->assertSame('ABC', $doc->getText()); + + $this->assertFalse($doc->redo()); + } + + public function testUndoOnEmptyStack() + { + $doc = new EditorDocument(); + + $this->assertFalse($doc->undo()); + } + + public function testNewEditClearsRedoStack() + { + $doc = new EditorDocument(); + $doc->insertText('A'); + $doc->insertText('B'); + $doc->undo(); + $doc->insertText('C'); + + $this->assertSame('AC', $doc->getText()); + $this->assertFalse($doc->redo()); + } + + // --- Kill Ring --- + + public function testYankAndYankPop() + { + $doc = new EditorDocument(); + $doc->insertText('Hello World'); + + // Delete to line end (from start) + $doc->moveToLineStart(); + $doc->deleteToLineEnd(); + $this->assertSame('', $doc->getText()); + + // Yank it back + $this->assertTrue($doc->yank()); + $this->assertSame('Hello World', $doc->getText()); + } + + public function testYankWithEmptyKillRing() + { + $doc = new EditorDocument(); + + $this->assertFalse($doc->yank()); + } + + public function testYankPopWithNoYank() + { + $doc = new EditorDocument(); + + $this->assertFalse($doc->yankPop()); + } + + // --- Paste Handling --- + + public function testSmallPasteDoesNotCreateMarker() + { + $doc = new EditorDocument(); + $doc->handlePaste("line 1\nline 2\nline 3"); + + $this->assertSame("line 1\nline 2\nline 3", $doc->getText()); + $this->assertSame([], $doc->getPasteMarkers()); + } + + public function testLargePasteCreatesMarker() + { + $doc = new EditorDocument(); + $content = implode("\n", array_map(fn ($i) => "line $i", range(1, 15))); + $doc->handlePaste($content); + + $markers = $doc->getPasteMarkers(); + $this->assertCount(1, $markers); + $this->assertSame($content, $markers[0]['content']); + + // getText() expands the marker + $this->assertSame($content, $doc->getText()); + } + + public function testSetTextClearsPasteMarkers() + { + $doc = new EditorDocument(); + $content = implode("\n", array_map(fn ($i) => "line $i", range(1, 15))); + $doc->handlePaste($content); + $this->assertNotSame([], $doc->getPasteMarkers()); + + $doc->setText('new'); + $this->assertSame([], $doc->getPasteMarkers()); + } + + // --- UTF-8 --- + + #[DataProvider('utf8DeletionProvider')] + public function testUtf8BackwardDeletion(string $input, string $expected) + { + $doc = new EditorDocument(); + $doc->setText($input); + $doc->moveToLineEnd(); + $doc->deleteCharBackward(); + + $this->assertSame($expected, $doc->getText()); + } + + /** + * @return iterable + */ + public static function utf8DeletionProvider(): iterable + { + yield 'Latin accent' => ['café', 'caf']; + yield 'Emoji' => ['hello👋', 'hello']; + yield 'Japanese' => ['日本', '日']; + yield 'CJK' => ['中文', '中']; + } +} diff --git a/src/Symfony/Component/Tui/Tests/Widget/Editor/EditorRendererTest.php b/src/Symfony/Component/Tui/Tests/Widget/Editor/EditorRendererTest.php new file mode 100644 index 0000000000000..d08b2e4971b01 --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Widget/Editor/EditorRendererTest.php @@ -0,0 +1,167 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Widget\Editor; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Style\CursorShape; +use Symfony\Component\Tui\Style\Style; +use Symfony\Component\Tui\Widget\Editor\EditorRenderer; + +class EditorRendererTest extends TestCase +{ + private EditorRenderer $renderer; + + protected function setUp(): void + { + $this->renderer = new EditorRenderer(); + } + + public function testRenderEmptyDocument() + { + $lines = $this->renderSimple([''], 0, 0, 40, 10); + + // Top border + 1 content line + bottom border + $this->assertCount(3, $lines); + } + + public function testRenderMultipleLines() + { + $lines = $this->renderSimple(['Line 1', 'Line 2', 'Line 3'], 0, 0, 40, 10); + + // Top border + 3 content lines + bottom border + $this->assertCount(5, $lines); + } + + public function testRenderLinesDoNotExceedWidth() + { + $width = 30; + $lines = $this->renderSimple(['Hello World', 'Second line'], 0, 5, $width, 10); + + foreach ($lines as $i => $line) { + $lineWidth = AnsiUtils::visibleWidth($line); + $this->assertLessThanOrEqual( + $width, + $lineWidth, + \sprintf('Line %d exceeds width: %d > %d', $i, $lineWidth, $width), + ); + } + } + + public function testRenderScrollIndicatorAbove() + { + $lines = $this->renderWithViewport( + ['Line 0', 'Line 1', 'Line 2'], + ['scrollOffset' => 1, 'visibleLineCount' => 2, 'linesAbove' => 1, 'linesBelow' => 0], + 1, 0, 40, 10, + ); + + $topBorder = AnsiUtils::stripAnsiCodes($lines[0]); + $this->assertStringContainsString('↑', $topBorder); + $this->assertStringContainsString('1 more', $topBorder); + } + + public function testRenderScrollIndicatorBelow() + { + $lines = $this->renderWithViewport( + ['Line 0', 'Line 1', 'Line 2'], + ['scrollOffset' => 0, 'visibleLineCount' => 2, 'linesAbove' => 0, 'linesBelow' => 1], + 0, 0, 40, 10, + ); + + $bottomBorder = AnsiUtils::stripAnsiCodes($lines[\count($lines) - 1]); + $this->assertStringContainsString('↓', $bottomBorder); + $this->assertStringContainsString('1 more', $bottomBorder); + } + + public function testRenderPadsInFillMode() + { + $maxDisplayRows = 10; + $lines = $this->renderSimple(['Line 1', 'Line 2'], 0, 0, 40, $maxDisplayRows, true); + + // Top border + maxDisplayRows content rows + bottom border + $this->assertCount($maxDisplayRows + 2, $lines); + } + + public function testRenderWrappedLineDoesNotExceedWidth() + { + $width = 20; + $longLine = str_repeat('x', 50); + $lines = $this->renderSimple([$longLine], 0, 0, $width, 10); + + foreach ($lines as $i => $line) { + $lineWidth = AnsiUtils::visibleWidth($line); + $this->assertLessThanOrEqual( + $width, + $lineWidth, + \sprintf('Line %d exceeds width: %d > %d', $i, $lineWidth, $width), + ); + } + } + + public function testRenderCursorAtEndProducesValidUtf8() + { + $lines = $this->renderSimple(['café'], 0, \strlen('café'), 40, 10, false, true); + + foreach ($lines as $line) { + $this->assertTrue(mb_check_encoding($line, 'UTF-8'), 'Line should be valid UTF-8'); + } + } + + public function testRenderEmojiProducesValidUtf8() + { + $lines = $this->renderSimple(['📝 Hello'], 0, 0, 40, 10, false, true); + + foreach ($lines as $line) { + $this->assertTrue(mb_check_encoding($line, 'UTF-8'), 'Line should be valid UTF-8'); + } + } + + /** + * @param string[] $docLines + * + * @return string[] + */ + private function renderSimple(array $docLines, int $cursorLine, int $cursorCol, int $columns, int $maxDisplayRows, bool $verticallyExpanded = false, bool $focused = false): array + { + $viewport = [ + 'scrollOffset' => 0, + 'visibleLineCount' => \count($docLines), + 'linesAbove' => 0, + 'linesBelow' => 0, + ]; + + return $this->renderWithViewport($docLines, $viewport, $cursorLine, $cursorCol, $columns, $maxDisplayRows, $verticallyExpanded, $focused); + } + + /** + * @param string[] $docLines + * @param array{scrollOffset: int, visibleLineCount: int, linesAbove: int, linesBelow: int} $viewport + * + * @return string[] + */ + private function renderWithViewport(array $docLines, array $viewport, int $cursorLine, int $cursorCol, int $columns, int $maxDisplayRows, bool $verticallyExpanded = false, bool $focused = false): array + { + return $this->renderer->render( + $docLines, + $viewport, + $cursorLine, + $cursorCol, + $columns, + $maxDisplayRows, + $verticallyExpanded, + $focused, + CursorShape::Block, + new Style(), + ); + } +} diff --git a/src/Symfony/Component/Tui/Tests/Widget/Editor/EditorViewportTest.php b/src/Symfony/Component/Tui/Tests/Widget/Editor/EditorViewportTest.php new file mode 100644 index 0000000000000..31a998b2bdc3c --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Widget/Editor/EditorViewportTest.php @@ -0,0 +1,142 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Widget\Editor; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Widget\Editor\EditorViewport; + +class EditorViewportTest extends TestCase +{ + public function testComputeViewportKeepsCursorVisible() + { + $viewport = new EditorViewport(); + $lines = []; + for ($i = 0; $i < 50; ++$i) { + $lines[] = "Line $i"; + } + + // Cursor at line 20, viewport shows 10 rows + $result = $viewport->computeViewport($lines, 20, 10, 80, false, 1); + + $this->assertGreaterThanOrEqual($result['scrollOffset'], 20); + $this->assertLessThan($result['scrollOffset'] + $result['visibleLineCount'], 20); + } + + public function testComputeViewportScrollsUpWhenCursorAbove() + { + $viewport = new EditorViewport(); + $lines = []; + for ($i = 0; $i < 50; ++$i) { + $lines[] = "Line $i"; + } + + // First, scroll to line 20 + $viewport->computeViewport($lines, 20, 10, 80, false, 1); + + // Now cursor goes back to line 0 + $result = $viewport->computeViewport($lines, 0, 10, 80, false, 1); + + $this->assertSame(0, $result['scrollOffset']); + } + + public function testComputeViewportExpandedMode() + { + $viewport = new EditorViewport(); + $lines = ['Line 1', 'Line 2']; + + $result = $viewport->computeViewport($lines, 0, 20, 80, true, 1); + + // In expanded mode, visibleLineCount should fill available space + $this->assertSame(2, $result['visibleLineCount']); + } + + public function testComputeViewportReportsLinesAboveAndBelow() + { + $viewport = new EditorViewport(); + $lines = []; + for ($i = 0; $i < 30; ++$i) { + $lines[] = "Line $i"; + } + + $result = $viewport->computeViewport($lines, 15, 10, 80, false, 1); + + $this->assertGreaterThan(0, $result['linesAbove']); + $this->assertGreaterThan(0, $result['linesBelow']); + } + + public function testPageScrollDown() + { + $viewport = new EditorViewport(); + $lines = []; + for ($i = 0; $i < 50; ++$i) { + $lines[] = "Line $i"; + } + + $result = $viewport->pageScroll($lines, 1, 10, 0, 0); + + $this->assertNotNull($result); + $this->assertSame(10, $result['cursorLine']); + } + + public function testPageScrollUp() + { + $viewport = new EditorViewport(); + $lines = []; + for ($i = 0; $i < 50; ++$i) { + $lines[] = "Line $i"; + } + + $result = $viewport->pageScroll($lines, -1, 10, 20, 0); + + $this->assertNotNull($result); + $this->assertSame(10, $result['cursorLine']); + } + + public function testPageScrollClampsToEnd() + { + $viewport = new EditorViewport(); + $lines = ['A', 'B', 'C']; + + $result = $viewport->pageScroll($lines, 1, 100, 0, 0); + + $this->assertNotNull($result); + $this->assertSame(2, $result['cursorLine']); + } + + public function testPageScrollReturnsNullWhenNoChange() + { + $viewport = new EditorViewport(); + $lines = ['A']; + + $result = $viewport->pageScroll($lines, 1, 10, 0, 0); + + $this->assertNull($result); + } + + public function testReset() + { + $viewport = new EditorViewport(); + + // Scroll via pageScroll to change offset, then reset + $lines = []; + for ($i = 0; $i < 20; ++$i) { + $lines[] = "Line $i"; + } + + // computeViewport with cursor at line 15 will adjust scrollOffset + $viewport->computeViewport($lines, 15, 5, 80, false, 0); + $this->assertGreaterThan(0, $viewport->getScrollOffset()); + + $viewport->reset(); + $this->assertSame(0, $viewport->getScrollOffset()); + } +} diff --git a/src/Symfony/Component/Tui/Tests/Widget/EditorTest.php b/src/Symfony/Component/Tui/Tests/Widget/EditorTest.php new file mode 100644 index 0000000000000..3103beff709ce --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Widget/EditorTest.php @@ -0,0 +1,1479 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Widget; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Event\ChangeEvent; +use Symfony\Component\Tui\Render\RenderContext; +use Symfony\Component\Tui\Render\Renderer; +use Symfony\Component\Tui\Style\Style; +use Symfony\Component\Tui\Style\StyleSheet; +use Symfony\Component\Tui\Terminal\VirtualTerminal; +use Symfony\Component\Tui\Tui; +use Symfony\Component\Tui\Widget\AbstractWidget; +use Symfony\Component\Tui\Widget\ContainerWidget; +use Symfony\Component\Tui\Widget\EditorWidget; + +class EditorTest extends TestCase +{ + public function testRenderEmpty() + { + $editor = new EditorWidget(); + $lines = $editor->render(new RenderContext(40, 24)); + + // An empty editor renders a border + the cursor line (top border + content + bottom border) + $this->assertCount(3, $lines); + } + + public function testTypeCharacter() + { + $editor = new EditorWidget(); + $editor->handleInput('H'); + $editor->handleInput('i'); + + $this->assertSame('Hi', $editor->getText()); + } + + public function testBackspace() + { + $editor = new EditorWidget(); + $editor->setText('Hello'); + + // Move cursor to end + $editor->handleInput("\x1b[F"); // End key + $editor->handleInput("\x7f"); // Backspace + + $this->assertSame('Hell', $editor->getText()); + } + + #[DataProvider('deleteWordBackwardProvider')] + public function testDeleteWordBackward(string $keySequence, string $description) + { + $editor = new EditorWidget(); + $editor->setText('hello world'); + + $editor->handleInput("\x1b[F"); // End key + $editor->handleInput($keySequence); + + $this->assertSame('hello ', $editor->getText()); + } + + /** + * @return iterable + */ + public static function deleteWordBackwardProvider(): iterable + { + yield 'Alt+Backspace (legacy: ESC + DEL)' => ["\x1b\x7f", 'Alt+Backspace']; + yield 'Ctrl+W' => ["\x17", 'Ctrl+W']; + yield 'Alt+Backspace (Kitty protocol)' => ["\x1b[127;3u", 'Kitty Alt+Backspace']; + } + + public function testEnterCreatesNewLine() + { + $editor = new EditorWidget(); + $editor->setText('Hello'); + // Move cursor to end using Ctrl+E + $editor->handleInput("\x05"); + // Shift+Enter for new line (using Kitty protocol sequence) + $editor->handleInput("\x1b[13;2u"); + + $this->assertStringContainsString("\n", $editor->getText()); + } + + public function testOnChangeCallback() + { + [$editor, $tui] = $this->createEditorWithTui(); + + $changedText = null; + $tui->on(ChangeEvent::class, function (ChangeEvent $e) use (&$changedText) { + $changedText = $e->getValue(); + }); + + $editor->handleInput('X'); + + $this->assertSame('X', $changedText); + } + + public function testFocusable() + { + $editor = new EditorWidget(); + + $this->assertFalse($editor->isFocused()); + + $editor->setFocused(true); + $this->assertTrue($editor->isFocused()); + + $editor->setFocused(false); + $this->assertFalse($editor->isFocused()); + } + + public function testRenderWithinWidth() + { + $editor = new EditorWidget(); + $editor->setText("Line 1\nLine 2\nLine 3"); + + $width = 40; + $lines = $editor->render(new RenderContext($width, 24)); + + foreach ($lines as $i => $line) { + $lineWidth = AnsiUtils::visibleWidth($line); + $this->assertLessThanOrEqual( + $width, + $lineWidth, + \sprintf('Line %d exceeds width: %d > %d', $i, $lineWidth, $width), + ); + } + } + + public function testCursorMovement() + { + $editor = new EditorWidget(); + $editor->setText('Hello'); + + // Move to start + $editor->handleInput("\x01"); // Ctrl+A (home) + + // Type at start + $editor->handleInput('X'); + + $this->assertSame('XHello', $editor->getText()); + } + + public function testDeleteToEnd() + { + $editor = new EditorWidget(); + $editor->setText('Hello World'); + + // Move to start then right 5 times (after "Hello") + $editor->handleInput("\x01"); // Start + for ($i = 0; $i < 5; ++$i) { + $editor->handleInput("\x1b[C"); // Right arrow + } + + // Delete to end + $editor->handleInput("\x0b"); // Ctrl+K + + $this->assertSame('Hello', $editor->getText()); + } + + public function testDeleteLine() + { + [$editor, $tui] = $this->createEditorWithTui(); + $tui->start(); + $editor->setText("Line 1\nLine 2\nLine 3"); + + // Move cursor to line 2 + $editor->handleInput("\x1b[B"); // Down arrow + + // Delete line (Ctrl+Shift+K) + $editor->handleInput("\x1b[107;6u"); // Kitty: Ctrl+Shift+K + + $this->assertSame("Line 1\nLine 3", $editor->getText()); + + $tui->stop(); + } + + public function testDeleteLineLastLine() + { + [$editor, $tui] = $this->createEditorWithTui(); + $tui->start(); + $editor->setText("Line 1\nLine 2"); + + // Move to last line + $editor->handleInput("\x1b[B"); // Down arrow + + // Delete line + $editor->handleInput("\x1b[107;6u"); // Kitty: Ctrl+Shift+K + + $this->assertSame('Line 1', $editor->getText()); + + $tui->stop(); + } + + public function testDeleteLineSingleLine() + { + [$editor, $tui] = $this->createEditorWithTui(); + $tui->start(); + $editor->setText('Only line'); + + // Delete line + $editor->handleInput("\x1b[107;6u"); // Kitty: Ctrl+Shift+K + + $this->assertSame('', $editor->getText()); + + $tui->stop(); + } + + public function testLongLineWrapping() + { + $editor = new EditorWidget(); + // Create a line that is longer than our test width + $longText = str_repeat('a', 50); + $editor->setText($longText); + + // Move cursor to end + $editor->handleInput("\x05"); // Ctrl+E + + $width = 30; + $lines = $editor->render(new RenderContext($width, 24)); + + // Verify no line exceeds width + foreach ($lines as $i => $line) { + $lineWidth = AnsiUtils::visibleWidth($line); + $this->assertLessThanOrEqual( + $width, + $lineWidth, + \sprintf('Line %d exceeds width: %d > %d', $i, $lineWidth, $width), + ); + } + + // Should have wrapped into multiple content lines (plus borders) + // With 50 chars and width 30, we expect at least 2 content lines + $this->assertGreaterThanOrEqual(4, \count($lines), 'Long line should wrap into multiple display lines'); + } + + public function testLongLineWrappingWithCursorAtEnd() + { + $editor = new EditorWidget(); + $editor->setFocused(true); + + // Create a long line + $longText = str_repeat('x', 100); + $editor->setText($longText); + + // Move cursor to end + $editor->handleInput("\x05"); // Ctrl+E + + $width = 40; + $lines = $editor->render(new RenderContext($width, 24)); + + // Verify no line exceeds width - this was the bug + foreach ($lines as $i => $line) { + $lineWidth = AnsiUtils::visibleWidth($line); + $this->assertLessThanOrEqual( + $width, + $lineWidth, + \sprintf('Line %d exceeds width: %d > %d (cursor at end of long line)', $i, $lineWidth, $width), + ); + } + } + + public function testCursorAtEndOfFullWidthLine() + { + $editor = new EditorWidget(); + $editor->setFocused(true); + + // Create a line that is exactly at the width limit + // This tests the edge case where cursor at end would normally add a space + // but there's no room for it + $width = 50; + $textLength = $width; // Exactly the content width + $longText = str_repeat('a', $textLength); + $editor->setText($longText); + + // Move cursor to end + $editor->handleInput("\x05"); // Ctrl+E + + $lines = $editor->render(new RenderContext($width, 24)); + + // Verify no line exceeds width - the cursor should highlight the last char + // instead of adding a space + foreach ($lines as $i => $line) { + $lineWidth = AnsiUtils::visibleWidth($line); + $this->assertLessThanOrEqual( + $width, + $lineWidth, + \sprintf('Line %d exceeds width: %d > %d (cursor at end of exactly full-width line)', $i, $lineWidth, $width), + ); + } + } + + /** + * UTF-8 REGRESSION TESTS - Prevent invalid multi-byte character handling. + * These tests ensure that cursor positioning and text manipulation + * always work with complete graphemes, not byte boundaries. + * + * @see https://github.com/fabpot/into-the-void/issues/XXX + */ + public function testUtf8CursorLeftMovement() + { + // Regression: moveCursorLeft decremented by 1 byte + $editor = new EditorWidget(); + $editor->setText('café'); + $editor->handleInput("\x1b[F"); // End + $editor->handleInput("\x1b[D"); // Left arrow + $editor->handleInput("\x7f"); // Backspace + + // Should delete 'f' (char before cursor at 'é') + $this->assertSame('caé', $editor->getText()); + } + + public function testUtf8CursorRightMovement() + { + // Regression: moveCursorRight incremented by 1 byte + $editor = new EditorWidget(); + $editor->setText('café'); + $editor->handleInput("\x1b[H"); // Home + $editor->handleInput("\x1b[C"); // Right arrow + $editor->handleInput("\x1b[C"); // Right arrow + $editor->handleInput("\x1b[C"); // Right arrow + $editor->handleInput("\x1b[C"); // Right arrow - now at 'é' + $editor->handleInput("\x1b[C"); // Right arrow - should move past 'é' + $editor->handleInput("\x7f"); // Backspace - delete 'é' + + $this->assertSame('caf', $editor->getText()); + } + + public function testUtf8MultipleBackspaces() + { + $editor = new EditorWidget(); + $editor->setText('café'); + $editor->handleInput("\x1b[F"); // End + + // Delete all characters one by one + $editor->handleInput("\x7f"); + $this->assertSame('caf', $editor->getText()); + + $editor->handleInput("\x7f"); + $this->assertSame('ca', $editor->getText()); + + $editor->handleInput("\x7f"); + $this->assertSame('c', $editor->getText()); + + $editor->handleInput("\x7f"); + $this->assertSame('', $editor->getText()); + } + + public function testUtf8TypeMultibyte() + { + $editor = new EditorWidget(); + $editor->handleInput('h'); + $editor->handleInput('i'); + $editor->handleInput('é'); + $editor->handleInput('s'); + + $this->assertSame('hiés', $editor->getText()); + } + + public function testUtf8MultilineWithMultibyte() + { + $editor = new EditorWidget(); + $editor->setText("café\nté"); + + // Move to second line + $editor->handleInput("\x1b[F"); // End (of first line) + $editor->handleInput("\x1b[B"); // Down + $editor->handleInput("\x1b[F"); // End (of second line) + $editor->handleInput("\x7f"); // Backspace + + $this->assertSame("café\nt", $editor->getText()); + } + + /** + * @param list $setup Inputs to position cursor + * @param list $action Key sequence to execute + */ + #[DataProvider('utf8OperationsProvider')] + public function testUtf8Operations(string $text, array $setup, array $action, string $expected) + { + $editor = new EditorWidget(); + $editor->setText($text); + foreach ($setup as $input) { + $editor->handleInput($input); + } + foreach ($action as $input) { + $editor->handleInput($input); + } + $this->assertSame($expected, $editor->getText()); + } + + /** + * @return iterable, list, string}> + */ + public static function utf8OperationsProvider(): iterable + { + yield 'delete to line end' => ['café naïve', ["\x1b[H", "\x1b[C"], ["\x0b"], 'c']; + yield 'delete to line start' => ['café naïve', ["\x1b[F"], ["\x15"], '']; + yield 'word delete backward' => ['hello café', ["\x1b[F"], ["\x17"], 'hello ']; + yield 'word delete forward' => ['café hello', ["\x1b[H"], ["\x1bd"], ' hello']; + } + + #[DataProvider('utf8BackwardDeletionProvider')] + public function testUtf8BackwardDeletion(string $input, string $expected) + { + $editor = new EditorWidget(); + $editor->setText($input); + $editor->handleInput("\x1b[F"); // End + $editor->handleInput("\x7f"); // Backspace + $this->assertSame($expected, $editor->getText()); + $this->assertValidUtf8($editor->getText()); + } + + /** + * @return iterable + */ + public static function utf8BackwardDeletionProvider(): iterable + { + yield 'Latin accent' => ['café', 'caf']; + yield 'Multiple accents' => ['café naïve', 'café naïv']; + yield 'Emoji' => ['hello👋', 'hello']; + yield 'Japanese' => ['日本', '日']; + yield 'Chinese' => ['中文', '中']; + yield 'Korean' => ['한글', '한']; + yield 'Mixed Latin+Emoji' => ['hi👋bye', 'hi👋by']; + yield 'CJK three characters' => ['日本語', '日本']; + yield 'Mixed scripts Latin+CJK' => ['café日本', 'café日']; + } + + #[DataProvider('utf8ForwardDeletionProvider')] + public function testUtf8ForwardDeletion(string $input, string $expected) + { + $editor = new EditorWidget(); + $editor->setText($input); + $editor->handleInput("\x1b[H"); // Home + $editor->handleInput("\x1b[3~"); // Delete + $this->assertSame($expected, $editor->getText()); + $this->assertValidUtf8($editor->getText()); + } + + /** + * @return iterable + */ + public static function utf8ForwardDeletionProvider(): iterable + { + yield 'Latin accent' => ['café', 'afé']; + yield 'Emoji in middle' => ['hello👋world', 'ello👋world']; + yield 'Japanese' => ['日本語', '本語']; + yield 'Mixed scripts' => ['café🌍hello', 'afé🌍hello']; + } + + /** + * UTF-8 CURSOR RENDERING TESTS - Ensure cursor rendering through + * multi-byte characters doesn't produce invalid UTF-8 output. + */ + #[DataProvider('cursorRenderingUtf8Provider')] + public function testCursorRenderingWithUtf8(string $text, int $width) + { + [$editor, $tui] = $this->createEditorWithTui(); + $editor->setFocused(true); + $editor->setText($text); + + $lines = $editor->render(new RenderContext($width, 24)); + + foreach ($lines as $i => $line) { + $this->assertValidUtf8($line, \sprintf('Line %d should have valid UTF-8', $i)); + $this->assertLessThanOrEqual( + $width, + AnsiUtils::visibleWidth($line), + \sprintf('Line %d should not exceed width %d', $i, $width), + ); + } + } + + /** + * @return iterable + */ + public static function cursorRenderingUtf8Provider(): iterable + { + yield 'single emoji' => ['📝 Real-time preview', 40]; + yield 'multiple emojis multiline' => ["📝 Real-time preview\n🎨 Syntax highlighting\n📂 File loading", 40]; + yield 'cursor at emoji start' => ['📝 Hello', 40]; + yield 'emoji byte sequence' => ['📝 Hello', 40]; + yield 'Latin accent' => ['café', 40]; + yield 'CJK characters' => ['日本語テスト', 40]; + yield 'mixed scripts' => ['Hello café 🌍 日本語', 50]; + yield 'consecutive emojis' => ['📝🎨📂', 40]; + yield 'emoji at wrap boundary' => ['hello world 📝 this is a test', 20]; + yield 'emoji at line start after wrap' => ['12345678901234567890📝test', 21]; + } + + public function testCursorAfterEmoji() + { + [$editor, $tui] = $this->createEditorWithTui(); + $editor->setFocused(true); + $editor->setText('📝 Hello'); + + // Move cursor past the emoji + $editor->handleInput("\x1b[C"); // Move right once (on emoji) + $editor->handleInput("\x1b[C"); // Move right again (past emoji) + $editor->handleInput("\x1b[C"); // Move right once more (on space) + + $lines = $editor->render(new RenderContext(40, 24)); + + foreach ($lines as $line) { + $this->assertValidUtf8($line, 'Line should have valid UTF-8 with cursor after emoji'); + } + } + + public function testBackspaceWithEmojiThenRender() + { + [$editor, $tui] = $this->createEditorWithTui(); + $editor->setFocused(true); + $editor->setText('hello👋'); + + $editor->handleInput("\x1b[F"); // End + $editor->handleInput("\x7f"); // Backspace + + $lines = $editor->render(new RenderContext(40, 24)); + + foreach ($lines as $line) { + $this->assertValidUtf8($line, 'Line should have valid UTF-8 after emoji deletion'); + } + } + + /** + * @param list $setup Inputs to position cursor before the boundary action + * @param list $action Boundary key + marker input + */ + #[DataProvider('cursorBoundaryProvider')] + public function testCursorBoundaryBehavior(string $text, array $setup, array $action, string $expected) + { + $editor = new EditorWidget(); + $editor->setText($text); + foreach ($setup as $input) { + $editor->handleInput($input); + } + foreach ($action as $input) { + $editor->handleInput($input); + } + $this->assertSame($expected, $editor->getText()); + } + + /** + * @return iterable, list, string}> + */ + public static function cursorBoundaryProvider(): iterable + { + yield 'up on first line goes to start' => ['Hello World', ["\x1b[F"], ["\x1b[A", 'X'], 'XHello World']; + yield 'down on last line goes to end' => ['Hello World', ["\x1b[H"], ["\x1b[B", 'X'], 'Hello WorldX']; + yield 'up on multiline moves up' => ["Line 1\nLine 2", ["\x1b[B", "\x1b[F"], ["\x1b[A", "\x1b[H", 'X'], "XLine 1\nLine 2"]; + yield 'down on multiline moves down' => ["Line 1\nLine 2", [], ["\x1b[B", "\x1b[H", 'X'], "Line 1\nXLine 2"]; + yield 'up on first line of multiline goes to start' => ["Hello World\nLine 2", ["\x1b[F"], ["\x1b[A", 'X'], "XHello World\nLine 2"]; + yield 'down on last line of multiline goes to end' => ["Line 1\nHello World", ["\x1b[B", "\x1b[H"], ["\x1b[B", 'X'], "Line 1\nHello WorldX"]; + } + + /** + * @param string[] $inputs + */ + #[DataProvider('jumpToCharacterProvider')] + public function testJumpToCharacter(string $text, array $inputs, string $expected) + { + $editor = new EditorWidget(); + $editor->setText($text); + + foreach ($inputs as $input) { + $editor->handleInput($input); + } + + $editor->handleInput('X'); + $this->assertSame($expected, $editor->getText()); + } + + /** + * @return iterable + */ + public static function jumpToCharacterProvider(): iterable + { + yield 'forward to character' => [ + 'Hello World', + ["\x1b[H", "\x1d", 'W'], + 'Hello XWorld', + ]; + yield 'backward to character' => [ + 'Hello World', + ["\x1b[F", "\x1b\x1d", 'H'], + 'XHello World', + ]; + yield 'forward skips current position' => [ + 'aabaa', + ["\x1b[H", "\x1d", 'a'], + 'aXabaa', + ]; + yield 'forward across lines' => [ + "Hello\nWorld", + ["\x1b[H", "\x1d", 'W'], + "Hello\nXWorld", + ]; + yield 'cancelled by pressing again' => [ + 'Hello', + ["\x1b[H", "\x1d", "\x1d"], + 'XHello', + ]; + yield 'no match does not move cursor' => [ + 'Hello', + ["\x1b[H", "\x1d", 'Z'], + 'XHello', + ]; + yield 'forward from multi-byte character' => [ + 'é_hello', + ["\x1b[H", "\x1d", 'h'], + 'é_Xhello', + ]; + yield 'forward skips multi-byte at current position' => [ + 'éhéllo', + ["\x1b[H", "\x1d", 'é'], + 'éhXéllo', + ]; + yield 'backward to multi-byte character' => [ + 'héllo', + ["\x1b[F", "\x1b\x1d", 'é'], + 'hXéllo', + ]; + yield 'forward to emoji' => [ + '😀 hello 😀 world', + ["\x1b[H", "\x1d", '😀'], + '😀 hello X😀 world', + ]; + } + + public function testPageScrollDown() + { + [$editor, $tui] = $this->createEditorWithTui(); + // Create many lines + $lines = []; + for ($i = 0; $i < 50; ++$i) { + $lines[] = "Line $i"; + } + $editor->setText(implode("\n", $lines)); + $editor->handleInput("\x1b[H"); // Home + + // Page down + $editor->handleInput("\x1b[6~"); // Page Down + + // Cursor should have moved down (not on first line anymore) + $editor->handleInput("\x1b[H"); // Home + $editor->handleInput('X'); + + $text = $editor->getText(); + // First line should NOT have X - cursor moved down + $this->assertStringStartsWith('Line 0', $text); + $this->assertStringContainsString('X', $text); + } + + public function testPageScrollUp() + { + [$editor, $tui] = $this->createEditorWithTui(); + // Create many lines + $lines = []; + for ($i = 0; $i < 50; ++$i) { + $lines[] = "Line $i"; + } + $editor->setText(implode("\n", $lines)); + + // Go to last line + for ($i = 0; $i < 50; ++$i) { + $editor->handleInput("\x1b[B"); // Down + } + + // Page up + $editor->handleInput("\x1b[5~"); // Page Up + + // Cursor should have moved up from last line + $editor->handleInput("\x1b[H"); // Home + $editor->handleInput('X'); + + $text = $editor->getText(); + // Last line should NOT have X + $lastLine = explode("\n", $text); + $this->assertStringStartsWith('Line 49', end($lastLine)); + } + + /** + * @param list $setup Inputs to position cursor before the keybinding + * @param list $action Keybinding + optional marker input + */ + #[DataProvider('keybindingProvider')] + public function testKeybinding(string $text, array $setup, array $action, string $expected) + { + $editor = new EditorWidget(); + $editor->setText($text); + foreach ($setup as $input) { + $editor->handleInput($input); + } + foreach ($action as $input) { + $editor->handleInput($input); + } + $this->assertSame($expected, $editor->getText()); + } + + /** + * @return iterable, list, string}> + */ + public static function keybindingProvider(): iterable + { + yield 'Ctrl+B moves left' => ['Hello', ["\x1b[F"], ["\x02", 'X'], 'HellXo']; + yield 'Ctrl+F moves right' => ['Hello', ["\x1b[H"], ["\x06", 'X'], 'HXello']; + yield 'Ctrl+D deletes forward' => ['Hello', ["\x1b[H"], ["\x04"], 'ello']; + yield 'Alt+B moves word left' => ['Hello World', ["\x1b[F"], ["\x1bb", 'X'], 'Hello XWorld']; + yield 'Alt+F moves word right' => ['Hello World', ["\x1b[H"], ["\x1bf", 'X'], 'HelloX World']; + } + + /** + * Test typing behavior and ensure cursor moves properly. + * + * This is a regression test for the bug where the cursor didn't move + * when typing at the initial position. + */ + public function testTypingMovesCursor() + { + [$editor, $tui] = $this->createEditorWithTui(); + $editor->setFocused(true); + + // Initially, cursor should be at position 0 on an empty line + $this->assertSame('', $editor->getText()); + + // Type 'H' - cursor should move to position 1 + $editor->handleInput('H'); + $this->assertSame('H', $editor->getText()); + + // Render and verify output is valid UTF-8 + $lines = $editor->render(new RenderContext(40, 24)); + foreach ($lines as $line) { + $this->assertValidUtf8($line, 'Rendered line should be valid UTF-8'); + } + + // Type 'e' - cursor should move to position 2 + $editor->handleInput('e'); + $this->assertSame('He', $editor->getText()); + + // Type 'l' twice, then 'o' + $editor->handleInput('l'); + $editor->handleInput('l'); + $editor->handleInput('o'); + $this->assertSame('Hello', $editor->getText()); + + // Final render should still be valid + $lines = $editor->render(new RenderContext(40, 24)); + foreach ($lines as $line) { + $this->assertValidUtf8($line, 'Final rendered line should be valid UTF-8'); + } + } + + public function testCursorPositionAfterTypingSpace() + { + [$editor, $tui] = $this->createEditorWithTui(); + $editor->setFocused(true); + + $editor->handleInput('H'); + $editor->handleInput('i'); + $this->assertSame('Hi', $editor->getText()); + + // Type a space; cursor must advance past it + $editor->handleInput(' '); + $this->assertSame('Hi ', $editor->getText()); + + $lines = $editor->render(new RenderContext(40, 24)); + + // The cursor marker should appear after "Hi " (i.e. at column 3), + // not after "Hi" (column 2). Find the content line (skip border). + $contentLine = $lines[1]; // first content line after top border + $markerPos = strpos($contentLine, AnsiUtils::CURSOR_MARKER_PREFIX); + $this->assertIsInt($markerPos, 'Cursor marker should be present'); + + $beforeMarker = substr($contentLine, 0, $markerPos); + $this->assertSame(3, AnsiUtils::visibleWidth($beforeMarker), 'Cursor should be at column 3 (after "Hi ")'); + } + + public function testTypingEmojiDirectly() + { + [$editor, $tui] = $this->createEditorWithTui(); + $editor->setFocused(true); + + // Type emoji directly (not via paste); this was previously blocked + // because StringUtils::hasControlChars() treated UTF-8 continuation bytes + // in the 0x80-0x9F range as C1 control characters + $editor->handleInput('😀'); + $this->assertSame('😀', $editor->getText()); + + // Type more emoji + $editor->handleInput('🎉'); + $this->assertSame('😀🎉', $editor->getText()); + + // Mix text and emoji + $editor->handleInput(' hi'); + $this->assertSame('😀🎉 hi', $editor->getText()); + } + + public function testTypingWithEmojiThenRegularText() + { + [$editor, $tui] = $this->createEditorWithTui(); + $editor->setFocused(true); + + // Set text with emoji directly + $editor->setText('📝 '); + $this->assertSame('📝 ', $editor->getText()); + + // Type regular text after emoji (move cursor to end first) + $editor->handleInput("\x05"); // End key + $editor->handleInput('H'); + $editor->handleInput('i'); + $this->assertSame('📝 Hi', $editor->getText()); + + // Verify rendering is valid + $lines = $editor->render(new RenderContext(40, 24)); + foreach ($lines as $line) { + $this->assertValidUtf8($line); + } + } + + /** + * Regression: typing a space that fills the line to exactly the terminal + * width caused the rendered output to exceed the width by 1. + * + * The bug was in renderLine(): lineVisibleWidth was computed from rtrim'd + * text, so trailing spaces were not counted. When the cursor was at the + * end past a trailing space, the "room for block cursor" check passed + * incorrectly, adding a styled space on top of the full-width content. + */ + public function testSpaceAtEndOfFullWidthLineDoesNotExceedWidth() + { + $editor = new EditorWidget(); + $editor->setFocused(true); + + $width = 20; + + // Fill line to width-1 with non-space chars, then type a space + $editor->setText(str_repeat('a', $width - 1)); + $editor->handleInput("\x05"); // End + $editor->handleInput(' '); + + $this->assertSame(str_repeat('a', $width - 1).' ', $editor->getText()); + + $lines = $editor->render(new RenderContext($width, 24)); + + foreach ($lines as $i => $line) { + $lineWidth = AnsiUtils::visibleWidth($line); + $this->assertLessThanOrEqual( + $width, + $lineWidth, + \sprintf('Line %d exceeds width: %d > %d (space at end of full-width line)', $i, $lineWidth, $width), + ); + } + } + + public function testTypingInMiddleOfLine() + { + [$editor, $tui] = $this->createEditorWithTui(); + $editor->setFocused(true); + + // Type initial "Hello" + $editor->handleInput('H'); + $editor->handleInput('e'); + $editor->handleInput('l'); + $editor->handleInput('l'); + $editor->handleInput('o'); + + // Move to position after 'H' (position 1) + // First move to home, then right once + $editor->handleInput("\x01"); // Home + $editor->handleInput("\x1b[C"); // Right once (to position 1) + + // Type 3 characters at position 1 (after 'H', before 'e') + // "Hello" becomes "Hxyzello" + $editor->handleInput('x'); + $editor->handleInput('y'); + $editor->handleInput('z'); + $this->assertSame('Hxyzello', $editor->getText()); + + // Verify rendering + $lines = $editor->render(new RenderContext(40, 24)); + foreach ($lines as $line) { + $this->assertValidUtf8($line); + } + } + + /** + * Regression: autocomplete stopped working after deleting characters. + * + * When typing "/hx" with no matching slash command, autocomplete closed. + * Then deleting "x" to get "/h" (which has matches) did NOT re-open + * autocomplete because notifyChange() only called refresh() (which + * early-returns when inactive) instead of trying to re-trigger. + */ + public function testUndoRestoresPreviousState() + { + $editor = new EditorWidget(); + $editor->handleInput('H'); + $editor->handleInput('i'); + $this->assertSame('Hi', $editor->getText()); + + // Undo (Ctrl+-) + $editor->handleInput("\x1f"); + $this->assertSame('H', $editor->getText()); + + $editor->handleInput("\x1f"); + $this->assertSame('', $editor->getText()); + } + + public function testRedoRestoresUndoneState() + { + $editor = new EditorWidget(); + $editor->handleInput('H'); + $editor->handleInput('i'); + $this->assertSame('Hi', $editor->getText()); + + // Undo (Ctrl+-) + $editor->handleInput("\x1f"); + $this->assertSame('H', $editor->getText()); + + // Redo (Ctrl+Shift+Z) + $editor->handleInput("\x1b[122;6u"); + $this->assertSame('Hi', $editor->getText()); + } + + public function testRedoDoesNothingWhenStackIsEmpty() + { + $editor = new EditorWidget(); + $editor->handleInput('H'); + $this->assertSame('H', $editor->getText()); + + // Redo without prior undo does nothing + $editor->handleInput("\x1b[122;6u"); + $this->assertSame('H', $editor->getText()); + } + + public function testRedoStackClearedOnNewEdit() + { + $editor = new EditorWidget(); + $editor->handleInput('A'); + $editor->handleInput('B'); + $this->assertSame('AB', $editor->getText()); + + // Undo + $editor->handleInput("\x1f"); + $this->assertSame('A', $editor->getText()); + + // Type something new; redo stack should be cleared + $editor->handleInput('C'); + $this->assertSame('AC', $editor->getText()); + + // Redo should do nothing now + $editor->handleInput("\x1b[122;6u"); + $this->assertSame('AC', $editor->getText()); + } + + public function testMultipleUndoAndRedo() + { + $editor = new EditorWidget(); + $editor->handleInput('A'); + $editor->handleInput('B'); + $editor->handleInput('C'); + $this->assertSame('ABC', $editor->getText()); + + // Undo 3 times + $editor->handleInput("\x1f"); + $this->assertSame('AB', $editor->getText()); + $editor->handleInput("\x1f"); + $this->assertSame('A', $editor->getText()); + $editor->handleInput("\x1f"); + $this->assertSame('', $editor->getText()); + + // Redo 3 times + $editor->handleInput("\x1b[122;6u"); + $this->assertSame('A', $editor->getText()); + $editor->handleInput("\x1b[122;6u"); + $this->assertSame('AB', $editor->getText()); + $editor->handleInput("\x1b[122;6u"); + $this->assertSame('ABC', $editor->getText()); + + // Extra redo does nothing + $editor->handleInput("\x1b[122;6u"); + $this->assertSame('ABC', $editor->getText()); + } + + public function testUndoRedoWithNewLine() + { + $editor = new EditorWidget(); + $editor->handleInput('H'); + $editor->handleInput('i'); + $editor->handleInput("\x1b[F"); // End + $editor->handleInput("\x1b[13;2u"); // Shift+Enter for new line + $editor->handleInput('!'); + $this->assertSame("Hi\n!", $editor->getText()); + + // Undo the '!' + $editor->handleInput("\x1f"); + $this->assertSame("Hi\n", $editor->getText()); + + // Redo the '!' + $editor->handleInput("\x1b[122;6u"); + $this->assertSame("Hi\n!", $editor->getText()); + } + + public function testSetTextClearsUndoRedoStacks() + { + $editor = new EditorWidget(); + + // Build up undo history + $editor->handleInput('A'); + $editor->handleInput('B'); + $editor->handleInput('C'); + $this->assertSame('ABC', $editor->getText()); + + // Undo once to also populate the redo stack + $editor->handleInput("\x1f"); + $this->assertSame('AB', $editor->getText()); + + // Programmatically replace all content + $editor->setText('New content'); + $this->assertSame('New content', $editor->getText()); + + // Undo should do nothing; stack was cleared + $editor->handleInput("\x1f"); + $this->assertSame('New content', $editor->getText()); + + // Redo should do nothing; stack was cleared + $editor->handleInput("\x1b[122;6u"); + $this->assertSame('New content', $editor->getText()); + } + + public function testLargePasteCreatesMarkerAndGetTextReturnsFullContent() + { + $editor = new EditorWidget(); + $content = $this->generateLargeContent(15); + + $this->simulatePaste($editor, $content); + + // getText() should return the full paste content + $this->assertSame($content, $editor->getText()); + + // A marker should have been created + $markers = $editor->getPasteMarkers(); + $this->assertCount(1, $markers); + $this->assertSame($content, $markers[0]['content']); + } + + public function testSmallPasteDoesNotCreateMarker() + { + $editor = new EditorWidget(); + $content = "line 1\nline 2\nline 3"; + + $this->simulatePaste($editor, $content); + + $this->assertSame($content, $editor->getText()); + $this->assertSame([], $editor->getPasteMarkers()); + } + + public function testPasteMarkerCollisionWithUserText() + { + $editor = new EditorWidget(); + + // First, do a large paste + $pasteContent = $this->generateLargeContent(15); + $this->simulatePaste($editor, $pasteContent); + + // Get the marker that was created + $markers = $editor->getPasteMarkers(); + $this->assertCount(1, $markers); + $markerText = $markers[0]['marker']; + + // Now create a second editor where user types a string that looks like + // a paste marker (but without the random ID) + $editor2 = new EditorWidget(); + $editor2->handleInput('[paste #1 +15 lines]'); + + // The user-typed text should remain as-is, not be replaced + $this->assertSame('[paste #1 +15 lines]', $editor2->getText()); + + // The actual marker contains a random hex ID, so it's different + $this->assertStringContainsString('<', $markerText); + $this->assertStringContainsString('>', $markerText); + } + + public function testPasteMarkerUniqueness() + { + $editor = new EditorWidget(); + $content1 = $this->generateLargeContent(12); + $content2 = $this->generateLargeContent(12); + + $this->simulatePaste($editor, $content1); + $this->simulatePaste($editor, $content2); + + $markers = $editor->getPasteMarkers(); + $this->assertCount(2, $markers); + + // Each marker should have a unique string + $this->assertNotSame($markers[0]['marker'], $markers[1]['marker']); + } + + public function testPasteContentContainingMarkerLikeText() + { + $editor = new EditorWidget(); + + // First paste: content that looks like a marker + $maliciousContent = implode("\n", array_fill(0, 12, '[paste #2 +12 lines]')); + $this->simulatePaste($editor, $maliciousContent); + + // Second paste + $normalContent = $this->generateLargeContent(12); + $this->simulatePaste($editor, $normalContent); + + // getText() should correctly expand both markers without chaining + $text = $editor->getText(); + $this->assertStringContainsString($maliciousContent, $text); + $this->assertStringContainsString($normalContent, $text); + } + + public function testSetTextClearsPasteMarkers() + { + $editor = new EditorWidget(); + + // Create a large paste + $content = $this->generateLargeContent(15); + $this->simulatePaste($editor, $content); + $this->assertNotSame([], $editor->getPasteMarkers()); + + // setText() should clear paste markers + $editor->setText('new content'); + $this->assertSame([], $editor->getPasteMarkers()); + $this->assertSame('new content', $editor->getText()); + } + + public function testMultipleLargePastesExpandCorrectly() + { + $editor = new EditorWidget(); + + $content1 = $this->generateLargeContent(11, 'alpha'); + $this->simulatePaste($editor, $content1); + + // Add a newline between pastes + $editor->handleInput("\x1b[13;2u"); // Shift+Enter + + $content2 = $this->generateLargeContent(13, 'beta'); + $this->simulatePaste($editor, $content2); + + $text = $editor->getText(); + $this->assertStringContainsString($content1, $text); + $this->assertStringContainsString($content2, $text); + } + + /** + * @return iterable + */ + public static function largePasteLineEndingProvider(): iterable + { + yield 'Windows \\r\\n' => ["\r\n"]; + yield 'old Mac \\r' => ["\r"]; + } + + #[DataProvider('largePasteLineEndingProvider')] + public function testLargePasteNormalizesLineEndings(string $separator) + { + $editor = new EditorWidget(); + + $lines = []; + for ($i = 1; $i <= 12; ++$i) { + $lines[] = "line $i"; + } + $content = implode($separator, $lines); + + $this->simulatePaste($editor, $content); + + $text = $editor->getText(); + $this->assertStringNotContainsString("\r", $text); + $this->assertSame(implode("\n", $lines), $text); + } + + public function testRenderResolvesElementStylesOncePerPass() + { + $counter = new \stdClass(); + $counter->calls = []; + + $stylesheet = new class($counter) extends StyleSheet { + public function __construct( + private readonly \stdClass $counter, + ) { + parent::__construct(); + } + + public function resolveElement(AbstractWidget $widget, string $element): Style + { + $this->counter->calls[] = $element; + + return parent::resolveElement($widget, $element); + } + }; + + $terminal = new VirtualTerminal(80, 24); + $tui = new Tui(styleSheet: $stylesheet, terminal: $terminal); + $editor = new EditorWidget(); + $tui->add($editor); + $editor->setFocused(true); + + // Set up multiline content with cursor on a line that wraps + $editor->setText("Line 1\nLine 2\nLine 3\nLine 4\nLine 5"); + + // Reset counter after setup + $counter->calls = []; + + // Render + $editor->render(new RenderContext(40, 24)); + + // resolveElement should be called exactly twice: once for 'cursor', once for 'frame' + $this->assertSame(['cursor', 'frame'], $counter->calls); + } + + public function testPageScrollRespectsMaxVisibleLines() + { + $terminal = new VirtualTerminal(80, 24); + $tui = new Tui(terminal: $terminal); + $editor = new EditorWidget(); + $tui->add($editor); + + // Set maxVisibleLines to 3 + $editor->setMaxVisibleLines(3); + + // Create 20 lines + $lines = []; + for ($i = 0; $i < 20; ++$i) { + $lines[] = "Line $i"; + } + $editor->setText(implode("\n", $lines)); + $editor->handleInput("\x1b[H"); // Home - cursor at line 0 + + // Render so that lastMaxVisibleLines is computed (should be 3) + $editor->render(new RenderContext(80, 24)); + + // Page down should move by maxVisibleLines (3), not 30% of 24 (7) + $editor->handleInput("\x1b[6~"); // Page Down + + // Cursor should be at line 3 + $editor->handleInput("\x1b[H"); // Home + $editor->handleInput('X'); + + $text = $editor->getText(); + $resultLines = explode("\n", $text); + $this->assertSame('XLine 3', $resultLines[3]); + } + + public function testPageScrollRespectsVerticallyExpandedMode() + { + $terminal = new VirtualTerminal(80, 40); + $tui = new Tui(terminal: $terminal); + $editor = new EditorWidget(); + $tui->add($editor); + + $editor->expandVertically(true); + + // Create 100 lines + $lines = []; + for ($i = 0; $i < 100; ++$i) { + $lines[] = "Line $i"; + } + $editor->setText(implode("\n", $lines)); + $editor->handleInput("\x1b[H"); // Home + + // Render with 40 rows context (fill mode: maxVisibleLines = 40 - 2 = 38) + $editor->render(new RenderContext(80, 40)); + + // Page down should move by 38, not 30% of 40 (12) + $editor->handleInput("\x1b[6~"); // Page Down + + $editor->handleInput("\x1b[H"); // Home + $editor->handleInput('X'); + + $text = $editor->getText(); + $resultLines = explode("\n", $text); + $this->assertSame('XLine 38', $resultLines[38]); + } + + #[DataProvider('lineEndingNormalizationProvider')] + public function testSetTextNormalizesLineEndings(string $input, string $expected) + { + $editor = new EditorWidget(); + $editor->setText($input); + + $this->assertSame($expected, $editor->getText()); + } + + /** + * @return iterable + */ + public static function lineEndingNormalizationProvider(): iterable + { + yield 'Windows \\r\\n' => ["Line 1\r\nLine 2\r\nLine 3", "Line 1\nLine 2\nLine 3"]; + yield 'Old Mac \\r' => ["Line 1\rLine 2\rLine 3", "Line 1\nLine 2\nLine 3"]; + yield 'Mixed endings' => ["Line 1\r\nLine 2\rLine 3\nLine 4", "Line 1\nLine 2\nLine 3\nLine 4"]; + } + + public function testTypingAfterSetTextWithCarriageReturn() + { + $editor = new EditorWidget(); + $editor->setText("Hello\r\nWorld"); + + // Move to end of first line and type + $editor->handleInput("\x1b[F"); // End + $editor->handleInput('!'); + + $this->assertSame("Hello!\nWorld", $editor->getText()); + } + + #[DataProvider('pasteLineEndingNormalizationProvider')] + public function testPasteNormalizesLineEndings(string $pasteContent) + { + $editor = new EditorWidget(); + $editor->handleInput("\x1b[200~".$pasteContent."\x1b[201~"); + + $this->assertSame("Line 1\nLine 2\nLine 3", $editor->getText()); + } + + /** + * @return iterable + */ + public static function pasteLineEndingNormalizationProvider(): iterable + { + yield 'Windows \\r\\n' => ["Line 1\r\nLine 2\r\nLine 3"]; + yield 'Old Mac \\r' => ["Line 1\rLine 2\rLine 3"]; + } + + public function testPageScrollFallsBackBeforeFirstRender() + { + $terminal = new VirtualTerminal(80, 24); + $tui = new Tui(terminal: $terminal); + $editor = new EditorWidget(); + $tui->add($editor); + + // Create 50 lines + $lines = []; + for ($i = 0; $i < 50; ++$i) { + $lines[] = "Line $i"; + } + $editor->setText(implode("\n", $lines)); + $editor->handleInput("\x1b[H"); // Home + + // Page down WITHOUT rendering first; should use fallback (30% of 24 = 7) + $editor->handleInput("\x1b[6~"); // Page Down + + $editor->handleInput("\x1b[H"); // Home + $editor->handleInput('X'); + + $text = $editor->getText(); + $resultLines = explode("\n", $text); + $this->assertSame('XLine 7', $resultLines[7]); + } + + public function testRenderWithWrappingLinesRespectsDisplayRowBudget() + { + $editor = new EditorWidget(); + $editor->expandVertically(true); + + // Create lines that each wrap to 2 display rows in 30 columns + $content = []; + for ($i = 0; $i < 20; ++$i) { + $content[] = "Line $i: This is a long line that will definitely wrap."; + } + $editor->setText(implode("\n", $content)); + + // Render with 12 rows available (10 display rows for content + 2 borders) + $lines = $editor->render(new RenderContext(30, 12)); + + // Total output should not exceed 12 rows (the allocated context height) + $this->assertLessThanOrEqual(12, \count($lines), 'Editor must not exceed allocated display rows when lines wrap'); + + // Should have top border + content + bottom border + $firstLine = AnsiUtils::stripAnsiCodes($lines[0]); + $lastLine = AnsiUtils::stripAnsiCodes($lines[\count($lines) - 1]); + $this->assertStringContainsString('─', $firstLine, 'First line should be top border'); + $this->assertStringContainsString('─', $lastLine, 'Last line should be bottom border'); + + // The bottom border should show "more" indicator since we can't fit all 20 lines + $this->assertStringContainsString('more', $lastLine, 'Should show scroll indicator when content overflows'); + } + + public function testScrollWithWrappingLinesKeepsCursorVisible() + { + $terminal = new VirtualTerminal(50, 15); + $renderer = new Renderer(); + $tui = new Tui(terminal: $terminal, renderer: $renderer); + + $editor = new EditorWidget(); + $editor->expandVertically(true); + + // Lines that wrap in 48 columns (50 - 2 padding) + $content = []; + for ($i = 0; $i < 20; ++$i) { + $content[] = "Line $i: This text is long enough to wrap in the editor."; + } + $editor->setText(implode("\n", $content)); + $tui->add($editor); + $tui->setFocus($editor); + $tui->start(); + $tui->processRender(); + + // Navigate down past the visible area + for ($i = 0; $i < 10; ++$i) { + $terminal->simulateInput("\x1b[B"); // Down arrow + $tui->processRender(); + } + + // Type a marker to verify cursor is at logical line 10 + $editor->handleInput('X'); + $lines = explode("\n", $editor->getText()); + $this->assertStringStartsWith('XLine 10:', $lines[10], 'Cursor should be at the beginning of line 10'); + // Undo the marker to restore original state + $editor->handleInput("\x1f"); + + // Render and verify scrolling happened (Line 0 should not be visible) + $root = new ContainerWidget(); + $root->add($editor); + $editor->invalidate(); + $result = $renderer->render($root, 50, 15); + $allContent = implode("\n", $result); + $this->assertStringNotContainsString('Line 0:', $allContent, 'Scroll offset should advance when cursor moves past visible area with wrapping lines'); + + // Verify the rendered output doesn't exceed the allocated height + $this->assertLessThanOrEqual(15, \count($result), 'Total render should not exceed terminal height'); + + $tui->stop(); + } + + private function assertValidUtf8(string $value, string $message = ''): void + { + $this->assertTrue(mb_check_encoding($value, 'UTF-8'), $message ?: 'Value should be valid UTF-8'); + } + + /** + * @return array{EditorWidget, Tui} + */ + private function createEditorWithTui(): array + { + $terminal = new VirtualTerminal(80, 24); + $tui = new Tui(terminal: $terminal); + $editor = new EditorWidget(); + $tui->add($editor); + + return [$editor, $tui]; + } + + /** + * Helper to simulate a bracketed paste into the editor. + */ + private function simulatePaste(EditorWidget $editor, string $content): void + { + $editor->handleInput("\x1b[200~".$content."\x1b[201~"); + } + + /** + * Generate a large paste content with the given number of lines. + */ + private function generateLargeContent(int $lineCount, string $prefix = 'line'): string + { + $lines = []; + for ($i = 1; $i <= $lineCount; ++$i) { + $lines[] = "$prefix $i content"; + } + + return implode("\n", $lines); + } +} diff --git a/src/Symfony/Component/Tui/Tests/Widget/Figlet/FigletFontTest.php b/src/Symfony/Component/Tui/Tests/Widget/Figlet/FigletFontTest.php new file mode 100644 index 0000000000000..02907904af1ab --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Widget/Figlet/FigletFontTest.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Widget\Figlet; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Exception\InvalidArgumentException; +use Symfony\Component\Tui\Widget\Figlet\FigletFont; + +class FigletFontTest extends TestCase +{ + private const FONTS_DIR = __DIR__.'/../../../Widget/Figlet/fonts'; + + /** + * @return iterable + */ + public static function bundledFontProvider(): iterable + { + yield 'big' => ['big', 8]; + yield 'small' => ['small', 5]; + yield 'slant' => ['slant', 6]; + yield 'standard' => ['standard', 6]; + yield 'mini' => ['mini', 4]; + } + + #[DataProvider('bundledFontProvider')] + public function testLoadBundledFont(string $name, int $expectedHeight) + { + $font = FigletFont::load(self::FONTS_DIR.'/'.$name.'.flf'); + + $this->assertSame($expectedHeight, $font->getHeight()); + + // Every bundled font must cover the full printable ASCII range + for ($code = 32; $code <= 126; ++$code) { + $this->assertTrue( + $font->hasCharacter($code), + \sprintf('Font "%s" is missing character %d (%s)', $name, $code, chr($code)), + ); + } + } + + public function testGetCharacterReturnsCorrectHeight() + { + $font = FigletFont::load(self::FONTS_DIR.'/big.flf'); + $charLines = $font->getCharacter(65); // A + + $this->assertCount(8, $charLines); + } + + public function testGetCharacterForUnknownCodepoint() + { + $font = FigletFont::load(self::FONTS_DIR.'/big.flf'); + $charLines = $font->getCharacter(9999); + + $this->assertCount(8, $charLines); + foreach ($charLines as $line) { + $this->assertSame('', $line); + } + } + + public function testHardblankIsReplacedWithSpace() + { + $font = FigletFont::load(self::FONTS_DIR.'/big.flf'); + + // Space character (ASCII 32); in the .flf file it uses $ (hardblank) + $spaceLines = $font->getCharacter(32); + + // No $ should remain in the output + foreach ($spaceLines as $line) { + $this->assertStringNotContainsString('$', $line); + } + } + + public function testParseInvalidHeader() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('missing flf2 signature'); + + FigletFont::parse("not a figlet font\n"); + } + + public function testLoadNonExistentFile() + { + $this->expectException(InvalidArgumentException::class); + + FigletFont::load('/nonexistent/path/to/font.flf'); + } +} diff --git a/src/Symfony/Component/Tui/Tests/Widget/Figlet/FigletRendererTest.php b/src/Symfony/Component/Tui/Tests/Widget/Figlet/FigletRendererTest.php new file mode 100644 index 0000000000000..4f9aaf26828b8 --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Widget/Figlet/FigletRendererTest.php @@ -0,0 +1,194 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Widget\Figlet; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Widget\Figlet\FigletFont; +use Symfony\Component\Tui\Widget\Figlet\FigletRenderer; + +class FigletRendererTest extends TestCase +{ + private const FONTS_DIR = __DIR__.'/../../../Widget/Figlet/fonts'; + + public function testRenderEmptyString() + { + $font = FigletFont::load(self::FONTS_DIR.'/big.flf'); + $renderer = new FigletRenderer($font); + + $this->assertSame([], $renderer->render('')); + } + + public function testRenderSingleCharacter() + { + $font = FigletFont::load(self::FONTS_DIR.'/big.flf'); + $renderer = new FigletRenderer($font); + + $lines = $renderer->render('A'); + + // big font is 8 tall, trailing blank lines stripped; should be ≤ 8 and ≥ 1 + $this->assertGreaterThanOrEqual(1, \count($lines)); + $this->assertLessThanOrEqual(8, \count($lines)); + + // Should contain the recognizable parts of 'A' in big font + $joined = implode("\n", $lines); + $this->assertStringContainsString('/\\', $joined); + } + + public function testRenderMultipleCharacters() + { + $font = FigletFont::load(self::FONTS_DIR.'/big.flf'); + $renderer = new FigletRenderer($font); + + $lines = $renderer->render('Hi'); + + $this->assertGreaterThanOrEqual(1, \count($lines)); + $this->assertLessThanOrEqual(8, \count($lines)); + + // Each line should be wider than a single character + $singleCharLines = $renderer->render('H'); + $this->assertGreaterThanOrEqual(1, \count($singleCharLines)); + $this->assertGreaterThan( + $this->maxLineLength($singleCharLines), + $this->maxLineLength($lines), + ); + } + + public function testTrailingWhitespaceIsStripped() + { + $font = FigletFont::load(self::FONTS_DIR.'/big.flf'); + $renderer = new FigletRenderer($font); + + $lines = $renderer->render('A'); + + foreach ($lines as $line) { + $this->assertSame($line, rtrim($line), 'Lines should not have trailing whitespace'); + } + } + + public function testTrailingBlankLinesAreRemoved() + { + $font = FigletFont::load(self::FONTS_DIR.'/big.flf'); + $renderer = new FigletRenderer($font); + + $lines = $renderer->render('A'); + + // Last line should not be blank + if ([] !== $lines) { + $this->assertNotSame('', end($lines), 'Trailing blank lines should be removed'); + } + } + + public function testRenderSpace() + { + $font = FigletFont::load(self::FONTS_DIR.'/big.flf'); + $renderer = new FigletRenderer($font); + + $linesWithSpace = $renderer->render('A B'); + $linesWithout = $renderer->render('AB'); + + // With space should be wider + $this->assertGreaterThanOrEqual(1, \count($linesWithout)); + $this->assertGreaterThanOrEqual(1, \count($linesWithSpace)); + $this->assertGreaterThan( + $this->maxLineLength($linesWithout), + $this->maxLineLength($linesWithSpace), + ); + } + + public function testRenderWithNamedColor() + { + $font = FigletFont::load(self::FONTS_DIR.'/big.flf'); + $renderer = new FigletRenderer($font); + + $lines = $renderer->render('A', 'red'); + + $this->assertNotSame([], $lines); + // Each non-empty line should start with ANSI escape and end with reset + foreach ($lines as $line) { + if ('' === $line) { + continue; + } + $this->assertStringStartsWith("\x1b[", $line); + $this->assertStringEndsWith("\x1b[0m", $line); + } + } + + public function testRenderWithHexColor() + { + $font = FigletFont::load(self::FONTS_DIR.'/big.flf'); + $renderer = new FigletRenderer($font); + + $lines = $renderer->render('A', '#ff5500'); + + $this->assertNotSame([], $lines); + foreach ($lines as $line) { + if ('' === $line) { + continue; + } + $this->assertStringContainsString('38;2;255;85;0', $line); + } + } + + public function testRenderWithColorPreservesContent() + { + $font = FigletFont::load(self::FONTS_DIR.'/big.flf'); + $renderer = new FigletRenderer($font); + + $plain = $renderer->render('A'); + $colored = $renderer->render('A', 'red'); + + $this->assertCount(\count($plain), $colored); + + // Stripping ANSI codes should give back the plain text + foreach ($colored as $i => $line) { + $stripped = preg_replace('/\x1b\[[0-9;]*m/', '', $line); + $this->assertSame($plain[$i], $stripped); + } + } + + public function testRenderWithColorEmptyLinesStayEmpty() + { + $font = FigletFont::load(self::FONTS_DIR.'/big.flf'); + $renderer = new FigletRenderer($font); + + $lines = $renderer->render('A', 'cyan'); + + // If any blank lines exist in the output, they should remain empty + // (no color wrapping) so they stay transparent in Compositor + foreach ($lines as $line) { + if (!str_contains($line, "\x1b")) { + $this->assertSame('', $line); + } + } + } + + public function testRenderWithNullColorReturnsPlain() + { + $font = FigletFont::load(self::FONTS_DIR.'/big.flf'); + $renderer = new FigletRenderer($font); + + $plain = $renderer->render('A'); + $withNull = $renderer->render('A', null); + + $this->assertSame($plain, $withNull); + } + + /** + * @param string[] $lines + */ + private function maxLineLength(array $lines): int + { + $lengths = array_map('strlen', $lines); + + return [] !== $lengths ? max($lengths) : 0; + } +} diff --git a/src/Symfony/Component/Tui/Tests/Widget/Figlet/FontRegistryTest.php b/src/Symfony/Component/Tui/Tests/Widget/Figlet/FontRegistryTest.php new file mode 100644 index 0000000000000..ea698b14b56de --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Widget/Figlet/FontRegistryTest.php @@ -0,0 +1,102 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Widget\Figlet; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Exception\InvalidArgumentException; +use Symfony\Component\Tui\Widget\Figlet\FontRegistry; + +class FontRegistryTest extends TestCase +{ + public function testGetBundledFont() + { + $registry = new FontRegistry(); + + $font = $registry->get('big'); + + $this->assertSame(8, $font->getHeight()); + } + + public function testRegisterCustomFont() + { + $registry = new FontRegistry(); + $fontsDir = \dirname(__DIR__, 3).'/Widget/Figlet/fonts'; + + $registry->register('my-font', $fontsDir.'/small.flf'); + + $this->assertTrue($registry->has('my-font')); + $font = $registry->get('my-font'); + $this->assertSame(5, $font->getHeight()); // small font is 5 tall + } + + public function testRegisterOverridesBundled() + { + $registry = new FontRegistry(); + $fontsDir = \dirname(__DIR__, 3).'/Widget/Figlet/fonts'; + + // Override 'big' with 'small' font file + $registry->register('big', $fontsDir.'/small.flf'); + + $font = $registry->get('big'); + $this->assertSame(5, $font->getHeight()); // small font height, not big's 8 + } + + public function testReRegisterInvalidatesCache() + { + $registry = new FontRegistry(); + $fontsDir = \dirname(__DIR__, 3).'/Widget/Figlet/fonts'; + + // Load big font (cached) + $big = $registry->get('big'); + $this->assertSame(8, $big->getHeight()); + + // Re-register with small font file + $registry->register('big', $fontsDir.'/small.flf'); + + // Should load the new file, not return cached + $small = $registry->get('big'); + $this->assertSame(5, $small->getHeight()); + } + + public function testGetUnregisteredFontThrows() + { + $registry = new FontRegistry(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Font "unknown" is not registered'); + + $registry->get('unknown'); + } + + public function testGetUnregisteredFontExceptionListsAvailable() + { + $registry = new FontRegistry(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('big, small, slant, standard, mini'); + + $registry->get('unknown'); + } + + public function testInvalidPathThrowsOnGet() + { + $registry = new FontRegistry(); + $registry->register('broken', '/nonexistent/path.flf'); + + // Registration succeeds (lazy loading) + $this->assertTrue($registry->has('broken')); + + // Loading fails + $this->expectException(InvalidArgumentException::class); + $registry->get('broken'); + } +} diff --git a/src/Symfony/Component/Tui/Tests/Widget/FigletTest.php b/src/Symfony/Component/Tui/Tests/Widget/FigletTest.php new file mode 100644 index 0000000000000..2cf4a76d36fc5 --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Widget/FigletTest.php @@ -0,0 +1,294 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Widget; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Exception\InvalidArgumentException; +use Symfony\Component\Tui\Render\RenderContext; +use Symfony\Component\Tui\Render\Renderer; +use Symfony\Component\Tui\Style\Style; +use Symfony\Component\Tui\Style\StyleSheet; +use Symfony\Component\Tui\Style\TailwindStylesheet; +use Symfony\Component\Tui\Widget\ContainerWidget; +use Symfony\Component\Tui\Widget\Figlet\FontRegistry; +use Symfony\Component\Tui\Widget\TextWidget; + +class FigletTest extends TestCase +{ + #[DataProvider('fontProvider')] + public function testRenderWithFont(string $font, int $expectedLines) + { + $widget = new TextWidget('Hi'); + $lines = $this->renderWidgetWithFont($widget, $font, 80, 24); + + $this->assertCount($expectedLines, $lines); + } + + /** + * @return iterable + */ + public static function fontProvider(): iterable + { + yield 'big' => ['big', 6]; + yield 'small' => ['small', 4]; + yield 'slant' => ['slant', 5]; + yield 'standard' => ['standard', 5]; + yield 'mini' => ['mini', 3]; + } + + public function testRenderEmptyText() + { + $widget = new TextWidget(''); + $lines = $widget->render(new RenderContext(80, 24, new Style(font: 'big'))); + + $this->assertSame([], $lines); + } + + public function testRenderWhitespaceOnlyText() + { + $widget = new TextWidget(' '); + $lines = $widget->render(new RenderContext(80, 24, new Style(font: 'big'))); + + $this->assertSame([], $lines); + } + + public function testTruncatesWhenTooWide() + { + $widget = new TextWidget('Hello World!'); + $width = 30; + $lines = $widget->render(new RenderContext($width, 24, new Style(font: 'big'))); + + foreach ($lines as $line) { + $this->assertLessThanOrEqual($width, AnsiUtils::visibleWidth($line)); + } + } + + public function testSetText() + { + $widget = new TextWidget('A'); + $linesA = $this->renderWidgetWithFont($widget, 'big', 80, 24); + + $widget->setText('B'); + $linesB = $this->renderWidgetWithFont($widget, 'big', 80, 24); + + $this->assertNotSame($linesA, $linesB); + } + + public function testChangingFontViaStyle() + { + $widget = new TextWidget('Hi'); + $linesBig = $this->renderWidgetWithFont($widget, 'big', 80, 24); + $linesSmall = $this->renderWidgetWithFont($widget, 'small', 80, 24); + + // Small font should produce fewer lines + $this->assertLessThan(\count($linesBig), \count($linesSmall)); + } + + public function testUnknownFontThrows() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('is not registered'); + + $widget = new TextWidget('Hi'); + $widget->render(new RenderContext(80, 24, new Style(font: 'nonexistent_font'))); + } + + public function testWithoutFontRendersNormalText() + { + $widget = new TextWidget('Hi'); + $lines = $this->renderWidget($widget, 80, 24); + + $this->assertCount(1, $lines); + $this->assertStringContainsString('Hi', AnsiUtils::stripAnsiCodes($lines[0])); + } + + public function testStyleFontSwitchesRendering() + { + $widget = new TextWidget('Ok'); + + // Normal text (no font in style) + $normalLines = $this->renderWidget($widget, 80, 24); + $this->assertCount(1, $normalLines); + + // FIGlet (font in style) + $figletLines = $this->renderWidgetWithFont($widget, 'small', 80, 24); + $this->assertCount(4, $figletLines); + } + + public function testFontFromStylesheet() + { + $stylesheet = new StyleSheet([ + TextWidget::class => new Style(font: 'big'), + ]); + $renderer = new Renderer($stylesheet); + + $root = new ContainerWidget(); + $widget = new TextWidget('Ok'); + $root->add($widget); + + $lines = $renderer->render($root, 80, 24); + + // Should render as FIGlet (multi-line), not normal text + $this->assertCount(6, $lines); + } + + public function testFontFromCssClassRule() + { + $stylesheet = new StyleSheet([ + '.title' => new Style(font: 'small'), + ]); + $renderer = new Renderer($stylesheet); + + $root = new ContainerWidget(); + $widget = new TextWidget('Hi'); + $widget->addStyleClass('title'); + $root->add($widget); + + $lines = $renderer->render($root, 80, 24); + + // Should render as FIGlet + $this->assertCount(4, $lines); + } + + public function testFontFromTailwindUtilityClass() + { + $stylesheet = new TailwindStylesheet(); + $renderer = new Renderer($stylesheet); + + $root = new ContainerWidget(); + $widget = new TextWidget('Hi'); + $widget->addStyleClass('font-small'); + $root->add($widget); + + $lines = $renderer->render($root, 80, 24); + + // Should render as FIGlet + $this->assertCount(4, $lines); + } + + public function testInstanceStyleFontOverridesStylesheet() + { + $stylesheet = new StyleSheet([ + TextWidget::class => new Style(font: 'big'), + ]); + $renderer = new Renderer($stylesheet); + + $root = new ContainerWidget(); + $widget = new TextWidget('Hi'); + // Instance style overrides stylesheet rule + $widget->setStyle(new Style(font: 'small')); + $root->add($widget); + + $lines = $renderer->render($root, 80, 24); + + // Render with small font directly for comparison + $renderer2 = new Renderer(new StyleSheet([ + TextWidget::class => new Style(font: 'small'), + ])); + $root2 = new ContainerWidget(); + $root2->add(new TextWidget('Hi')); + $linesSmall = $renderer2->render($root2, 80, 24); + + // Both should use 'small' font, so same line count + $this->assertCount(\count($linesSmall), $lines); + } + + public function testNoFontRendersNormalTextEvenWithStylesheet() + { + // Stylesheet has no font rule; should render as normal text + $stylesheet = new StyleSheet([ + TextWidget::class => new Style()->withColor('red'), + ]); + $renderer = new Renderer($stylesheet); + + $root = new ContainerWidget(); + $widget = new TextWidget('Hi'); + $root->add($widget); + + $lines = $renderer->render($root, 80, 24); + + // Normal text: single line + $this->assertCount(1, $lines); + } + + public function testCustomRegisteredFont() + { + $fontsDir = \dirname(__DIR__, 2).'/Widget/Figlet/fonts'; + + $fontRegistry = new FontRegistry(); + $fontRegistry->register('my-custom', $fontsDir.'/mini.flf'); + + $stylesheet = new StyleSheet([ + '.banner' => new Style(font: 'my-custom'), + ]); + $renderer = new Renderer($stylesheet, $fontRegistry); + + $root = new ContainerWidget(); + $widget = new TextWidget('Hi'); + $widget->addStyleClass('banner'); + $root->add($widget); + + $lines = $renderer->render($root, 80, 24); + + // Should render as FIGlet using the registered custom font + $this->assertCount(3, $lines); + } + + public function testCustomFontViaTailwindUtility() + { + $fontsDir = \dirname(__DIR__, 2).'/Widget/Figlet/fonts'; + + $fontRegistry = new FontRegistry(); + $fontRegistry->register('my-title', $fontsDir.'/slant.flf'); + + $stylesheet = new TailwindStylesheet(); + $renderer = new Renderer($stylesheet, $fontRegistry); + + $root = new ContainerWidget(); + $widget = new TextWidget('Hi'); + $widget->addStyleClass('font-my-title'); + $root->add($widget); + + $lines = $renderer->render($root, 80, 24); + + // Should render as FIGlet + $this->assertCount(5, $lines); + } + + /** + * @return string[] + */ + private function renderWidget(TextWidget $widget, int $columns, int $rows): array + { + $renderer = new Renderer(); + + return $renderer->renderWidget($widget, new RenderContext($columns, $rows)); + } + + /** + * @return string[] + */ + private function renderWidgetWithFont(TextWidget $widget, string $font, int $columns, int $rows): array + { + $stylesheet = new StyleSheet([ + TextWidget::class => new Style(font: $font), + ]); + $renderer = new Renderer($stylesheet); + + $root = new ContainerWidget(); + $root->add($widget); + + return $renderer->render($root, $columns, $rows); + } +} diff --git a/src/Symfony/Component/Tui/Tests/Widget/InputTest.php b/src/Symfony/Component/Tui/Tests/Widget/InputTest.php new file mode 100644 index 0000000000000..5519ad83f1c35 --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Widget/InputTest.php @@ -0,0 +1,491 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Widget; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Event\CancelEvent; +use Symfony\Component\Tui\Event\ChangeEvent; +use Symfony\Component\Tui\Event\SubmitEvent; +use Symfony\Component\Tui\Render\RenderContext; +use Symfony\Component\Tui\Terminal\VirtualTerminal; +use Symfony\Component\Tui\Tui; +use Symfony\Component\Tui\Widget\InputWidget; + +class InputTest extends TestCase +{ + public function testRenderEmpty() + { + $input = new InputWidget(); + $lines = $input->render(new RenderContext(40, 24)); + + $this->assertCount(1, $lines); + $this->assertStringContainsString('>', $lines[0]); + } + + public function testRenderWithEmptyPrompt() + { + $input = new InputWidget(); + $input->setPrompt(''); + $input->setValue('Hello'); + $lines = $input->render(new RenderContext(40, 24)); + + $this->assertCount(1, $lines); + // Full width available for text, no prompt prefix + $this->assertSame(40, AnsiUtils::visibleWidth($lines[0])); + $stripped = AnsiUtils::stripAnsiCodes($lines[0]); + $this->assertStringNotContainsString('> ', substr($stripped, 0, 2)); + } + + /** + * Regression: prompt width was calculated with strlen(), which returns + * byte count instead of visible column width, breaking layout for + * multi-byte prompts. + * + * @see https://github.com/fabpot/into-the-void/issues/280 + */ + #[DataProvider('promptRenderingProvider')] + public function testRenderWithPrompt(string $prompt, string $value) + { + $input = new InputWidget(); + $input->setPrompt($prompt); + $input->setValue($value); + $lines = $input->render(new RenderContext(40, 24)); + + $this->assertCount(1, $lines); + $this->assertSame(40, AnsiUtils::visibleWidth($lines[0])); + $this->assertStringContainsString($value, AnsiUtils::stripAnsiCodes($lines[0])); + } + + /** + * @return iterable + */ + public static function promptRenderingProvider(): iterable + { + yield 'plain text' => ['Email: ', 'test@example.com']; + yield 'emoji (multi-byte width)' => ['🔍 ', 'search term']; + yield 'ANSI styled' => ["\033[1m> \033[0m", 'styled']; + yield 'CJK characters' => ['検索: ', 'query']; + } + + public function testRenderWithValue() + { + $input = new InputWidget(); + $input->setValue('Hello'); + $lines = $input->render(new RenderContext(40, 24)); + + // The value is rendered with the first char in inverse video (cursor) + // So we check for "ello" (rest of word) and the line contains the value + $this->assertStringContainsString('ello', $lines[0]); + } + + public function testTypeCharacter() + { + $input = new InputWidget(); + $input->handleInput('H'); + $input->handleInput('i'); + + $this->assertSame('Hi', $input->getValue()); + } + + public function testBackspace() + { + $input = new InputWidget(); + $input->setValue('Hello'); + // Move cursor to end first (Ctrl+E) + $input->handleInput("\x05"); + $input->handleInput("\x7f"); // Backspace + + $this->assertSame('Hell', $input->getValue()); + } + + public function testOnSubmitCallback() + { + [$input, $tui] = $this->createInputWithTui(); + + $submitted = null; + $tui->on(SubmitEvent::class, function (SubmitEvent $e) use (&$submitted) { + $submitted = $e->getValue(); + }); + + $input->setValue('test'); + $input->handleInput("\r"); // Enter + + $this->assertSame('test', $submitted); + } + + public function testOnCancelCallback() + { + [$input, $tui] = $this->createInputWithTui(); + + $cancelled = false; + $tui->on(CancelEvent::class, function (CancelEvent $e) use (&$cancelled) { + $cancelled = true; + }); + + $input->handleInput("\x1b"); // Escape + + $this->assertTrue($cancelled); + } + + public function testOnInputCallbackConsumesEvent() + { + $input = new InputWidget(); + + $intercepted = false; + $input->onInput(function (string $data) use (&$intercepted): bool { + if ('x' === $data) { + $intercepted = true; + + return true; + } + + return false; + }); + + $input->handleInput('x'); + $this->assertTrue($intercepted); + $this->assertSame('', $input->getValue(), 'Consumed input should not be typed'); + } + + public function testOnInputCallbackPassesThrough() + { + $input = new InputWidget(); + + $input->onInput(function (string $data): bool { + return 'x' === $data; + }); + + $input->handleInput('y'); + $this->assertSame('y', $input->getValue(), 'Non-consumed input should be typed'); + } + + public function testCursorMovement() + { + $input = new InputWidget(); + $input->setValue('Hello'); + + // Move to start + $input->handleInput("\x01"); // Ctrl+A + + // Type at start + $input->handleInput('X'); + + $this->assertSame('XHello', $input->getValue()); + } + + public function testDeleteWordBackwardWithAltBackspace() + { + $input = new InputWidget(); + $input->setValue('hello world'); + // Move cursor to end + $input->handleInput("\x05"); // Ctrl+E + // Alt+Backspace (legacy: ESC + DEL) + $input->handleInput("\x1b\x7f"); + + $this->assertSame('hello ', $input->getValue()); + } + + public function testDeleteWordBackwardWithCtrlW() + { + $input = new InputWidget(); + $input->setValue('hello world'); + // Move cursor to end + $input->handleInput("\x05"); // Ctrl+E + // Ctrl+W + $input->handleInput("\x17"); + + $this->assertSame('hello ', $input->getValue()); + } + + public function testDeleteToEnd() + { + $input = new InputWidget(); + $input->setValue('Hello World'); + + // Move to position 5 (after "Hello") + $input->handleInput("\x01"); // Start + for ($i = 0; $i < 5; ++$i) { + $input->handleInput("\x1b[C"); // Right arrow + } + + // Delete to end + $input->handleInput("\x0b"); // Ctrl+K + + $this->assertSame('Hello', $input->getValue()); + } + + public function testRenderLineWidth() + { + $input = new InputWidget(); + $input->setValue('Some text'); + $width = 40; + $lines = $input->render(new RenderContext($width, 24)); + + $this->assertSame($width, AnsiUtils::visibleWidth($lines[0])); + } + + public function testFocusable() + { + $input = new InputWidget(); + + $this->assertFalse($input->isFocused()); + + $input->setFocused(true); + $this->assertTrue($input->isFocused()); + + $input->setFocused(false); + $this->assertFalse($input->isFocused()); + } + + public function testOnChangeCallback() + { + [$input, $tui] = $this->createInputWithTui(); + + $changedValue = null; + $tui->on(ChangeEvent::class, function (ChangeEvent $e) use (&$changedValue) { + $changedValue = $e->getValue(); + }); + + $input->handleInput('X'); + + $this->assertSame('X', $changedValue); + } + + /** + * UTF-8 REGRESSION TESTS - Prevent invalid multi-byte character handling. + * These tests ensure that cursor positioning and text manipulation + * always work with complete graphemes, not byte boundaries. + * + * @see https://github.com/fabpot/into-the-void/issues/XXX + */ + public function testUtf8BackspaceAfterSetValue() + { + // Regression: setValue() was using min() for cursor, keeping it at 0 + $input = new InputWidget(); + $input->setValue('café'); + $input->handleInput("\x7f"); // Backspace + + $this->assertSame('caf', $input->getValue()); + // Verify no invalid UTF-8 + $this->assertValidUtf8($input->getValue(), 'Value should be valid UTF-8'); + } + + public function testUtf8CursorMovementLeft() + { + // Regression: moveCursorLeft used byte decrement, could land mid-character + $input = new InputWidget(); + $input->setValue('café'); + $input->handleInput("\x1b[D"); // Left arrow + $input->handleInput("\x7f"); // Backspace (delete char before cursor) + + $this->assertSame('caé', $input->getValue()); + } + + public function testUtf8MultipleBackspaces() + { + $input = new InputWidget(); + $input->setValue('café'); + + // Delete all characters one by one + $input->handleInput("\x7f"); + $this->assertSame('caf', $input->getValue()); + + $input->handleInput("\x7f"); + $this->assertSame('ca', $input->getValue()); + + $input->handleInput("\x7f"); + $this->assertSame('c', $input->getValue()); + + $input->handleInput("\x7f"); + $this->assertSame('', $input->getValue()); + } + + public function testUtf8TypeMultibyte() + { + $input = new InputWidget(); + $input->handleInput('h'); + $input->handleInput('i'); + $input->handleInput('é'); + $input->handleInput('s'); + + $this->assertSame('hiés', $input->getValue()); + } + + /** + * @return iterable, string}> + */ + public static function utf8DeleteRangeProvider(): iterable + { + yield 'delete to start from middle' => [["\x01", "\x1b[C", "\x1b[C", "\x15"], 'fé']; + yield 'delete to end from start' => [["\x01", "\x0b"], '']; + } + + /** + * @param list $inputs + */ + #[DataProvider('utf8DeleteRangeProvider')] + public function testUtf8DeleteRange(array $inputs, string $expected) + { + $input = new InputWidget(); + $input->setValue('café'); + + foreach ($inputs as $key) { + $input->handleInput($key); + } + + $this->assertSame($expected, $input->getValue()); + } + + public function testUtf8WordMovement() + { + $input = new InputWidget(); + $input->setValue('hello café'); + // Move to end + $input->handleInput("\x05"); // Ctrl+E (end) + + // Delete to start should delete everything + $input->handleInput("\x15"); // Ctrl+U (delete to start) + $this->assertSame('', $input->getValue()); + } + + public function testUtf8ControlCharactersIgnored() + { + // Verify that only printable characters are inserted + $input = new InputWidget(); + $input->handleInput('h'); + $input->handleInput('e'); + $input->handleInput("\x00"); // NULL byte - should be ignored + $input->handleInput('l'); + + $this->assertSame('hel', $input->getValue()); + } + + #[DataProvider('utf8DeletionProvider')] + public function testUtf8Deletion(string $initial, string $expected) + { + $widget = new InputWidget(); + $widget->setValue($initial); + $widget->handleInput("\x7f"); + $this->assertSame($expected, $widget->getValue()); + $this->assertValidUtf8($widget->getValue()); + } + + /** + * @return iterable + */ + public static function utf8DeletionProvider(): iterable + { + yield 'latin accent' => ['café', 'caf']; + yield 'multiple accents' => ['café naïve', 'café naïv']; + yield 'emoji' => ['hello👋', 'hello']; + yield 'japanese' => ['日本', '日']; + yield 'chinese' => ['中文', '中']; + yield 'korean' => ['한글', '한']; + yield 'mixed latin+emoji' => ['hi👋bye', 'hi👋by']; + yield 'mixed scripts' => ['café日本', 'café日']; + } + + /** + * Regression: render() used single-byte array access to extract the cursor + * character, splitting multi-byte UTF-8 characters. + * + * @see https://github.com/fabpot/into-the-void/issues/268 + */ + public function testRenderDoesNotSplitMultiByteCharacterAtCursor() + { + $input = new InputWidget(); + $input->setValue('café'); + + // Cursor is at end (byte offset 5), move left to land on 'é' + $input->handleInput("\x1b[D"); // Left arrow + + $lines = $input->render(new RenderContext(40, 24)); + $stripped = AnsiUtils::stripAnsiCodes($lines[0]); + + // The rendered line must contain valid UTF-8 (no broken bytes) + $this->assertValidUtf8($stripped, 'Rendered line contains invalid UTF-8'); + // 'é' should appear intact, not split into broken bytes + $this->assertStringContainsString('é', $stripped); + } + + /** + * @see https://github.com/fabpot/into-the-void/issues/268 + */ + public function testRenderDoesNotSplitEmojiAtCursor() + { + $input = new InputWidget(); + $input->setValue('hi👋'); + + // Cursor is at end, move left to land on the emoji + $input->handleInput("\x1b[D"); // Left arrow + + $lines = $input->render(new RenderContext(40, 24)); + $stripped = AnsiUtils::stripAnsiCodes($lines[0]); + + $this->assertValidUtf8($stripped, 'Rendered line contains invalid UTF-8'); + $this->assertStringContainsString('👋', $stripped); + } + + public function testRenderScrollingWithCJKCharacters() + { + $input = new InputWidget(); + // CJK characters are 2 columns wide each; 10 chars = 20 columns display width + $input->setValue('日本語漢字中文韓國語言'); + + // Render with width 12 (prompt "> " = 2 cols, available = 10) + // Only 5 CJK chars fit (5 × 2 = 10 columns) + $lines = $input->render(new RenderContext(12, 24)); + $stripped = AnsiUtils::stripAnsiCodes($lines[0]); + + $this->assertValidUtf8($stripped, 'Rendered line contains invalid UTF-8'); + $this->assertSame(12, AnsiUtils::visibleWidth($lines[0])); + } + + public function testRenderScrollingWithMixedWidthCharacters() + { + $input = new InputWidget(); + // Mix of ASCII (1 col), accented (1 col), CJK (2 col), emoji (2 col) + $input->setValue('café日本👋hello世界🎉test'); + + // Move cursor to middle + $input->handleInput("\x01"); // Home + for ($i = 0; $i < 6; ++$i) { + $input->handleInput("\x1b[C"); // Right arrow + } + + $lines = $input->render(new RenderContext(15, 24)); + $stripped = AnsiUtils::stripAnsiCodes($lines[0]); + + $this->assertValidUtf8($stripped, 'Rendered line contains invalid UTF-8'); + $this->assertLessThanOrEqual(15, AnsiUtils::visibleWidth($lines[0])); + } + + private function assertValidUtf8(string $value, string $message = ''): void + { + $this->assertTrue(mb_check_encoding($value, 'UTF-8'), $message ?: 'Value should be valid UTF-8'); + } + + /** + * @return array{InputWidget, Tui} + */ + private function createInputWithTui(): array + { + $terminal = new VirtualTerminal(80, 24); + $tui = new Tui(terminal: $terminal); + $input = new InputWidget(); + $tui->add($input); + + return [$input, $tui]; + } +} diff --git a/src/Symfony/Component/Tui/Tests/Widget/LoaderTest.php b/src/Symfony/Component/Tui/Tests/Widget/LoaderTest.php new file mode 100644 index 0000000000000..e06fe58c80dab --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Widget/LoaderTest.php @@ -0,0 +1,217 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Widget; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Exception\InvalidArgumentException; +use Symfony\Component\Tui\Render\RenderContext; +use Symfony\Component\Tui\Style\Style; +use Symfony\Component\Tui\Style\StyleSheet; +use Symfony\Component\Tui\Terminal\VirtualTerminal; +use Symfony\Component\Tui\Tui; +use Symfony\Component\Tui\Widget\LoaderWidget; + +class LoaderTest extends TestCase +{ + public function testRenderIncludesBlankLine() + { + $loader = new LoaderWidget(message: 'Test'); + + $lines = $loader->render(new RenderContext(80, 24)); + + // First line should be blank, second line has the spinner + message + $this->assertSame('', $lines[0]); + $this->assertCount(2, $lines); + } + + public function testRenderIncludesSpinnerAndMessage() + { + $loader = new LoaderWidget(message: 'Working...'); + + $lines = $loader->render(new RenderContext(80, 24)); + $content = implode('', $lines); + + // Should contain the message + $this->assertStringContainsString('Working...', $content); + // Should contain a spinner character (one of the braille frames) + $this->assertMatchesRegularExpression('/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/', $content, 'Render output should contain a spinner frame'); + } + + public function testSubElementStylesFromStylesheet() + { + // Verify that LoaderWidget resolves ::spinner and ::message sub-elements + // from the stylesheet when rendered with a Tui context + $terminal = new VirtualTerminal(80, 24); + $loader = new LoaderWidget(message: 'Test'); + + $stylesheet = new StyleSheet([ + LoaderWidget::class.'::spinner' => new Style()->withBold(), + LoaderWidget::class.'::message' => new Style()->withUnderline(), + ]); + + $tui = new Tui($stylesheet, $terminal); + $tui->add($loader); + + $lines = $loader->render(new RenderContext(80, 24)); + $content = implode('', $lines); + + $this->assertStringContainsString("\x1b[1m", $content); + $this->assertStringContainsString("\x1b[4m", $content); + $this->assertStringContainsString('Test', $content); + } + + public function testSetSpinnerStyle() + { + $loader = new LoaderWidget(message: 'Test'); + $loader->setSpinner('line'); + + $lines = $loader->render(new RenderContext(80, 24)); + $content = implode('', $lines); + + $this->assertStringContainsString('-', $content); + $this->assertStringContainsString('Test', $content); + } + + public function testUnknownSpinnerStyleThrows() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown loader style "nope".'); + + $loader = new LoaderWidget(); + $loader->setSpinner('nope'); + } + + public function testAddSpinnerStyle() + { + LoaderWidget::addSpinner('custom', ['A', 'B', 'C']); + + $loader = new LoaderWidget(message: 'Test'); + $loader->setSpinner('custom'); + + $lines = $loader->render(new RenderContext(80, 24)); + $content = implode('', $lines); + + $this->assertStringContainsString('A', $content); + $this->assertStringContainsString('Test', $content); + } + + public function testAddSpinnerStyleRequiresAtLeastTwo() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Must have at least 2 indicator frame characters.'); + + LoaderWidget::addSpinner('bad', ['X']); + } + + public function testFinishedIndicatorShownAfterStop() + { + $loader = new LoaderWidget(message: 'Done!'); + $loader->setFinishedIndicator('✔'); + $loader->stop(); + + $lines = $loader->render(new RenderContext(80, 24)); + $content = implode('', $lines); + + $this->assertStringContainsString('✔', $content); + $this->assertStringContainsString('Done!', $content); + } + + public function testDefaultFinishedIndicatorRendersEmpty() + { + $loader = new LoaderWidget(message: 'Test'); + $loader->stop(); + + $lines = $loader->render(new RenderContext(80, 24)); + + $this->assertSame([], $lines); + } + + public function testStartResetsFinishedState() + { + $loader = new LoaderWidget(message: 'Test'); + $loader->setFinishedIndicator('✔'); + $loader->stop(); + $loader->start(); + + $lines = $loader->render(new RenderContext(80, 24)); + $content = implode('', $lines); + + // Should show spinner frame, not the finished indicator + $this->assertMatchesRegularExpression('/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/', $content); + $this->assertStringNotContainsString('✔', $content); + } + + public function testTickAdvancesWithElapsedDelta() + { + $loader = new LoaderWidget(message: 'Test'); + $loader->setSpinner('line'); + $loader->setIntervalMs(80); + + $loader->tick(0.24); + $this->assertSame('/', $loader->getSpinnerFrame()); + } + + public function testSetIntervalMustBePositive() + { + $loader = new LoaderWidget(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Interval must be greater than 0'); + $loader->setIntervalMs(0); + } + + public function testAttachSchedulesTickWhenRunning() + { + $terminal = new VirtualTerminal(80, 24); + $tui = new Tui(terminal: $terminal); + $loader = new LoaderWidget(); + $tui->add($loader); + + $this->assertNotNull($this->getScheduledTickId($loader)); + } + + public function testDetachClearsScheduledTickId() + { + $terminal = new VirtualTerminal(80, 24); + $tui = new Tui(terminal: $terminal); + $loader = new LoaderWidget(); + $tui->add($loader); + $this->assertNotNull($this->getScheduledTickId($loader)); + + $tui->remove($loader); + $this->assertNull($this->getScheduledTickId($loader)); + } + + public function testSetIntervalReschedulesTickWhenRunningAndAttached() + { + $terminal = new VirtualTerminal(80, 24); + $tui = new Tui(terminal: $terminal); + $loader = new LoaderWidget(); + $tui->add($loader); + + $firstId = $this->getScheduledTickId($loader); + $this->assertNotNull($firstId); + + $loader->setIntervalMs(120); + + $secondId = $this->getScheduledTickId($loader); + $this->assertNotNull($secondId); + $this->assertNotSame($firstId, $secondId); + } + + private function getScheduledTickId(LoaderWidget $loader): ?string + { + $property = new \ReflectionProperty($loader, 'scheduledTickId'); + + return $property->getValue($loader); + } +} diff --git a/src/Symfony/Component/Tui/Tests/Widget/MarkdownTest.php b/src/Symfony/Component/Tui/Tests/Widget/MarkdownTest.php new file mode 100644 index 0000000000000..3514c9005538d --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Widget/MarkdownTest.php @@ -0,0 +1,207 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Widget; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Render\RenderContext; +use Symfony\Component\Tui\Render\Renderer; +use Symfony\Component\Tui\Style\Style; +use Symfony\Component\Tui\Terminal\VirtualTerminal; +use Symfony\Component\Tui\Tui; +use Symfony\Component\Tui\Widget\MarkdownWidget; + +class MarkdownTest extends TestCase +{ + public function testRenderEmpty() + { + $md = $this->createMarkdown(''); + $lines = $md->render(new RenderContext(40, 24)); + + $this->assertSame([], $lines); + } + + /** + * @param list $expectedSubstrings substrings to find in the joined output + */ + #[DataProvider('markdownElementProvider')] + public function testRenderElement(string $markdown, int $width, array $expectedSubstrings) + { + $md = $this->createMarkdown($markdown); + $lines = $md->render(new RenderContext($width, 24)); + + $content = implode("\n", $lines); + foreach ($expectedSubstrings as $expected) { + $this->assertStringContainsString($expected, $content); + } + } + + /** + * @return iterable}> + */ + public static function markdownElementProvider(): iterable + { + yield 'plain text' => ['Hello World', 40, ['Hello World']]; + yield 'heading' => ['# Heading', 40, ['# Heading']]; + yield 'bold (ANSI code)' => ['This is **bold** text', 60, ["\x1b[1m", 'bold']]; + yield 'italic (ANSI code)' => ['This is *italic* text', 60, ["\x1b[3m"]]; + yield 'inline code' => ['Use `code` here', 60, ['code']]; + yield 'code block' => ["```php\necho 'hello';\n```", 40, ['echo']]; + yield 'blockquote' => ['> This is a quote', 40, ['This is a quote']]; + yield 'unordered list' => ["- Item 1\n- Item 2", 40, ['Item 1', 'Item 2']]; + yield 'horizontal rule' => ['---', 40, ['─']]; + yield 'table' => ["| A | B |\n| - | - |\n| 1 | 2 |", 40, ['A', '1', '┌']]; + yield 'link' => ['[Click here](https://example.com)', 60, ['Click here', 'example.com']]; + } + + public function testRenderWithPadding() + { + $md = $this->createMarkdown('Hello'); + $md->setStyle(Style::padding([1, 2])); + $lines = $this->renderThroughRenderer($md, 40, 24); + + // Should have top + content + bottom = 3 lines + $this->assertSame(3, \count($lines)); + } + + public function testAllLinesRespectWidth() + { + $text = "# Heading\n\nThis is a paragraph with **bold** and *italic* text.\n\n- List item 1\n- List item 2\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n> Blockquote"; + $md = $this->createMarkdown($text)->setStyle(Style::padding([0, 1])); + $width = 50; + $lines = $this->renderThroughRenderer($md, $width, 24); + + foreach ($lines as $i => $line) { + $lineWidth = AnsiUtils::visibleWidth($line); + $this->assertLessThanOrEqual( + $width, + $lineWidth, + \sprintf('Line %d exceeds width: %d > %d', $i, $lineWidth, $width), + ); + } + } + + public function testSetTextSanitizesInvalidUtf8() + { + // "\x80" is an invalid UTF-8 continuation byte on its own + $md = $this->createMarkdown("Hello \x80World"); + $this->assertSame('Hello World', $md->getText()); + + // Also via setText() + $md->setText("Foo \xC0\xC1 Bar"); + $this->assertSame('Foo Bar', $md->getText()); + } + + public function testSetTextPreservesValidUtf8() + { + $text = 'Hello 😀 World; café'; + $md = $this->createMarkdown($text); + $this->assertSame($text, $md->getText()); + } + + public function testGetTextIsStableAcrossRenders() + { + $md = $this->createMarkdown("Hello \x80World"); + $textBefore = $md->getText(); + $md->render(new RenderContext(40, 24)); + $this->assertSame($textBefore, $md->getText()); + } + + public function testCacheInvalidation() + { + $md = $this->createMarkdown('Hello'); + $lines1 = $md->render(new RenderContext(40, 24)); + + $md->setText('World'); + $lines2 = $md->render(new RenderContext(40, 24)); + + $this->assertStringContainsString('Hello', $lines1[0]); + $this->assertStringContainsString('World', $lines2[0]); + } + + public function testStripHtmlTags() + { + $md = $this->createMarkdown('Hello world test'); + $lines = $md->render(new RenderContext(60, 24)); + + $content = implode('', $lines); + $this->assertStringNotContainsString('', $content); + $this->assertStringNotContainsString('', $content); + $this->assertStringContainsString('world', $content); + } + + public function testLongLinesAreWrapped() + { + // Test that very long lines are properly wrapped to fit within width + $longText = str_repeat('word ', 100); + $md = $this->createMarkdown($longText)->setStyle(Style::padding([0, 1])); + + $width = 80; + $lines = $this->renderThroughRenderer($md, $width, 24); + + foreach ($lines as $i => $line) { + $lineWidth = AnsiUtils::visibleWidth($line); + $this->assertLessThanOrEqual( + $width, + $lineWidth, + \sprintf('Line %d exceeds width: %d > %d (long text wrapping)', $i, $lineWidth, $width), + ); + } + } + + public function testCodeBlockWithLongLines() + { + // Test that code blocks with very long lines don't exceed width + $longCode = str_repeat('x', 200); + $text = "```\n{$longCode}\n```"; + $md = $this->createMarkdown($text)->setStyle(Style::padding([0, 1])); + + $width = 100; + $lines = $this->renderThroughRenderer($md, $width, 24); + + foreach ($lines as $i => $line) { + $lineWidth = AnsiUtils::visibleWidth($line); + $this->assertLessThanOrEqual( + $width, + $lineWidth, + \sprintf('Line %d exceeds width: %d > %d (code block with long line)', $i, $lineWidth, $width), + ); + } + } + + /** + * Create a MarkdownWidget attached to a Tui context so stylesheet + * sub-element resolution (::bold, ::italic, etc.) works. + */ + private function createMarkdown(string $text): MarkdownWidget + { + $terminal = new VirtualTerminal(80, 24); + $tui = new Tui(terminal: $terminal); + $md = new MarkdownWidget($text); + $tui->add($md); + + return $md; + } + + /** + * Render a widget through the Renderer pipeline to get full chrome applied. + * + * @return string[] + */ + private function renderThroughRenderer(MarkdownWidget $widget, int $columns, int $rows): array + { + $renderer = new Renderer(); + + return $renderer->renderWidget($widget, new RenderContext($columns, $rows)); + } +} diff --git a/src/Symfony/Component/Tui/Tests/Widget/ProgressBarTest.php b/src/Symfony/Component/Tui/Tests/Widget/ProgressBarTest.php new file mode 100644 index 0000000000000..690b9a0811f4b --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Widget/ProgressBarTest.php @@ -0,0 +1,527 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Widget; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Render\RenderContext; +use Symfony\Component\Tui\Style\Style; +use Symfony\Component\Tui\Style\StyleSheet; +use Symfony\Component\Tui\Terminal\VirtualTerminal; +use Symfony\Component\Tui\Tui; +use Symfony\Component\Tui\Widget\ProgressBarWidget; + +/** + * @covers \Symfony\Component\Tui\Widget\ProgressBarWidget + */ +class ProgressBarTest extends TestCase +{ + public function testConstructionDeterminate() + { + $bar = new ProgressBarWidget(100); + + $this->assertSame(100, $bar->getMaxSteps()); + $this->assertSame(0, $bar->getProgress()); + $this->assertSame(0.0, $bar->getProgressPercent()); + } + + public function testConstructionIndeterminate() + { + $bar = new ProgressBarWidget(); + + $this->assertSame(0, $bar->getMaxSteps()); + $this->assertSame(0, $bar->getProgress()); + } + + public function testAdvance() + { + $bar = new ProgressBarWidget(100); + $bar->advance(10); + + $this->assertSame(10, $bar->getProgress()); + $this->assertEqualsWithDelta(0.1, $bar->getProgressPercent(), 0.001); + + $bar->advance(5); + $this->assertSame(15, $bar->getProgress()); + } + + public function testSetProgress() + { + $bar = new ProgressBarWidget(100); + $bar->setProgress(50); + + $this->assertSame(50, $bar->getProgress()); + $this->assertEqualsWithDelta(0.5, $bar->getProgressPercent(), 0.001); + } + + public function testSetProgressBeyondMaxExtendsMax() + { + $bar = new ProgressBarWidget(50); + $bar->setProgress(75); + + $this->assertSame(75, $bar->getMaxSteps()); + $this->assertSame(75, $bar->getProgress()); + } + + public function testSetProgressNegativeClampsToZero() + { + $bar = new ProgressBarWidget(100); + $bar->setProgress(50); + $bar->setProgress(-10); + + $this->assertSame(0, $bar->getProgress()); + } + + public function testFinish() + { + $bar = new ProgressBarWidget(100); + $bar->advance(30); + $bar->finish(); + + $this->assertSame(100, $bar->getProgress()); + $this->assertEqualsWithDelta(1.0, $bar->getProgressPercent(), 0.001); + $this->assertFalse($bar->isRunning()); + } + + public function testFinishIndeterminate() + { + $bar = new ProgressBarWidget(); + $bar->advance(42); + $bar->finish(); + + $this->assertSame(42, $bar->getMaxSteps()); + $this->assertSame(42, $bar->getProgress()); + $this->assertFalse($bar->isRunning()); + } + + public function testStartResetsState() + { + $bar = new ProgressBarWidget(100); + $bar->advance(50); + $bar->start(200, 10); + + $this->assertSame(200, $bar->getMaxSteps()); + $this->assertSame(10, $bar->getProgress()); + $this->assertTrue($bar->isRunning()); + } + + public function testStartKeepsMaxIfNull() + { + $bar = new ProgressBarWidget(100); + $bar->start(); + + $this->assertSame(100, $bar->getMaxSteps()); + $this->assertSame(0, $bar->getProgress()); + } + + public function testBarWidth() + { + $bar = new ProgressBarWidget(100); + $bar->setBarWidth(40); + + $this->assertSame(40, $bar->getBarWidth()); + } + + public function testBarWidthMinimumIsOne() + { + $bar = new ProgressBarWidget(100); + $bar->setBarWidth(0); + + $this->assertSame(1, $bar->getBarWidth()); + } + + public function testBarCharacters() + { + $bar = new ProgressBarWidget(100); + $bar->setBarCharacter('='); + $bar->setEmptyBarCharacter('-'); + $bar->setProgressCharacter('>'); + + $this->assertSame('=', $bar->getBarCharacter()); + $this->assertSame('-', $bar->getEmptyBarCharacter()); + $this->assertSame('>', $bar->getProgressCharacter()); + } + + public function testFormat() + { + $bar = new ProgressBarWidget(100); + $bar->setFormat('Progress: %percent%%'); + + $this->assertSame('Progress: %percent%%', $bar->getFormat()); + } + + public function testMessages() + { + $bar = new ProgressBarWidget(100); + $bar->setMessage('Downloading files...'); + + $this->assertSame('Downloading files...', $bar->getMessage()); + $this->assertNull($bar->getMessage('other')); + } + + public function testNamedMessages() + { + $bar = new ProgressBarWidget(100); + $bar->setMessage('file.txt', 'filename'); + + $this->assertSame('file.txt', $bar->getMessage('filename')); + } + + public function testRenderDeterminate() + { + $bar = new ProgressBarWidget(100); + $bar->setBarWidth(10); + $bar->setBarCharacter('='); + $bar->setEmptyBarCharacter('-'); + $bar->setProgressCharacter('>'); + + $bar->setProgress(50); + + $lines = $bar->render(new RenderContext(80, 24)); + $content = $lines[0]; + + $this->assertStringContainsString('50/100', $content); + $this->assertStringContainsString('50%', $content); + $this->assertStringContainsString('=====', $content); + $this->assertStringContainsString('>', $content); + $this->assertStringContainsString('----', $content); + } + + public function testRenderComplete() + { + $bar = new ProgressBarWidget(10); + $bar->setBarWidth(10); + $bar->setBarCharacter('='); + $bar->setEmptyBarCharacter('-'); + $bar->setProgressCharacter('>'); + + $bar->setProgress(10); + + $lines = $bar->render(new RenderContext(80, 24)); + $content = $lines[0]; + + // At 100%, no empty bar characters + $this->assertStringContainsString('100%', $content); + $this->assertStringContainsString('==========', $content); + $this->assertStringNotContainsString('-', $content); + } + + public function testRenderIndeterminate() + { + $bar = new ProgressBarWidget(); + $bar->setBarWidth(10); + + $bar->advance(3); + + $lines = $bar->render(new RenderContext(80, 24)); + $content = $lines[0]; + + // Should show current count, no percentage or max + $this->assertStringContainsString('3', $content); + $this->assertStringNotContainsString('%', $content); + } + + public function testRenderWithMessagePlaceholder() + { + $bar = new ProgressBarWidget(100); + $bar->setFormat('%message% %percent%%'); + $bar->setMessage('Installing...'); + + $bar->setProgress(25); + + $lines = $bar->render(new RenderContext(80, 24)); + $content = $lines[0]; + + $this->assertStringContainsString('Installing...', $content); + $this->assertStringContainsString('25%', $content); + } + + public function testCustomPlaceholderFormatter() + { + $bar = new ProgressBarWidget(100); + $bar->setFormat('%custom%'); + $bar->setPlaceholderFormatter('custom', fn (ProgressBarWidget $b) => 'step-'.$b->getProgress()); + + $bar->setProgress(7); + + $lines = $bar->render(new RenderContext(80, 24)); + $content = $lines[0]; + + $this->assertStringContainsString('step-7', $content); + } + + public function testDefaultPlaceholderFormatter() + { + ProgressBarWidget::setDefaultPlaceholderFormatter('global_test', fn (ProgressBarWidget $b) => 'G'.$b->getProgress()); + + $bar = new ProgressBarWidget(100); + $bar->setFormat('%global_test%'); + $bar->setProgress(5); + + $lines = $bar->render(new RenderContext(80, 24)); + $content = $lines[0]; + + $this->assertStringContainsString('G5', $content); + } + + public function testInstanceFormatterOverridesDefault() + { + ProgressBarWidget::setDefaultPlaceholderFormatter('override_test', fn (ProgressBarWidget $b) => 'DEFAULT'); + + $bar = new ProgressBarWidget(100); + $bar->setFormat('%override_test%'); + $bar->setPlaceholderFormatter('override_test', fn (ProgressBarWidget $b) => 'INSTANCE'); + + $lines = $bar->render(new RenderContext(80, 24)); + $content = $lines[0]; + + $this->assertStringContainsString('INSTANCE', $content); + $this->assertStringNotContainsString('DEFAULT', $content); + } + + public function testUnknownPlaceholderLeftAsIs() + { + $bar = new ProgressBarWidget(100); + $bar->setFormat('%nonexistent%'); + + $lines = $bar->render(new RenderContext(80, 24)); + $content = $lines[0]; + + $this->assertStringContainsString('%nonexistent%', $content); + } + + public function testSetMaxStepsToZeroMakesIndeterminate() + { + $bar = new ProgressBarWidget(100); + $this->assertSame(100, $bar->getMaxSteps()); + + $bar->setMaxSteps(0); + $this->assertSame(0, $bar->getMaxSteps()); + } + + public function testStepWidth() + { + $bar = new ProgressBarWidget(1000); + + $this->assertSame(4, $bar->getStepWidth()); + } + + public function testStepWidthIndeterminate() + { + $bar = new ProgressBarWidget(); + + $this->assertSame(4, $bar->getStepWidth()); + } + + public function testBarOffset() + { + $bar = new ProgressBarWidget(100); + $bar->setBarWidth(20); + $bar->setProgress(50); + + $this->assertSame(10, $bar->getBarOffset()); + } + + public function testBarOffsetIndeterminate() + { + $bar = new ProgressBarWidget(); + $bar->setBarWidth(10); + $bar->advance(13); + + // 13 % 10 = 3 + $this->assertSame(3, $bar->getBarOffset()); + } + + public function testFormatSpecifiers() + { + $bar = new ProgressBarWidget(100); + $bar->setFormat('%percent:3s%%'); + $bar->setProgress(5); + + $lines = $bar->render(new RenderContext(80, 24)); + $content = $lines[0]; + + // %3s should right-pad "5" to " 5" + $this->assertStringContainsString(' 5%', $content); + } + + public function testLineFillsAvailableWidth() + { + $bar = new ProgressBarWidget(100); + $bar->setProgress(50); + + $lines = $bar->render(new RenderContext(60, 24)); + $content = $lines[0]; + + // The rendered line should be padded to 60 columns + $visibleWidth = mb_strwidth(preg_replace('/\x1b\[[^m]*m/', '', $content)); + $this->assertSame(60, $visibleWidth); + } + + public function testTickWhenNotRunningReturnsFalse() + { + $bar = new ProgressBarWidget(100); + $bar->finish(); + + $this->assertFalse($bar->tick()); + } + + public function testTickWhenRunningReturnsTrue() + { + $bar = new ProgressBarWidget(100); + $bar->start(); + + $this->assertTrue($bar->tick()); + } + + public function testSubElementStylesFromStylesheet() + { + $terminal = new VirtualTerminal(80, 24); + $bar = new ProgressBarWidget(100); + $bar->setBarWidth(10); + $bar->setProgress(50); + + $stylesheet = new StyleSheet([ + ProgressBarWidget::class.'::bar-fill' => new Style()->withBold(), + ProgressBarWidget::class.'::bar-empty' => new Style()->withDim(), + ]); + + $tui = new Tui($stylesheet, $terminal); + $tui->add($bar); + + $lines = $bar->render(new RenderContext(80, 24)); + $content = implode('', $lines); + + // Bold for fill, dim for empty + $this->assertStringContainsString("\x1b[1m", $content); + $this->assertStringContainsString("\x1b[2m", $content); + } + + public function testRenderBarShrinkingToFitWidth() + { + $bar = new ProgressBarWidget(100); + $bar->setBarWidth(50); + $bar->setBarCharacter('='); + $bar->setEmptyBarCharacter('-'); + $bar->setProgressCharacter(''); + $bar->setProgress(50); + + // Render in a very narrow width + $lines = $bar->render(new RenderContext(40, 24)); + $content = $lines[0]; + + // Should not exceed 40 columns + $visibleWidth = mb_strwidth(preg_replace('/\x1b\[[^m]*m/', '', $content)); + $this->assertLessThanOrEqual(40, $visibleWidth); + } + + public function testMemoryPlaceholder() + { + $bar = new ProgressBarWidget(100); + $bar->setFormat('%memory%'); + + $lines = $bar->render(new RenderContext(80, 24)); + $content = $lines[0]; + + // Should contain a memory measurement unit + $this->assertTrue( + str_contains($content, 'MiB') + || str_contains($content, 'GiB') + || str_contains($content, 'KiB') + || str_contains($content, 'B'), + 'Memory placeholder should show a unit' + ); + } + + public function testElapsedPlaceholder() + { + $bar = new ProgressBarWidget(100); + $bar->setFormat('%elapsed%'); + $bar->start(); + + $lines = $bar->render(new RenderContext(80, 24)); + $content = $lines[0]; + + // Should contain time like "0:00" + $this->assertMatchesRegularExpression('/\d+:\d{2}/', $content); + } + + public function testPercentAtZeroMax() + { + $bar = new ProgressBarWidget(0); + // Zero max → percent is 0 (indeterminate) + $this->assertSame(0.0, $bar->getProgressPercent()); + } + + public function testEstimatedAndRemainingAtZeroStep() + { + $bar = new ProgressBarWidget(100); + + $this->assertSame(0.0, $bar->getEstimated()); + $this->assertSame(0.0, $bar->getRemaining()); + } + + public function testGetRemainingIndeterminate() + { + $bar = new ProgressBarWidget(); + $bar->advance(10); + + $this->assertSame(0.0, $bar->getRemaining()); + } + + public function testFormatConstants() + { + // Verify format constants contain expected placeholders + $this->assertStringContainsString('%bar%', ProgressBarWidget::FORMAT_NORMAL); + $this->assertStringContainsString('%percent:', ProgressBarWidget::FORMAT_NORMAL); + $this->assertStringContainsString('%current%', ProgressBarWidget::FORMAT_NORMAL); + $this->assertStringContainsString('%max%', ProgressBarWidget::FORMAT_NORMAL); + + $this->assertStringContainsString('%bar%', ProgressBarWidget::FORMAT_INDETERMINATE); + $this->assertStringNotContainsString('%percent%', ProgressBarWidget::FORMAT_INDETERMINATE); + $this->assertStringNotContainsString('%max%', ProgressBarWidget::FORMAT_INDETERMINATE); + + $this->assertStringContainsString('%elapsed:', ProgressBarWidget::FORMAT_VERBOSE); + $this->assertStringContainsString('%estimated:', ProgressBarWidget::FORMAT_VERY_VERBOSE); + $this->assertStringContainsString('%memory:', ProgressBarWidget::FORMAT_DEBUG); + } + + public function testAttachResumesScheduledTickWhenRunning() + { + $terminal = new VirtualTerminal(80, 24); + $tui = new Tui(terminal: $terminal); + $bar = new ProgressBarWidget(100); + $bar->start(); + $tui->add($bar); + + $this->assertNotNull($this->getScheduledTickId($bar)); + } + + public function testDetachClearsScheduledTickId() + { + $terminal = new VirtualTerminal(80, 24); + $tui = new Tui(terminal: $terminal); + $bar = new ProgressBarWidget(100); + $bar->start(); + $tui->add($bar); + $this->assertNotNull($this->getScheduledTickId($bar)); + + $tui->remove($bar); + $this->assertNull($this->getScheduledTickId($bar)); + } + + private function getScheduledTickId(ProgressBarWidget $bar): ?string + { + $property = new \ReflectionProperty($bar, 'scheduledTickId'); + + return $property->getValue($bar); + } +} diff --git a/src/Symfony/Component/Tui/Tests/Widget/SelectListTest.php b/src/Symfony/Component/Tui/Tests/Widget/SelectListTest.php new file mode 100644 index 0000000000000..8625397984d67 --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Widget/SelectListTest.php @@ -0,0 +1,256 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Widget; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Event\CancelEvent; +use Symfony\Component\Tui\Event\SelectEvent; +use Symfony\Component\Tui\Event\SelectionChangeEvent; +use Symfony\Component\Tui\Render\RenderContext; +use Symfony\Component\Tui\Terminal\VirtualTerminal; +use Symfony\Component\Tui\Tui; +use Symfony\Component\Tui\Widget\SelectListWidget; + +class SelectListTest extends TestCase +{ + public function testRenderShowsItems() + { + $list = $this->createTestList(); + $lines = $list->render(new RenderContext(80, 24)); + + $this->assertStringContainsString('Option 1', $lines[0]); + } + + public function testRenderShowsSelectedIndicator() + { + $list = $this->createTestList(); + $lines = $list->render(new RenderContext(80, 24)); + + // First item should have arrow indicator + $this->assertStringContainsString('→', $lines[0]); + } + + public function testNavigateDown() + { + $list = $this->createTestList(); + + $this->assertSame('opt1', $list->getSelectedItem()['value']); + + // Simulate down arrow + $list->handleInput("\x1b[B"); + + $this->assertSame('opt2', $list->getSelectedItem()['value']); + } + + public function testNavigateUp() + { + $list = $this->createTestList(); + $list->setSelectedIndex(2); + + $this->assertSame('opt3', $list->getSelectedItem()['value']); + + // Simulate up arrow + $list->handleInput("\x1b[A"); + + $this->assertSame('opt2', $list->getSelectedItem()['value']); + } + + public function testNavigateWrapsAtBottom() + { + $list = $this->createTestList(); + $list->setSelectedIndex(2); + + // Simulate down arrow at bottom + $list->handleInput("\x1b[B"); + + $this->assertSame('opt1', $list->getSelectedItem()['value']); + } + + public function testNavigateWrapsAtTop() + { + $list = $this->createTestList(); + $list->setSelectedIndex(0); + + // Simulate up arrow at top + $list->handleInput("\x1b[A"); + + $this->assertSame('opt3', $list->getSelectedItem()['value']); + } + + public function testOnSelectCallback() + { + [$list, $tui] = $this->createTestListWithTui(); + + $selectedItem = null; + $tui->on(SelectEvent::class, function (SelectEvent $e) use (&$selectedItem) { + $selectedItem = $e->getItem(); + }); + + // Simulate Enter + $list->handleInput("\r"); + + $this->assertSame('opt1', $selectedItem['value']); + } + + public function testOnCancelCallback() + { + [$list, $tui] = $this->createTestListWithTui(); + + $cancelled = false; + $tui->on(CancelEvent::class, function (CancelEvent $e) use (&$cancelled) { + $cancelled = true; + }); + + // Simulate Escape + $list->handleInput("\x1b"); + + $this->assertTrue($cancelled); + } + + public function testFilter() + { + $list = $this->createTestList(); + + $list->setFilter('opt2'); + + $selected = $list->getSelectedItem(); + $this->assertSame('opt2', $selected['value']); + } + + public function testFilterNoMatch() + { + $list = $this->createTestList(); + + $list->setFilter('nonexistent'); + + $lines = $list->render(new RenderContext(80, 24)); + $this->assertStringContainsString('No matching', $lines[0]); + } + + public function testNormalizesMultilineDescription() + { + $items = [ + [ + 'value' => 'test', + 'label' => 'Test', + 'description' => "Line one\nLine two\nLine three", + ], + ]; + + $list = new SelectListWidget($items, 5); + $lines = $list->render(new RenderContext(100, 24)); + + $this->assertStringNotContainsString("\n", $lines[0]); + $this->assertStringContainsString('Line one Line two Line three', $lines[0]); + } + + public function testRendersWithinWidth() + { + $list = $this->createTestList(); + $width = 60; + $lines = $list->render(new RenderContext($width, 24)); + + foreach ($lines as $i => $line) { + $lineWidth = AnsiUtils::visibleWidth($line); + $this->assertLessThanOrEqual( + $width, + $lineWidth, + \sprintf('Line %d exceeds width: %d > %d', $i, $lineWidth, $width), + ); + } + } + + public function testOnSelectionChangeCallback() + { + [$list, $tui] = $this->createTestListWithTui(); + + $changedItem = null; + $tui->on(SelectionChangeEvent::class, function (SelectionChangeEvent $e) use (&$changedItem) { + $changedItem = $e->getItem(); + }); + + // Navigate down + $list->handleInput("\x1b[B"); + + $this->assertSame('opt2', $changedItem['value']); + } + + #[DataProvider('provideNavigationKeysOnEmptyFilteredItems')] + public function testNavigationOnEmptyFilteredItemsDoesNotCrash(string $input) + { + [$list, $tui] = $this->createTestListWithTui(); + $list->setFilter('nonexistent'); + + $selectionChanged = false; + $tui->on(SelectionChangeEvent::class, function () use (&$selectionChanged) { + $selectionChanged = true; + }); + + $list->handleInput($input); + + $this->assertNull($list->getSelectedItem()); + $this->assertFalse($selectionChanged); + } + + /** + * @return iterable + */ + public static function provideNavigationKeysOnEmptyFilteredItems(): iterable + { + yield 'up arrow' => ["\x1b[A"]; + yield 'down arrow' => ["\x1b[B"]; + yield 'page up' => ["\x1b[5~"]; + yield 'page down' => ["\x1b[6~"]; + yield 'confirm' => ["\r"]; + } + + public function testCancelStillWorksOnEmptyFilteredItems() + { + [$list, $tui] = $this->createTestListWithTui(); + $list->setFilter('nonexistent'); + + $cancelled = false; + $tui->on(CancelEvent::class, function () use (&$cancelled) { + $cancelled = true; + }); + + $list->handleInput("\x1b"); + + $this->assertTrue($cancelled); + } + + private function createTestList(): SelectListWidget + { + $items = [ + ['value' => 'opt1', 'label' => 'Option 1', 'description' => 'First option'], + ['value' => 'opt2', 'label' => 'Option 2', 'description' => 'Second option'], + ['value' => 'opt3', 'label' => 'Option 3', 'description' => 'Third option'], + ]; + + return new SelectListWidget($items, 5); + } + + /** + * @return array{SelectListWidget, Tui} + */ + private function createTestListWithTui(): array + { + $terminal = new VirtualTerminal(80, 24); + $tui = new Tui(terminal: $terminal); + $list = $this->createTestList(); + $tui->add($list); + + return [$list, $tui]; + } +} diff --git a/src/Symfony/Component/Tui/Tests/Widget/SettingsListTest.php b/src/Symfony/Component/Tui/Tests/Widget/SettingsListTest.php new file mode 100644 index 0000000000000..24b83e77345fe --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Widget/SettingsListTest.php @@ -0,0 +1,323 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Widget; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Event\CancelEvent; +use Symfony\Component\Tui\Event\SelectEvent; +use Symfony\Component\Tui\Event\SettingChangeEvent; +use Symfony\Component\Tui\Render\RenderContext; +use Symfony\Component\Tui\Render\Renderer; +use Symfony\Component\Tui\Style\Border; +use Symfony\Component\Tui\Style\Padding; +use Symfony\Component\Tui\Style\Style; +use Symfony\Component\Tui\Terminal\VirtualTerminal; +use Symfony\Component\Tui\Tui; +use Symfony\Component\Tui\Widget\SelectListWidget; +use Symfony\Component\Tui\Widget\SettingItem; +use Symfony\Component\Tui\Widget\SettingsListWidget; + +class SettingsListTest extends TestCase +{ + public function testRenderShowsItems() + { + $widget = new SettingsListWidget($this->createItems()); + $lines = $widget->render(new RenderContext(80, 24)); + + $this->assertStringContainsString('Theme', $lines[0]); + } + + public function testCycleValueRight() + { + [$widget, $tui] = $this->createWithTui(); + + $this->assertSame('dark', $widget->getValue('theme')); + + // Right arrow cycles forward + $widget->handleInput("\x1b[C"); + + $this->assertSame('auto', $widget->getValue('theme')); + } + + public function testCycleValueLeft() + { + [$widget, $tui] = $this->createWithTui(); + + $this->assertSame('dark', $widget->getValue('theme')); + + // Left arrow cycles backward + $widget->handleInput("\x1b[D"); + + $this->assertSame('light', $widget->getValue('theme')); + } + + public function testOnChangeCallback() + { + [$widget, $tui] = $this->createWithTui(); + + $changedId = null; + $changedValue = null; + $tui->on(SettingChangeEvent::class, function (SettingChangeEvent $e) use (&$changedId, &$changedValue) { + $changedId = $e->getId(); + $changedValue = $e->getValue(); + }); + + // Cycle theme value + $widget->handleInput("\x1b[C"); + + $this->assertSame('theme', $changedId); + $this->assertSame('auto', $changedValue); + } + + public function testOnCancelCallback() + { + [$widget, $tui] = $this->createWithTui(); + + $cancelled = false; + $tui->on(CancelEvent::class, function (CancelEvent $e) use (&$cancelled) { + $cancelled = true; + }); + + // Escape + $widget->handleInput("\x1b"); + + $this->assertTrue($cancelled); + } + + public function testNavigateDown() + { + [$widget, $tui] = $this->createWithTui(); + + // Navigate down + $widget->handleInput("\x1b[B"); + + // Cycle the now-selected item (Font Size) + $widget->handleInput("\x1b[C"); + + $this->assertSame('large', $widget->getValue('fontSize')); + } + + public function testSubmenuRenderedThroughRenderer() + { + // Create a settings widget with a submenu that has a border + $submenuWidget = null; + $items = [ + new SettingItem( + id: 'model', + label: 'Model', + currentValue: 'gpt-4', + submenu: function (string $currentValue, callable $onDone) use (&$submenuWidget) { + $list = new SelectListWidget([ + ['value' => 'gpt-4', 'label' => 'GPT-4'], + ['value' => 'claude', 'label' => 'Claude'], + ], 5); + + // Events are wired by SettingsListWidget via the dispatcher + + // Add a border to the submenu to verify it's applied through the Renderer + $list->setStyle(new Style(padding: new Padding(1, 2, 1, 2))); + + $submenuWidget = $list; + + return $list; + }, + ), + ]; + + $widget = new SettingsListWidget($items); + [$widget, $tui] = $this->createWithTui($widget); + + // Activate the submenu + $widget->handleInput("\r"); + + // Verify the submenu is now a child + $this->assertCount(1, $widget->all()); + $this->assertSame($submenuWidget, $widget->all()[0]); + + // Render through the Renderer (which applies chrome) + $renderer = new Renderer(); + $lines = $renderer->renderWidget($widget, new RenderContext(60, 20)); + + // The submenu's padding should have been applied by the Renderer. + // Without the fix, render() would call submenu->render() directly, + // skipping the Renderer pipeline and its chrome application. + // With padding (top: 1, left: 2), the first line should be blank padding + // and content lines should have left padding + $firstNonEmpty = null; + foreach ($lines as $i => $line) { + if ('' !== trim(AnsiUtils::stripAnsiCodes($line))) { + $firstNonEmpty = $i; + break; + } + } + + // With top padding of 1, the first non-empty line should be at index 1 or later + $this->assertGreaterThanOrEqual(1, $firstNonEmpty, 'Submenu padding should be applied by the Renderer'); + } + + public function testSubmenuCloseSetsChildrenEmpty() + { + $onDoneCallback = null; + $items = [ + new SettingItem( + id: 'model', + label: 'Model', + currentValue: 'gpt-4', + submenu: function (string $currentValue, callable $onDone) use (&$onDoneCallback) { + $onDoneCallback = $onDone; + + $list = new SelectListWidget([ + ['value' => 'gpt-4', 'label' => 'GPT-4'], + ], 5); + + // Events are wired by SettingsListWidget via the dispatcher + + return $list; + }, + ), + ]; + + $widget = new SettingsListWidget($items); + [$widget, $tui] = $this->createWithTui($widget); + + // Open submenu + $widget->handleInput("\r"); + $this->assertCount(1, $widget->all()); + + // Close submenu via callback + ($onDoneCallback)(null); + $this->assertSame([], $widget->all()); + } + + public function testDetachClearsActiveSubmenu() + { + $items = [ + new SettingItem( + id: 'model', + label: 'Model', + currentValue: 'gpt-4', + submenu: function (string $currentValue, callable $onDone) { + $list = new SelectListWidget([ + ['value' => 'gpt-4', 'label' => 'GPT-4'], + ['value' => 'claude', 'label' => 'Claude'], + ], 5); + + // Events are wired by SettingsListWidget via the dispatcher + + return $list; + }, + ), + ]; + + $widget = new SettingsListWidget($items); + [$widget, $tui] = $this->createWithTui($widget); + + // Open submenu + $widget->handleInput("\r"); + $this->assertCount(1, $widget->all()); + + // Detach the settings widget (simulating overlay close) + $tui->remove($widget); + + // The active submenu reference should be cleared + $this->assertSame([], $widget->all()); + } + + public function testSubmenuListenersCleanedUpOnDetach() + { + $items = [ + new SettingItem( + id: 'model', + label: 'Model', + currentValue: 'gpt-4', + submenu: function (string $currentValue, callable $onDone) { + return new SelectListWidget([ + ['value' => 'gpt-4', 'label' => 'GPT-4'], + ['value' => 'claude', 'label' => 'Claude'], + ], 5); + }, + ), + ]; + + $widget = new SettingsListWidget($items); + [$widget, $tui] = $this->createWithTui($widget); + + $dispatcher = $tui->getEventDispatcher(); + + $selectBefore = $dispatcher->getListeners(SelectEvent::class); + $cancelBefore = $dispatcher->getListeners(CancelEvent::class); + + // Open submenu; adds listeners to global dispatcher + $widget->handleInput("\r"); + $this->assertCount(\count($selectBefore) + 1, $dispatcher->getListeners(SelectEvent::class)); + $this->assertCount(\count($cancelBefore) + 1, $dispatcher->getListeners(CancelEvent::class)); + + // Detach the settings widget while submenu is open + $tui->remove($widget); + + // Submenu listeners should be cleaned up + $this->assertCount(\count($selectBefore), $dispatcher->getListeners(SelectEvent::class)); + $this->assertCount(\count($cancelBefore), $dispatcher->getListeners(CancelEvent::class)); + } + + public function testUpdateValue() + { + $widget = new SettingsListWidget($this->createItems()); + + $widget->updateValue('theme', 'auto'); + + $this->assertSame('auto', $widget->getValue('theme')); + } + + public function testGetValueReturnsNullForUnknownId() + { + $widget = new SettingsListWidget($this->createItems()); + + $this->assertNull($widget->getValue('nonexistent')); + } + + /** + * @return list + */ + private function createItems(): array + { + return [ + new SettingItem( + id: 'theme', + label: 'Theme', + currentValue: 'dark', + description: 'Color theme', + values: ['light', 'dark', 'auto'], + ), + new SettingItem( + id: 'fontSize', + label: 'Font Size', + currentValue: 'medium', + values: ['small', 'medium', 'large'], + ), + ]; + } + + /** + * @return array{SettingsListWidget, Tui} + */ + private function createWithTui(?SettingsListWidget $widget = null): array + { + $terminal = new VirtualTerminal(80, 24); + $tui = new Tui(terminal: $terminal); + $widget ??= new SettingsListWidget($this->createItems()); + $tui->add($widget); + + return [$widget, $tui]; + } +} diff --git a/src/Symfony/Component/Tui/Tests/Widget/TextTest.php b/src/Symfony/Component/Tui/Tests/Widget/TextTest.php new file mode 100644 index 0000000000000..df4603ed774b3 --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Widget/TextTest.php @@ -0,0 +1,235 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Widget; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Render\RenderContext; +use Symfony\Component\Tui\Render\Renderer; +use Symfony\Component\Tui\Style\Style; +use Symfony\Component\Tui\Widget\TextWidget; + +class TextTest extends TestCase +{ + public function testRenderSimpleText() + { + $text = new TextWidget('Hello'); + $lines = $this->renderWidget($text, 20, 24); + + $this->assertCount(1, $lines); + $this->assertStringContainsString('Hello', $lines[0]); + } + + public function testRenderWithPadding() + { + $style = Style::padding([1, 2]); + $text = new TextWidget('Hello'); + $text->setStyle($style); + $lines = $this->renderWidget($text, 20, 24); + + // Should have 1 top padding + 1 content + 1 bottom padding = 3 lines + $this->assertCount(3, $lines); + + // All lines should be full width + foreach ($lines as $line) { + $this->assertSame(20, AnsiUtils::visibleWidth($line)); + } + + // Middle line should contain text with padding + $this->assertStringContainsString('Hello', $lines[1]); + } + + public function testRenderEmptyText() + { + $text = new TextWidget(''); + $lines = $text->render(new RenderContext(20, 24)); + + $this->assertSame([], $lines); + } + + public function testRenderWhitespaceOnlyText() + { + $text = new TextWidget(' '); + $lines = $text->render(new RenderContext(20, 24)); + + $this->assertSame([], $lines); + } + + public function testRenderWithBackground() + { + $style = new Style(background: 'red'); + $text = new TextWidget('Hello'); + $text->setStyle($style); + $lines = $this->renderWidget($text, 20, 24); + + $this->assertStringContainsString("\x1b[41m", $lines[0]); + $this->assertSame(20, AnsiUtils::visibleWidth($lines[0])); + } + + public function testRenderLineWidthWithBackground() + { + $text = new TextWidget('Hello'); + $text->setStyle(new Style(background: 'blue')); + $width = 30; + $lines = $this->renderWidget($text, $width, 24); + + // Lines are padded to full width when background is set + foreach ($lines as $line) { + $this->assertSame($width, AnsiUtils::visibleWidth($line)); + } + } + + public function testRenderLongTextWraps() + { + $longText = 'This is a longer text that should wrap across multiple lines.'; + $text = new TextWidget($longText); + $lines = $this->renderWidget($text, 20, 24); + + $this->assertGreaterThan(1, \count($lines)); + + // Each line should fit within the width + foreach ($lines as $line) { + $this->assertLessThanOrEqual(20, AnsiUtils::visibleWidth($line)); + } + } + + public function testRenderTruncatedText() + { + $longText = 'This is a very long text that should be truncated with ellipsis'; + $text = new TextWidget($longText, truncate: true); + $lines = $this->renderWidget($text, 20, 24); + + // Should be exactly 1 line when truncated + $this->assertCount(1, $lines); + + // Line should be exactly 20 wide + $this->assertSame(20, AnsiUtils::visibleWidth($lines[0])); + + // Should contain ellipsis (...) + $this->assertStringContainsString('...', $lines[0]); + } + + public function testRenderTruncatedMultilineText() + { + $multilineText = "First line that is very long and will be truncated\nSecond line also very long and will be truncated\nThird line too"; + $text = new TextWidget($multilineText, truncate: true); + $lines = $this->renderWidget($text, 30, 24); + + // Should have 3 lines (one per input line) + $this->assertCount(3, $lines); + + // Each line should fit within 30 + foreach ($lines as $line) { + $this->assertLessThanOrEqual(30, AnsiUtils::visibleWidth($line)); + } + + // First two lines should have ellipsis (they're long) + $this->assertStringContainsString('...', $lines[0]); + $this->assertStringContainsString('...', $lines[1]); + } + + public function testRenderTruncatedShortText() + { + $shortText = 'Short'; + $text = new TextWidget($shortText, truncate: true); + $lines = $this->renderWidget($text, 20, 24); + + // Should be exactly 1 line + $this->assertCount(1, $lines); + + // Should contain the text without ellipsis + $this->assertStringContainsString('Short', $lines[0]); + $this->assertStringNotContainsString('…', $lines[0]); + } + + public function testRenderTruncatedWithPadding() + { + $longText = 'This text will be truncated after padding is applied'; + $style = Style::padding([1, 2]); + $text = new TextWidget($longText, truncate: true); + $text->setStyle($style); + $lines = $this->renderWidget($text, 30, 24); + + // Should have 1 top padding + 1 content + 1 bottom padding = 3 lines + $this->assertCount(3, $lines); + + // Content line should have padding spaces at start + $this->assertStringStartsWith(' ', $lines[1]); + + // All lines should be exactly 30 wide + foreach ($lines as $line) { + $this->assertSame(30, AnsiUtils::visibleWidth($line)); + } + } + + public function testRenderTruncatedWithAnsiCodes() + { + $styledText = "\x1b[32mGreen text that is very long and should be truncated\x1b[0m"; + $text = new TextWidget($styledText, truncate: true); + $lines = $this->renderWidget($text, 20, 24); + + // Should be exactly 1 line + $this->assertCount(1, $lines); + + // Should contain ANSI codes + $this->assertStringContainsString("\x1b[32m", $lines[0]); + + // Visible width should be exactly 20 + $this->assertSame(20, AnsiUtils::visibleWidth($lines[0])); + } + + public function testRenderTruncatedWithBackground() + { + $longText = 'This text has a background and will be truncated'; + $style = new Style(background: 'red'); + $text = new TextWidget($longText, truncate: true); + $text->setStyle($style); + $lines = $this->renderWidget($text, 25, 24); + + // Should contain background ANSI code + $this->assertStringContainsString("\x1b[41m", $lines[0]); + + // Should be exactly 25 visible width + $this->assertSame(25, AnsiUtils::visibleWidth($lines[0])); + } + + public function testTruncateVsWrapBehavior() + { + $longText = 'This is a text that would normally wrap to multiple lines but with truncate it stays on one line'; + + // Without truncate (wrapping) + $wrappedText = new TextWidget($longText, truncate: false); + $wrappedLines = $this->renderWidget($wrappedText, 30, 24); + + // With truncate + $truncatedText = new TextWidget($longText, truncate: true); + $truncatedLines = $this->renderWidget($truncatedText, 30, 24); + + // Wrapped should have multiple lines + $this->assertGreaterThan(1, \count($wrappedLines)); + + // Truncated should have exactly 1 line + $this->assertCount(1, $truncatedLines); + } + + /** + * Render a widget through the Renderer pipeline to get full chrome applied. + * + * @return string[] + */ + private function renderWidget(TextWidget $widget, int $columns, int $rows): array + { + $renderer = new Renderer(); + + return $renderer->renderWidget($widget, new RenderContext($columns, $rows)); + } +} diff --git a/src/Symfony/Component/Tui/Tests/Widget/Util/KillRingTest.php b/src/Symfony/Component/Tui/Tests/Widget/Util/KillRingTest.php new file mode 100644 index 0000000000000..2134e879c4269 --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Widget/Util/KillRingTest.php @@ -0,0 +1,178 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Widget\Util; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Widget\Util\KillRing; + +class KillRingTest extends TestCase +{ + public function testAddAndPeek() + { + $ring = new KillRing(); + $ring->add('hello', false); + + $this->assertSame('hello', $ring->peek()); + } + + public function testAddEmptyStringIsIgnored() + { + $ring = new KillRing(); + $ring->add('', false); + + $this->assertNull($ring->peek()); + } + + public function testConsecutiveKillsAppend() + { + $ring = new KillRing(); + $ring->add('hello', false); + $ring->add(' world', false); + + $this->assertSame('hello world', $ring->peek()); + } + + public function testConsecutiveKillsPrepend() + { + $ring = new KillRing(); + $ring->add('world', true); + $ring->add('hello ', true); + + $this->assertSame('hello world', $ring->peek()); + } + + public function testNonConsecutiveKillsCreateNewEntries() + { + $ring = new KillRing(); + $ring->add('first', false); + $ring->resetAction(); + $ring->add('second', false); + + $this->assertSame('second', $ring->peek()); + } + + public function testMaxEntries() + { + $ring = new KillRing(3); + $ring->add('a', false); + $ring->resetAction(); + $ring->add('b', false); + $ring->resetAction(); + $ring->add('c', false); + $ring->resetAction(); + $ring->add('d', false); + + // 'a' should have been evicted + $this->assertSame('d', $ring->peek()); + + // Rotate through: should only have b, c, d + $ring->resetAction(); + $ring->recordYank(['startLine' => 0, 'startCol' => 0, 'endLine' => 0, 'endCol' => 1]); + $this->assertTrue($ring->canYankPop()); + $text = $ring->rotate(); + $this->assertSame('c', $text); + } + + public function testCanYankPopRequiresYankAction() + { + $ring = new KillRing(); + $ring->add('a', false); + $ring->resetAction(); + $ring->add('b', false); + + $this->assertFalse($ring->canYankPop()); + } + + public function testCanYankPopRequiresMultipleEntries() + { + $ring = new KillRing(); + $ring->add('only', false); + $ring->recordYank(['startLine' => 0, 'startCol' => 0, 'endLine' => 0, 'endCol' => 4]); + + $this->assertFalse($ring->canYankPop()); + } + + public function testCanYankPopRequiresYankRange() + { + $ring = new KillRing(); + $ring->add('a', false); + $ring->resetAction(); + $ring->add('b', false); + // Manually set last action to yank without recording range + // This shouldn't happen in practice, but canYankPop checks for it + + $this->assertFalse($ring->canYankPop()); + } + + public function testYankPopCycle() + { + $ring = new KillRing(); + $ring->add('first', false); + $ring->resetAction(); + $ring->add('second', false); + $ring->resetAction(); + $ring->add('third', false); + + // Yank gets 'third' + $this->assertSame('third', $ring->peek()); + $ring->recordYank(['startLine' => 0, 'startCol' => 0, 'endLine' => 0, 'endCol' => 5]); + + // Yank-pop rotates: third goes to front, 'second' is now on top + $this->assertTrue($ring->canYankPop()); + $text = $ring->rotate(); + $this->assertSame('second', $text); + } + + public function testRotateWithSingleEntry() + { + $ring = new KillRing(); + $ring->add('only', false); + + $this->assertNull($ring->rotate()); + } + + public function testResetAction() + { + $ring = new KillRing(); + $ring->add('a', false); + $ring->resetAction(); + $ring->add('b', false); + + // After reset, 'b' is a new entry, not appended to 'a' + $this->assertSame('b', $ring->peek()); + } + + public function testResetAll() + { + $ring = new KillRing(); + $ring->add('a', false); + $ring->resetAction(); + $ring->add('b', false); + $ring->recordYank(['startLine' => 0, 'startCol' => 0, 'endLine' => 0, 'endCol' => 1]); + + $ring->resetAll(); + + $this->assertFalse($ring->canYankPop()); + $this->assertNull($ring->getLastYankRange()); + } + + public function testGetLastYankRange() + { + $ring = new KillRing(); + $this->assertNull($ring->getLastYankRange()); + + $range = ['startLine' => 1, 'startCol' => 5, 'endLine' => 2, 'endCol' => 3]; + $ring->recordYank($range); + + $this->assertSame($range, $ring->getLastYankRange()); + } +} diff --git a/src/Symfony/Component/Tui/Tests/Widget/Util/LineTest.php b/src/Symfony/Component/Tui/Tests/Widget/Util/LineTest.php new file mode 100644 index 0000000000000..c0dcde55d8330 --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Widget/Util/LineTest.php @@ -0,0 +1,318 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Widget\Util; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Widget\Util\Line; + +class LineTest extends TestCase +{ + // --- bounds clamping --- + + public function testConstructorClampsNegativeCursor() + { + $line = new Line('hello', -5); + $this->assertSame(0, $line->getCursor()); + } + + public function testConstructorClampsCursorBeyondEnd() + { + $line = new Line('hello', 100); + $this->assertSame(5, $line->getCursor()); + } + + public function testSetCursorClampsNegativeValue() + { + $line = new Line('hello', 3); + $line->setCursor(-1); + $this->assertSame(0, $line->getCursor()); + } + + public function testSetCursorClampsBeyondEnd() + { + $line = new Line('hello', 0); + $line->setCursor(100); + $this->assertSame(5, $line->getCursor()); + } + + public function testSetTextAdjustsCursorWhenBeyondNewLength() + { + $line = new Line('hello world', 10); + $line->setText('hi'); + $this->assertSame('hi', $line->getText()); + $this->assertSame(2, $line->getCursor()); + } + + public function testSetTextKeepsCursorWhenWithinNewLength() + { + $line = new Line('hello', 3); + $line->setText('world!'); + $this->assertSame('world!', $line->getText()); + $this->assertSame(3, $line->getCursor()); + } + + // --- insert --- + + #[DataProvider('insertProvider')] + public function testInsert(string $text, int $cursor, string $insert, string $expectedText, int $expectedCursor) + { + $line = new Line($text, $cursor); + $line->insert($insert); + $this->assertSame($expectedText, $line->getText()); + $this->assertSame($expectedCursor, $line->getCursor()); + } + + /** + * @return iterable + */ + public static function insertProvider(): iterable + { + yield 'at start' => ['world', 0, 'hello ', 'hello world', 6]; + yield 'at end' => ['hello', 5, ' world', 'hello world', 11]; + yield 'in middle' => ['helo', 2, 'l', 'hello', 3]; + yield 'multibyte (é)' => ['caf', 3, 'é', 'café', 5]; + yield 'emoji (👋)' => ['hello', 5, '👋', 'hello👋', 9]; + } + + // --- deleteCharBackward --- + + #[DataProvider('deleteCharBackwardProvider')] + public function testDeleteCharBackward(string $text, int $cursor, bool $expectedReturn, string $expectedText, int $expectedCursor) + { + $line = new Line($text, $cursor); + $this->assertSame($expectedReturn, $line->deleteCharBackward()); + $this->assertSame($expectedText, $line->getText()); + $this->assertSame($expectedCursor, $line->getCursor()); + } + + /** + * @return iterable + */ + public static function deleteCharBackwardProvider(): iterable + { + yield 'ascii at end' => ['Hello', 5, true, 'Hell', 4]; + yield 'at start (noop)' => ['Hello', 0, false, 'Hello', 0]; + yield 'multibyte (é)' => ['café', 5, true, 'caf', 3]; + yield 'emoji' => ['hello👋', 9, true, 'hello', 5]; + yield 'CJK' => ['日本語', 9, true, '日本', 6]; + yield 'in middle' => ['Hello', 3, true, 'Helo', 2]; + } + + // --- deleteCharForward --- + + #[DataProvider('deleteCharForwardProvider')] + public function testDeleteCharForward(string $text, int $cursor, bool $expectedReturn, string $expectedText, int $expectedCursor) + { + $line = new Line($text, $cursor); + $this->assertSame($expectedReturn, $line->deleteCharForward()); + $this->assertSame($expectedText, $line->getText()); + $this->assertSame($expectedCursor, $line->getCursor()); + } + + /** + * @return iterable + */ + public static function deleteCharForwardProvider(): iterable + { + yield 'ascii at start' => ['Hello', 0, true, 'ello', 0]; + yield 'at end (noop)' => ['Hello', 5, false, 'Hello', 5]; + yield 'multibyte (é)' => ['café', 3, true, 'caf', 3]; + yield 'emoji' => ['👋hello', 0, true, 'hello', 0]; + yield 'CJK' => ['日本語', 0, true, '本語', 0]; + } + + // --- moveCursorLeft --- + + #[DataProvider('moveCursorLeftProvider')] + public function testMoveCursorLeft(string $text, int $cursor, bool $expectedReturn, int $expectedCursor) + { + $line = new Line($text, $cursor); + $this->assertSame($expectedReturn, $line->moveCursorLeft()); + $this->assertSame($expectedCursor, $line->getCursor()); + } + + /** + * @return iterable + */ + public static function moveCursorLeftProvider(): iterable + { + yield 'ascii' => ['Hello', 5, true, 4]; + yield 'at start (noop)' => ['Hello', 0, false, 0]; + yield 'multibyte (é)' => ['café', 5, true, 3]; + yield 'emoji' => ['a👋', 5, true, 1]; + } + + // --- moveCursorRight --- + + #[DataProvider('moveCursorRightProvider')] + public function testMoveCursorRight(string $text, int $cursor, bool $expectedReturn, int $expectedCursor) + { + $line = new Line($text, $cursor); + $this->assertSame($expectedReturn, $line->moveCursorRight()); + $this->assertSame($expectedCursor, $line->getCursor()); + } + + /** + * @return iterable + */ + public static function moveCursorRightProvider(): iterable + { + yield 'ascii' => ['Hello', 0, true, 1]; + yield 'at end (noop)' => ['Hello', 5, false, 5]; + yield 'multibyte (é)' => ['café', 3, true, 5]; + yield 'emoji' => ['👋a', 0, true, 4]; + } + + // --- moveCursorToStart / moveCursorToEnd --- + + public function testMoveCursorToStart() + { + $line = new Line('Hello', 3); + $this->assertTrue($line->moveCursorToStart()); + $this->assertSame(0, $line->getCursor()); + } + + public function testMoveCursorToStartAlreadyAtStart() + { + $line = new Line('Hello', 0); + $this->assertFalse($line->moveCursorToStart()); + } + + public function testMoveCursorToEnd() + { + $line = new Line('Hello', 0); + $this->assertTrue($line->moveCursorToEnd()); + $this->assertSame(5, $line->getCursor()); + } + + public function testMoveCursorToEndAlreadyAtEnd() + { + $line = new Line('Hello', 5); + $this->assertFalse($line->moveCursorToEnd()); + } + + // --- moveWordBackward / moveWordForward --- + + #[DataProvider('moveWordBackwardProvider')] + public function testMoveWordBackward(string $text, int $cursor, bool $expectedReturn, int $expectedCursor) + { + $line = new Line($text, $cursor); + $this->assertSame($expectedReturn, $line->moveWordBackward()); + $this->assertSame($expectedCursor, $line->getCursor()); + } + + /** + * @return iterable + */ + public static function moveWordBackwardProvider(): iterable + { + yield 'moves to word start' => ['hello world', 11, true, 6]; + yield 'at start (noop)' => ['hello', 0, false, 0]; + } + + #[DataProvider('moveWordForwardProvider')] + public function testMoveWordForward(string $text, int $cursor, bool $expectedReturn, int $expectedCursor) + { + $line = new Line($text, $cursor); + $this->assertSame($expectedReturn, $line->moveWordForward()); + $this->assertSame($expectedCursor, $line->getCursor()); + } + + /** + * @return iterable + */ + public static function moveWordForwardProvider(): iterable + { + yield 'moves to word end' => ['hello world', 0, true, 5]; + yield 'at end (noop)' => ['hello', 5, false, 5]; + } + + // --- deleteWordBackward --- + + #[DataProvider('deleteWordBackwardProvider')] + public function testDeleteWordBackward(string $text, int $cursor, string $expectedDeleted, string $expectedText, int $expectedCursor) + { + $line = new Line($text, $cursor); + $this->assertSame($expectedDeleted, $line->deleteWordBackward()); + $this->assertSame($expectedText, $line->getText()); + $this->assertSame($expectedCursor, $line->getCursor()); + } + + /** + * @return iterable + */ + public static function deleteWordBackwardProvider(): iterable + { + yield 'simple' => ['hello world', 11, 'world', 'hello ', 6]; + yield 'at start (noop)' => ['hello', 0, '', 'hello', 0]; + yield 'multibyte' => ['hello café', 11, 'café', 'hello ', 6]; + } + + // --- deleteWordForward --- + + #[DataProvider('deleteWordForwardProvider')] + public function testDeleteWordForward(string $text, int $cursor, string $expectedDeleted, string $expectedText, int $expectedCursor) + { + $line = new Line($text, $cursor); + $this->assertSame($expectedDeleted, $line->deleteWordForward()); + $this->assertSame($expectedText, $line->getText()); + $this->assertSame($expectedCursor, $line->getCursor()); + } + + /** + * @return iterable + */ + public static function deleteWordForwardProvider(): iterable + { + yield 'simple' => ['hello world', 0, 'hello', ' world', 0]; + yield 'at end (noop)' => ['hello', 5, '', 'hello', 5]; + } + + // --- deleteToEnd / deleteToStart --- + + #[DataProvider('deleteToEndProvider')] + public function testDeleteToEnd(string $text, int $cursor, string $expectedDeleted, string $expectedText, int $expectedCursor) + { + $line = new Line($text, $cursor); + $this->assertSame($expectedDeleted, $line->deleteToEnd()); + $this->assertSame($expectedText, $line->getText()); + $this->assertSame($expectedCursor, $line->getCursor()); + } + + /** + * @return iterable + */ + public static function deleteToEndProvider(): iterable + { + yield 'in middle' => ['Hello World', 5, ' World', 'Hello', 5]; + yield 'at end (noop)' => ['Hello', 5, '', 'Hello', 5]; + } + + #[DataProvider('deleteToStartProvider')] + public function testDeleteToStart(string $text, int $cursor, string $expectedDeleted, string $expectedText, int $expectedCursor) + { + $line = new Line($text, $cursor); + $this->assertSame($expectedDeleted, $line->deleteToStart()); + $this->assertSame($expectedText, $line->getText()); + $this->assertSame($expectedCursor, $line->getCursor()); + } + + /** + * @return iterable + */ + public static function deleteToStartProvider(): iterable + { + yield 'in middle' => ['Hello World', 5, 'Hello', ' World', 0]; + yield 'at start (noop)' => ['Hello', 0, '', 'Hello', 0]; + } +} diff --git a/src/Symfony/Component/Tui/Tests/Widget/Util/StringUtilsTest.php b/src/Symfony/Component/Tui/Tests/Widget/Util/StringUtilsTest.php new file mode 100644 index 0000000000000..e4ea75c385080 --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Widget/Util/StringUtilsTest.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Widget\Util; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Widget\Util\StringUtils; + +class StringUtilsTest extends TestCase +{ + // --- hasControlChars --- + + /** + * @return iterable + */ + public static function hasControlCharsProvider(): iterable + { + yield 'null byte' => ["\x00", true]; + yield 'escape' => ["\x1b", true]; + yield 'del' => ["\x7f", true]; + yield 'mixed (control in text)' => ["hello\x00world", true]; + yield 'printable ASCII' => ['hello', false]; + yield 'latin accents' => ['café', false]; + yield 'emoji' => ['👋', false]; + yield 'emoji mixed with text' => ['hello 🎉 world', false]; + yield 'CJK' => ['中文', false]; + } + + #[DataProvider('hasControlCharsProvider')] + public function testHasControlChars(string $input, bool $expected) + { + $this->assertSame($expected, StringUtils::hasControlChars($input)); + } + + // --- sanitizeUtf8 --- + + #[DataProvider('sanitizeUtf8PassThroughProvider')] + public function testSanitizeUtf8PassThrough(string $input) + { + $this->assertSame($input, StringUtils::sanitizeUtf8($input)); + } + + /** + * @return iterable + */ + public static function sanitizeUtf8PassThroughProvider(): iterable + { + yield 'ASCII' => ['hello']; + yield 'multibyte' => ['café']; + yield 'empty' => ['']; + } + + public function testSanitizeUtf8InvalidBytes() + { + $result = StringUtils::sanitizeUtf8("hello\xFF\xFEworld"); + $this->assertSame('helloworld', $result); + $this->assertTrue(mb_check_encoding($result, 'UTF-8')); + } +} diff --git a/src/Symfony/Component/Tui/Tests/Widget/Util/WordNavigatorTest.php b/src/Symfony/Component/Tui/Tests/Widget/Util/WordNavigatorTest.php new file mode 100644 index 0000000000000..b153af89ed41c --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Widget/Util/WordNavigatorTest.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Widget\Util; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Widget\Util\WordNavigator; + +final class WordNavigatorTest extends TestCase +{ + /** + * @return iterable + */ + public static function skipWordBackwardProvider(): iterable + { + yield 'at start of string' => ['hello world', 0, 0]; + yield 'skips word' => ['hello world', 11, 6]; + yield 'skips whitespace then word (from end)' => ['hello world', 12, 7]; + yield 'skips whitespace then word (from mid)' => ['hello world', 7, 0]; + yield 'skips punctuation (from end)' => ['hello...world', 13, 8]; + yield 'skips punctuation (from mid)' => ['hello...world', 8, 5]; + yield 'skips word after punctuation' => ['hello...world', 5, 0]; + yield 'trailing whitespace skips to start' => ['hello ', 8, 0]; + yield 'mixed punctuation: bar' => ['foo->bar', 8, 5]; + yield 'mixed punctuation: ->' => ['foo->bar', 5, 3]; + yield 'mixed punctuation: foo' => ['foo->bar', 3, 0]; + yield 'multibyte: skips world' => ["caf\xC3\xA9 world", 11, 6]; + yield 'multibyte: skips café' => ["caf\xC3\xA9 world", 6, 0]; + yield 'empty string' => ['', 0, 0]; + yield 'whitespace only' => [' ', 3, 0]; + } + + #[DataProvider('skipWordBackwardProvider')] + public function testSkipWordBackward(string $text, int $cursor, int $expected) + { + $this->assertSame($expected, WordNavigator::skipWordBackward($text, $cursor)); + } + + /** + * @return iterable + */ + public static function skipWordForwardProvider(): iterable + { + yield 'at end of string' => ['hello', 5, 5]; + yield 'skips word' => ['hello world', 0, 5]; + yield 'skips whitespace then word' => ['hello world', 5, 12]; + yield 'skips punctuation from start' => ['hello...world', 0, 5]; + yield 'skips punctuation dots' => ['hello...world', 5, 8]; + yield 'skips word after punctuation' => ['hello...world', 8, 13]; + yield 'leading whitespace' => [' hello', 0, 8]; + yield 'mixed punctuation: foo' => ['foo->bar', 0, 3]; + yield 'mixed punctuation: ->' => ['foo->bar', 3, 5]; + yield 'mixed punctuation: bar' => ['foo->bar', 5, 8]; + yield 'multibyte: skips café' => ["caf\xC3\xA9 world", 0, 5]; + yield 'multibyte: skips world' => ["caf\xC3\xA9 world", 5, 11]; + yield 'empty string' => ['', 0, 0]; + yield 'whitespace only' => [' ', 0, 3]; + } + + #[DataProvider('skipWordForwardProvider')] + public function testSkipWordForward(string $text, int $cursor, int $expected) + { + $this->assertSame($expected, WordNavigator::skipWordForward($text, $cursor)); + } + + public function testRoundTripWordNavigation() + { + $text = 'hello world...foo'; + // Forward: 0 → 5 → 12 → 15 → 18 + $pos = 0; + $pos = WordNavigator::skipWordForward($text, $pos); + $this->assertSame(5, $pos); + $pos = WordNavigator::skipWordForward($text, $pos); + $this->assertSame(12, $pos); + $pos = WordNavigator::skipWordForward($text, $pos); + $this->assertSame(15, $pos); + $pos = WordNavigator::skipWordForward($text, $pos); + $this->assertSame(18, $pos); + + // Backward: 18 → 15 → 12 → 7 → 0 + $pos = WordNavigator::skipWordBackward($text, $pos); + $this->assertSame(15, $pos); + $pos = WordNavigator::skipWordBackward($text, $pos); + $this->assertSame(12, $pos); + $pos = WordNavigator::skipWordBackward($text, $pos); + $this->assertSame(7, $pos); + $pos = WordNavigator::skipWordBackward($text, $pos); + $this->assertSame(0, $pos); + } +} diff --git a/src/Symfony/Component/Tui/Tests/Widget/WidgetTreeTest.php b/src/Symfony/Component/Tui/Tests/Widget/WidgetTreeTest.php new file mode 100644 index 0000000000000..887f6e38f3529 --- /dev/null +++ b/src/Symfony/Component/Tui/Tests/Widget/WidgetTreeTest.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Tests\Widget; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Tui\Focus\FocusManager; +use Symfony\Component\Tui\Input\Keybindings; +use Symfony\Component\Tui\Render\Renderer; +use Symfony\Component\Tui\Render\RenderRequestorInterface; +use Symfony\Component\Tui\Terminal\VirtualTerminal; +use Symfony\Component\Tui\Tui; +use Symfony\Component\Tui\Widget\ContainerWidget; +use Symfony\Component\Tui\Widget\TextWidget; +use Symfony\Component\Tui\Widget\WidgetContext; +use Symfony\Component\Tui\Widget\WidgetTree; + +class WidgetTreeTest extends TestCase +{ + private WidgetTree $tree; + + protected function setUp(): void + { + $terminal = new VirtualTerminal(80, 24); + $tui = new Tui(terminal: $terminal); + $this->tree = new WidgetTree($tui, new Keybindings(), new FocusManager($this->createStub(RenderRequestorInterface::class)), new Renderer(), $terminal, $tui->getEventDispatcher()); + } + + public function testSetRootDetachesPreviousRoot() + { + $root1 = new ContainerWidget(); + $root2 = new ContainerWidget(); + + $this->tree->setRoot($root1); + $this->assertInstanceOf(WidgetContext::class, $root1->getContext()); + + $this->tree->setRoot($root2); + $this->assertNull($root1->getContext()); + $this->assertInstanceOf(WidgetContext::class, $root2->getContext()); + } + + public function testAttachRecursivelyAttachesChildren() + { + $container = new ContainerWidget(); + $child1 = new TextWidget('Child 1'); + $child2 = new TextWidget('Child 2'); + $container->add($child1); + $container->add($child2); + + $this->tree->attach($container, null); + + $this->assertInstanceOf(WidgetContext::class, $child1->getContext()); + $this->assertInstanceOf(WidgetContext::class, $child2->getContext()); + } + + public function testDetachRecursivelyDetachesChildren() + { + $container = new ContainerWidget(); + $child1 = new TextWidget('Child 1'); + $child2 = new TextWidget('Child 2'); + $container->add($child1); + $container->add($child2); + + $this->tree->attach($container, null); + $this->tree->detach($container); + + $this->assertNull($container->getContext()); + $this->assertNull($child1->getContext()); + $this->assertNull($child2->getContext()); + } + + public function testDetachSubtreeOnlyAffectsSubtree() + { + $root = new ContainerWidget(); + $child1 = new TextWidget('Stay'); + $child2Container = new ContainerWidget(); + $grandchild = new TextWidget('Go'); + + $root->add($child1); + $root->add($child2Container); + $child2Container->add($grandchild); + + $this->tree->attach($root, null); + + // Detach only the subtree + $this->tree->detach($child2Container); + + $this->assertInstanceOf(WidgetContext::class, $root->getContext()); + $this->assertInstanceOf(WidgetContext::class, $child1->getContext()); + $this->assertNull($child2Container->getContext()); + $this->assertNull($grandchild->getContext()); + } + + public function testDetachClearsParent() + { + $parent = new ContainerWidget(); + $child = new TextWidget('Hello'); + + $this->tree->attach($child, $parent); + $this->tree->detach($child); + + $this->assertNull($child->getParent()); + } +} diff --git a/src/Symfony/Component/Tui/Tui.php b/src/Symfony/Component/Tui/Tui.php new file mode 100644 index 0000000000000..c55769e3aa8bf --- /dev/null +++ b/src/Symfony/Component/Tui/Tui.php @@ -0,0 +1,586 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui; + +use Revolt\EventLoop; +use Revolt\EventLoop\Suspension; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Tui\Event\AbstractEvent; +use Symfony\Component\Tui\Event\TickEvent; +use Symfony\Component\Tui\Exception\InvalidArgumentException; +use Symfony\Component\Tui\Focus\FocusManager; +use Symfony\Component\Tui\Input\Keybindings; +use Symfony\Component\Tui\Loop\AdaptativeTicker; +use Symfony\Component\Tui\Loop\TickRuntimeInterface; +use Symfony\Component\Tui\Loop\TickScheduler; +use Symfony\Component\Tui\Render\Renderer; +use Symfony\Component\Tui\Render\RenderRequestorInterface; +use Symfony\Component\Tui\Render\ScreenWriter; +use Symfony\Component\Tui\Style\StyleSheet; +use Symfony\Component\Tui\Terminal\Terminal; +use Symfony\Component\Tui\Terminal\TerminalInterface; +use Symfony\Component\Tui\Widget\AbstractWidget; +use Symfony\Component\Tui\Widget\ContainerWidget; +use Symfony\Component\Tui\Widget\Figlet\FontRegistry; +use Symfony\Component\Tui\Widget\FocusableInterface; +use Symfony\Component\Tui\Widget\WidgetTree; + +/** + * Main TUI class for managing terminal UI. + * + * This class orchestrates: + * - Terminal lifecycle (start/stop) + * - Event loop integration + * - Focus management + * - Input handling + * + * The root container is created internally with the style class "root". + * Use add(), remove(), and clear() to build the widget tree. + * Style the root via the stylesheet using the ".root" selector. + * + * Rendering is delegated to: + * - Renderer: widget tree → lines (content generation) + * - ScreenWriter: lines → terminal (differential output) + * + * @experimental + * + * @author Fabien Potencier + */ +class Tui implements RenderRequestorInterface, TickRuntimeInterface +{ + private ContainerWidget $root; + private Keybindings $keybindings; + private Renderer $renderer; + private ScreenWriter $screenWriter; + private WidgetTree $widgetTree; + private FocusManager $focusManager; + private TickScheduler $tickScheduler; + private AdaptativeTicker $adaptativeTicker; + private EventDispatcherInterface $eventDispatcher; + + /** @var string[] */ + private array $quitKeys = []; + + /** @var (callable(string): bool)|null */ + private $onInput; + + /** @var callable(): void */ + private $onDebug; + + /** @var callable(TickEvent): mixed */ + private $onTick; + + private bool $renderRequested = false; + private bool $running = false; + private bool $stopped = false; + private bool $ticking = false; + private ?float $lastTickAt = null; + private ?bool $lastTickBusyHint = null; + + /** @var Suspension|null */ + private ?Suspension $runSuspension = null; + + public function __construct( + ?StyleSheet $styleSheet = null, + private readonly TerminalInterface $terminal = new Terminal(), + ?Keybindings $keybindings = null, + ?FontRegistry $fontRegistry = null, + ?Renderer $renderer = null, + ?ScreenWriter $screenWriter = null, + ?EventDispatcherInterface $eventDispatcher = null, + ) { + $this->keybindings = $keybindings ?? new Keybindings(); + $this->root = new ContainerWidget(); + $this->root->expandVertically(true); + $this->root->addStyleClass('root'); + $this->renderer = $renderer ?? new Renderer($styleSheet, $fontRegistry); + $this->screenWriter = $screenWriter ?? new ScreenWriter($terminal); + $this->eventDispatcher = $eventDispatcher ?? new EventDispatcher(); + + // Share the KeyParser so Kitty protocol state is consistent + $this->focusManager = new FocusManager( + $this, + parser: $this->keybindings->getParser(), + eventDispatcher: $this->eventDispatcher, + ); + + $this->widgetTree = new WidgetTree($this, $this->keybindings, $this->focusManager, $this->renderer, $this->terminal, $this->eventDispatcher); + $this->widgetTree->setRoot($this->root); + $this->tickScheduler = new TickScheduler(); + $this->adaptativeTicker = new AdaptativeTicker($this); + } + + /** + * Add a child widget to the root container. + * + * @return $this + */ + public function add(AbstractWidget $widget): self + { + $this->root->add($widget); + + return $this; + } + + /** + * Remove a child widget from the root container. + * + * @return $this + */ + public function remove(AbstractWidget $widget): self + { + $this->root->remove($widget); + + return $this; + } + + /** + * Remove all child widgets from the root container. + * + * @return $this + */ + public function clear(): self + { + $this->root->clear(); + + return $this; + } + + /** + * Find a widget by ID in the widget tree. + */ + public function getById(string $id): AbstractWidget + { + $widget = $this->root->findById($id); + + if (null === $widget) { + throw new InvalidArgumentException(\sprintf('No widget found with id "%s".', $id)); + } + + return $widget; + } + + /** + * Add a stylesheet on top of the existing ones. + * + * User stylesheets are merged on top of defaults (last wins for same selectors). + * + * @return $this + */ + public function addStyleSheet(StyleSheet $styleSheet): self + { + $this->renderer->addStyleSheet($styleSheet); + + return $this; + } + + /** + * Run the main event loop using Revolt. + * + * This method runs the TUI using Revolt's event loop, allowing + * async operations (like HTTP streaming) to run concurrently. + * + * This blocks until stop() is called. + */ + public function run(): void + { + $this->start(); + $this->runSuspension = EventLoop::getSuspension(); + $this->refreshLoopDriver(); + + try { + // Block until stop() is called + $this->runSuspension->suspend(); + } finally { + $this->runSuspension = null; + // Ensure terminal is restored + $this->stop(); + } + } + + /** + * Start the TUI. + */ + public function start(): void + { + $this->running = true; + $this->stopped = false; + $this->lastTickAt = null; + $this->lastTickBusyHint = null; + $this->terminal->start($this->handleInput(...), $this->requestRender(...), function (): void { + $this->keybindings->setKittyProtocolActive(true); + }); + $this->terminal->hideCursor(); + $this->requestRender(); + } + + /** + * Run one iteration of the TUI loop. + * Processes scheduled tasks, rendering, and tick callback. + */ + public function tick(): void + { + // Guard against re-entrant ticks. When the onTick callback suspends + // a fiber (e.g., async file I/O via Amp), the Revolt event loop may + // fire another repeat-timer callback while the previous tick is still + // suspended. Without this guard, two fibers would concurrently mutate + // the agent state machine and render to the terminal, corrupting the + // display. + if ($this->ticking) { + return; + } + + $this->ticking = true; + $now = microtime(true); + $deltaTime = null === $this->lastTickAt ? 0.0 : max(0.0, $now - $this->lastTickAt); + $this->lastTickAt = $now; + $revisionBeforeTick = $this->root->getRenderRevision(); + + try { + $this->tickScheduler->runDue(); + $this->processRender(); + $this->lastTickBusyHint = $this->invokeTickCallback($deltaTime); + + if ($this->root->getRenderRevision() !== $revisionBeforeTick) { + $this->requestRender(); + } + } finally { + $this->ticking = false; + $this->refreshLoopDriver(); + } + } + + /** + * Stop the TUI and restore terminal state. + */ + public function stop(): void + { + $this->running = false; + $this->adaptativeTicker->stop(); + $this->tickScheduler->clear(); + $this->lastTickAt = null; + $this->lastTickBusyHint = null; + $this->resumeRunSuspension(); + + if ($this->stopped) { + return; + } + $this->stopped = true; + + // Move cursor to end of content + $state = $this->screenWriter->getState(); + if ($state['lineCount'] > 0) { + $lineDiff = $state['lineCount'] - $state['cursorRow']; + + if ($lineDiff > 0) { + $this->terminal->write("\x1b[{$lineDiff}B"); + } elseif ($lineDiff < 0) { + $this->terminal->write("\x1b[".(-$lineDiff).'A'); + } + + $this->terminal->write("\r\n"); + } + + // Restore default cursor shape (DECSCUSR 0) and show cursor + $this->terminal->write("\x1b[0 q"); + $this->terminal->showCursor(); + $this->terminal->stop(); + } + + /** + * Check if the TUI is running. + */ + public function isRunning(): bool + { + return $this->running; + } + + /** + * Register a global input interceptor. + * + * Called before focus navigation and before the focused widget receives input. + * Return true to consume the input. + * + * @param (callable(string): bool)|null $onInput + * + * @return $this + */ + public function onInput(?callable $onInput): self + { + $this->onInput = $onInput; + + return $this; + } + + /** + * Register key patterns that stop the TUI. + * + * Quit keys are checked at the very start of input handling, + * before the onInput interceptor and focus manager. + * + * @param string ...$keys Key identifiers (e.g., 'ctrl+c', Key::ESCAPE) + * + * @return $this + */ + public function quitOn(string ...$keys): self + { + $this->quitKeys = $keys; + + return $this; + } + + /** + * @return $this + */ + public function onDebug(?callable $onDebug): self + { + $this->onDebug = $onDebug; + + return $this; + } + + /** + * @param callable(TickEvent): mixed $onTick + * + * Return true while active work is in progress (fast 100Hz ticking), + * false when idle (no polling), or null/void to use fallback idle polling + * + * @return $this + */ + public function onTick(?callable $onTick): self + { + $this->onTick = $onTick; + $this->lastTickAt = null; + $this->lastTickBusyHint = null; + $this->refreshLoopDriver(); + + return $this; + } + + /** + * Register a listener for a widget event. + * + * This is the primary way to react to widget events (submit, cancel, + * change, select, etc.). All events dispatched by any widget in the + * tree are routed through this single dispatcher. + * + * Use {@see AbstractEvent::getTarget()} to filter by source widget when + * listening for a shared event type like CancelEvent. + * + * @template T of AbstractEvent + * + * @param class-string $eventClass The event class to listen for + * @param callable(T): void $listener The listener to invoke + * @param int $priority Higher = called earlier (default 0) + * + * @return $this + */ + public function on(string $eventClass, callable $listener, int $priority = 0): self + { + $this->eventDispatcher->addListener($eventClass, $listener, $priority); + + return $this; + } + + /** + * Get the event dispatcher. + */ + public function getEventDispatcher(): EventDispatcherInterface + { + return $this->eventDispatcher; + } + + /** + * Get the terminal. + */ + public function getTerminal(): TerminalInterface + { + return $this->terminal; + } + + /** + * Set the focused component. + * + * @return $this + */ + public function setFocus(?AbstractWidget $component): self + { + $this->focusManager->setFocus($component); + + return $this; + } + + /** + * Get the currently focused component. + */ + public function getFocus(): ?AbstractWidget + { + return $this->focusManager->getFocus(); + } + + /** + * Get the focus manager. + */ + public function getFocusManager(): FocusManager + { + return $this->focusManager; + } + + /** + * Request a render on the next tick. + * + * @param bool $force If true, clear all cached state and do full re-render + */ + public function requestRender(bool $force = false): void + { + if ($force) { + $this->screenWriter->reset(); + } + + $this->renderRequested = true; + $this->refreshLoopDriver(); + } + + /** + * Set the scroll offset (lines from bottom). + * + * When the content exceeds the viewport, the viewport normally shows + * the bottom portion. A positive scroll offset shifts the viewport + * up by that many lines. + */ + public function setScrollOffset(int $offset): void + { + $this->screenWriter->setScrollOffset($offset); + } + + /** + * Schedule a repeating callback in the internal TUI scheduler. + * + * @internal + * + * @param callable(): void $callback + */ + public function scheduleInterval(callable $callback, float $intervalSeconds): string + { + $id = $this->tickScheduler->schedule($callback, $intervalSeconds); + + $this->refreshLoopDriver(); + + return $id; + } + + /** + * Cancel a callback previously registered with scheduleInterval(). + * + * @internal + */ + public function cancelInterval(string $id): void + { + $this->tickScheduler->cancel($id); + $this->refreshLoopDriver(); + } + + /** + * Process any pending renders. + * + * Called automatically by tick(). Only call this directly + * if you are driving the loop manually instead of using run(). + */ + public function processRender(): void + { + if ($this->renderRequested) { + $this->renderRequested = false; + $columns = $this->terminal->getColumns(); + $rows = $this->terminal->getRows(); + $this->screenWriter->writeLines($this->renderer->render($this->root, $columns, $rows)); + } + } + + /** + * Handle input from the terminal. + */ + public function handleInput(string $data): void + { + // Check quit keys + if ([] !== $this->quitKeys) { + foreach ($this->quitKeys as $key) { + if ($this->keybindings->getParser()->matches($data, $key)) { + $this->stop(); + + return; + } + } + } + + if (null !== $this->onInput && ($this->onInput)($data)) { + return; + } + + // Global debug key handler (Shift+Ctrl+D) + if ("\x1b[68;6u" === $data && null !== $this->onDebug) { + ($this->onDebug)(); + + return; + } + + if ($this->focusManager->handleInput($data)) { + return; + } + + // Pass input to focused component + $focused = $this->focusManager->getFocus(); + if ($focused instanceof FocusableInterface) { + $revisionBeforeInput = $this->root->getRenderRevision(); + $focused->handleInput($data); + if ($this->root->getRenderRevision() !== $revisionBeforeInput) { + $this->requestRender(); + } + } + } + + private function invokeTickCallback(float $deltaTime): ?bool + { + if (null === $this->onTick) { + return null; + } + + $event = new TickEvent($deltaTime); + $result = ($this->onTick)($event); + + if (\is_bool($result)) { + return $result; + } + + if ($event->hasBusyHint()) { + return $event->isBusy(); + } + + return null; + } + + private function refreshLoopDriver(): void + { + $this->adaptativeTicker->refresh($this->running, $this->renderRequested, $this->tickScheduler->getNextDelay(), null !== $this->onTick, $this->lastTickBusyHint); + } + + private function resumeRunSuspension(): void + { + if (null === $this->runSuspension) { + return; + } + + $suspension = $this->runSuspension; + $this->runSuspension = null; + $suspension->resume(null); + } +} diff --git a/src/Symfony/Component/Tui/Widget/AbstractWidget.php b/src/Symfony/Component/Tui/Widget/AbstractWidget.php new file mode 100644 index 0000000000000..e61071450fbab --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/AbstractWidget.php @@ -0,0 +1,455 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Event\AbstractEvent; +use Symfony\Component\Tui\Exception\RenderException; +use Symfony\Component\Tui\Render\RenderContext; +use Symfony\Component\Tui\Style\Style; +use Symfony\Component\Tui\Style\StyleSheet; +use Symfony\Component\Tui\Tui; + +/** + * Base widget class with lifecycle hooks and dirty tracking. + * + * @experimental + * + * @author Fabien Potencier + */ +abstract class AbstractWidget +{ + use DirtyWidgetTrait { invalidate as private invalidateSelf; } + + private ?string $id = null; + private ?string $label = null; + private ?AbstractWidget $parent = null; + private ?WidgetContext $context = null; + private ?Style $internalStyle = null; + + /** @var string[] */ + private array $styleClasses = []; + + /** @var array, list> */ + private array $listeners = []; + + // Render cache: stores the last output of Renderer::renderWidget() + // keyed on (renderRevision, columns, rows) so unchanged widgets + // skip style resolution, layout, chrome, and content rendering. + + /** @var string[]|null */ + private ?array $renderCacheLines = null; + private int $renderCacheRevision = -1; + private int $renderCacheColumns = -1; + private int $renderCacheRows = -1; + + /** + * @return $this + */ + final public function setId(string $id): static + { + $this->id = $id; + + return $this; + } + + final public function getId(): ?string + { + return $this->id; + } + + /** + * Set an optional human-readable label for the widget. + * + * Used by parent widgets (e.g., TabsWidget) to extract metadata + * from children added via templates. + * + * @return $this + */ + final public function setLabel(string $label): static + { + $this->label = $label; + + return $this; + } + + final public function getLabel(): ?string + { + return $this->label; + } + + /** + * Find a descendant widget by ID (depth-first search). + * + * Searches this widget and its subtree. Returns null if not found. + */ + final public function findById(string $id): ?self + { + if ($this->id === $id) { + return $this; + } + + if ($this instanceof ParentInterface) { + foreach ($this->all() as $child) { + $found = $child->findById($id); + if (null !== $found) { + return $found; + } + } + } + + return null; + } + + final public function getParent(): ?AbstractWidget + { + return $this->parent; + } + + final public function getContext(): ?WidgetContext + { + return $this->context; + } + + /** + * @return string[] + */ + final public function getStyleClasses(): array + { + return $this->styleClasses; + } + + /** + * @param string[] $classes + */ + final public function setStyleClasses(array $classes): void + { + if ($this->styleClasses !== $classes) { + $this->styleClasses = $classes; + $this->invalidate(); + } + } + + /** + * @return $this + */ + final public function addStyleClass(string $class): self + { + if (!\in_array($class, $this->styleClasses, true)) { + $this->styleClasses[] = $class; + $this->invalidate(); + } + + return $this; + } + + /** + * @return $this + */ + final public function removeStyleClass(string $class): self + { + $newClasses = array_values(array_filter( + $this->styleClasses, + fn (string $c) => $c !== $class, + )); + + if ($newClasses !== $this->styleClasses) { + $this->styleClasses = $newClasses; + $this->invalidate(); + } + + return $this; + } + + /** + * @return string[] + */ + final public function getStateFlags(): array + { + $flags = []; + + if ($this instanceof FocusableInterface && $this->isFocused()) { + $flags[] = 'focused'; + } + + return $flags; + } + + final public function invalidate(): void + { + $this->invalidateSelf(); + $this->renderCacheLines = null; + + if (null !== $this->parent) { + $this->parent->invalidate(); + } + } + + /** + * @internal + */ + final public function attach(?AbstractWidget $parent, WidgetContext $context): void + { + $this->parent = $parent; + $this->context = $context; + + if ($this instanceof FocusableInterface) { + $context->getFocusManager()->add($this); + } + + $this->onAttach($context); + } + + /** + * @internal + */ + final public function detach(): void + { + $context = $this->context; + + if (null !== $context && $this instanceof FocusableInterface) { + $context->getFocusManager()->remove($this); + } + + $this->listeners = []; + + $this->onDetach(); + $this->parent = null; + $this->context = null; + } + + /** + * @return $this + */ + final public function setStyle(?Style $style): self + { + if ($this->internalStyle !== $style) { + $this->internalStyle = $style; + $this->invalidate(); + } + + return $this; + } + + final public function getStyle(): ?Style + { + return $this->internalStyle; + } + + /** + * Collect and return an escape sequence the terminal must process when + * this widget is removed from the tree, and reset the associated state. + * + * The {@see WidgetTree} calls this just before detaching the widget and + * writes the returned data to the terminal. Override this in widgets + * that allocate terminal-side resources (e.g. Kitty image data). + * + * Implementations should clear any resource IDs they return sequences + * for, so a second call is a no-op. + * + * The default implementation returns an empty string (no cleanup needed). + */ + public function collectTerminalCleanupSequence(): string + { + return ''; + } + + /** + * Check if this widget has any local listeners for the given event type. + * + * @param class-string $eventClass + */ + final public function hasListeners(string $eventClass): bool + { + return isset($this->listeners[$eventClass]) && [] !== $this->listeners[$eventClass]; + } + + /** + * Register a listener for a specific event type on this widget. + * + * The listener is only called when this specific widget dispatches the event. + * Listeners are stored locally on the widget and automatically cleared on detach. + * + * @param class-string $eventClass The event class to listen for + * @param callable $listener The listener to invoke + * + * @return $this + */ + final public function on(string $eventClass, callable $listener): static + { + $this->listeners[$eventClass][] = $listener; + + return $this; + } + + /** + * Return the cached render output if still valid. + * + * The cache is keyed on (renderRevision, columns, rows). A cache hit + * means the widget's state and available dimensions have not changed + * since the last render, so the Renderer can skip the entire pipeline + * (style resolution, layout, chrome, content rendering). + * + * @internal Used by the Renderer + * + * @return string[]|null Cached lines, or null on miss + */ + final public function getRenderCache(int $columns, int $rows): ?array + { + if ($this->renderCacheRevision === $this->getRenderRevision() + && $this->renderCacheColumns === $columns + && $this->renderCacheRows === $rows + ) { + return $this->renderCacheLines; + } + + return null; + } + + /** + * Store the render output for future cache lookups. + * + * @internal Used by the Renderer + * + * @param string[] $lines + */ + final public function setRenderCache(array $lines, int $columns, int $rows): void + { + $this->renderCacheLines = $lines; + $this->renderCacheRevision = $this->getRenderRevision(); + $this->renderCacheColumns = $columns; + $this->renderCacheRows = $rows; + } + + /** + * Clear the render cache without changing the render revision. + * + * Used by the layout engine to force a re-render for position tracking + * after the measurement pass has already cached the output. + * + * @internal Used by the LayoutEngine + */ + final public function clearRenderCache(): void + { + $this->renderCacheLines = null; + } + + /** + * Lifecycle hook: override to sync state before rendering. + * + * Called by the Renderer on every frame, even when the render cache is + * valid. Use it to update child widget content, manage overlays, or + * perform other pre-render state updates. Keep it lightweight; heavy + * work should be guarded by dirty checks. + */ + public function beforeRender(): void + { + } + + /** + * Render the widget content into terminal lines. + * + * The returned lines represent the widget's visual content, one array + * element per terminal row. The Renderer calls this method with a + * context whose dimensions already exclude chrome (padding, border). + * + * ## Contract + * + * - Lines MAY contain ANSI escape sequences for styling + * - Lines MUST NOT exceed `$context->getColumns()` in visible width + * - Lines MUST NOT contain newline characters (each element is one row) + * - Empty strings are valid (blank rows) + * - Image protocol sequences (Kitty/iTerm2) are exempt from the width constraint + * + * The Renderer validates the width constraint and throws a + * {@see RenderException} if any line exceeds + * the available columns. + * + * @return string[] One element per terminal row + */ + abstract public function render(RenderContext $context): array; + + /** + * @internal + */ + final protected function setParent(?AbstractWidget $parent): void + { + $this->parent = $parent; + $this->invalidate(); + } + + protected function onAttach(WidgetContext $context): void + { + } + + protected function onDetach(): void + { + } + + /** + * Resolve a sub-element style from the stylesheet. + * + * Sub-elements are parts within a widget that need independent styling + * (e.g., "cursor", "selected", "description"). The stylesheet resolves + * them using CSS pseudo-element syntax (::). + * + * Resolution order: + * 1. FQCN::element + * 2. .class::element + * 3. FQCN::element:state (e.g., :focused) + * 4. .class::element:state + * + * @see StyleSheet::resolveElement() + */ + final protected function resolveElement(string $element): Style + { + $context = $this->getContext(); + if (null === $context) { + return new Style(); + } + + return $context->resolveElement($this, $element); + } + + /** + * Apply a sub-element style to text. + * + * Shorthand for `$this->resolveElement($element)->apply($text)`. + */ + final protected function applyElement(string $element, string $text): string + { + if ('' === $text) { + return $text; + } + + return $this->resolveElement($element)->apply($text); + } + + /** + * Dispatch a widget event. + * + * Invokes per-widget listeners first (registered via {@see on()}), + * then dispatches to the global EventDispatcher for listeners + * registered via {@see Tui::on()}. + * + * Also requests a render after dispatching, since listeners typically + * mutate UI state. + */ + final protected function dispatch(AbstractEvent $event): void + { + // Per-widget listeners (no target check needed, they're already scoped) + foreach ($this->listeners[$event::class] ?? [] as $listener) { + $listener($event); + } + + $this->context?->dispatch($event); + } +} diff --git a/src/Symfony/Component/Tui/Widget/BracketedPasteTrait.php b/src/Symfony/Component/Tui/Widget/BracketedPasteTrait.php new file mode 100644 index 0000000000000..ea8f753302d2f --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/BracketedPasteTrait.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +/** + * Handles bracketed paste mode detection and buffering. + * + * Terminals that support bracketed paste wrap pasted text between + * ESC[200~ (start) and ESC[201~ (end) sequences. This trait + * accumulates chunks until the end marker is received, then + * returns the complete paste content. + * + * @experimental + * + * @author Fabien Potencier + */ +trait BracketedPasteTrait +{ + private bool $inPaste = false; + private string $pasteBuffer = ''; + + private function isBufferingPaste(): bool + { + return $this->inPaste; + } + + /** + * Process bracketed paste sequences in input data. + * + * Detects paste start/end markers and buffers content across + * multiple input chunks. Modifies $data in place to remove + * paste markers and consumed content. + * + * @param string $data Input data; modified to contain only the portion + * after the paste end marker (if any), or emptied + * if still buffering + * + * @return string|null The complete pasted text when the end marker is + * received, or null if still buffering + */ + private function processBracketedPaste(string &$data): ?string + { + if (str_contains($data, "\x1b[200~")) { + $this->inPaste = true; + $this->pasteBuffer = ''; + $data = str_replace("\x1b[200~", '', $data); + } + + if (!$this->inPaste) { + return null; + } + + $endIndex = strpos($data, "\x1b[201~"); + if (false !== $endIndex) { + $this->pasteBuffer .= substr($data, 0, $endIndex); + $pastedText = $this->pasteBuffer; + $this->inPaste = false; + $this->pasteBuffer = ''; + $data = substr($data, $endIndex + 6); + + return $pastedText; + } + + $this->pasteBuffer .= $data; + $data = ''; + + return null; + } +} diff --git a/src/Symfony/Component/Tui/Widget/CancellableLoaderWidget.php b/src/Symfony/Component/Tui/Widget/CancellableLoaderWidget.php new file mode 100644 index 0000000000000..b65cf3da1d417 --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/CancellableLoaderWidget.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Event\CancelEvent; +use Symfony\Component\Tui\Input\Key; + +/** + * Loader that can be cancelled with Escape key. + * + * @experimental + * + * @author Fabien Potencier + */ +class CancellableLoaderWidget extends LoaderWidget implements FocusableInterface +{ + use FocusableTrait; + use KeybindingsTrait; + + private bool $cancelled = false; + + public function __construct( + string $message = 'Loading...', + ) { + parent::__construct($message); + } + + /** + * Check if the loader was cancelled. + */ + public function isCancelled(): bool + { + return $this->cancelled; + } + + /** + * Reset the cancelled state. + */ + public function reset(): void + { + $this->cancelled = false; + } + + public function start(): void + { + parent::start(); + $this->cancelled = false; + } + + /** + * @param callable(CancelEvent): void $callback + * + * @return $this + */ + public function onCancel(callable $callback): self + { + return $this->on(CancelEvent::class, $callback); + } + + public function handleInput(string $data): void + { + if (null !== $this->onInput && ($this->onInput)($data)) { + return; + } + + if ($this->getKeybindings()->matches($data, 'select_cancel')) { + $this->cancelled = true; + $this->dispatch(new CancelEvent($this)); + } + } + + /** + * @return array + */ + protected static function getDefaultKeybindings(): array + { + return [ + 'select_cancel' => [Key::ESCAPE, 'ctrl+c'], + ]; + } +} diff --git a/src/Symfony/Component/Tui/Widget/ContainerInterface.php b/src/Symfony/Component/Tui/Widget/ContainerInterface.php new file mode 100644 index 0000000000000..6c7a4bb2d42d2 --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/ContainerInterface.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +/** + * Interface for widgets that can contain and mutate child widgets. + * + * Extends ParentInterface with mutation methods (add, remove, clear). + * Use ParentInterface when you only need read-only tree traversal. + * + * @experimental + * + * @author Fabien Potencier + */ +interface ContainerInterface extends ParentInterface +{ + /** + * @return $this + */ + public function add(AbstractWidget $widget): static; + + /** + * @return $this + */ + public function remove(AbstractWidget $widget): static; + + /** + * Remove all child widgets. + * + * @return $this + */ + public function clear(): static; +} diff --git a/src/Symfony/Component/Tui/Widget/ContainerWidget.php b/src/Symfony/Component/Tui/Widget/ContainerWidget.php new file mode 100644 index 0000000000000..4d635151c4836 --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/ContainerWidget.php @@ -0,0 +1,168 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Exception\LogicException; +use Symfony\Component\Tui\Render\RenderContext; + +/** + * Container widget that groups child widgets with optional styling. + * + * Supports: + * - Vertical or horizontal layout (via Style::direction) + * - Gap between children (via Style::gap) + * - Padding, border, background via Style + * - Vertically expandable children + * + * Layout direction and gap are style properties, configurable via + * stylesheets or inline styles: + * + * $container->setStyle(new Style(direction: Direction::Horizontal, gap: 1)); + * + * Or via stylesheet rules: + * + * $stylesheet->addRule('.panes', new Style(direction: Direction::Horizontal, gap: 2)); + * + * Layout and chrome rendering is handled by the Renderer. + * + * @experimental + * + * @author Fabien Potencier + */ +class ContainerWidget extends AbstractWidget implements ContainerInterface, VerticallyExpandableInterface +{ + /** @var AbstractWidget[] */ + private array $children = []; + private bool $verticallyExpanded = false; + + public function __construct() + { + } + + /** + * @return $this + */ + public function add(AbstractWidget $widget): static + { + $widget->setParent($this); + $this->children[] = $widget; + $this->invalidate(); + + if (null !== $this->getContext()) { + $this->getContext()->attachChild($this, $widget); + } + + return $this; + } + + /** + * @return $this + */ + public function remove(AbstractWidget $widget): static + { + $index = array_search($widget, $this->children, true); + if (false !== $index) { + $child = $this->children[(int) $index]; + $child->setParent(null); + if (null !== $this->getContext()) { + $this->getContext()->detachChild($child); + } + array_splice($this->children, (int) $index, 1); + $this->invalidate(); + } + + return $this; + } + + /** + * Remove all child widgets. + * + * @return $this + */ + public function clear(): static + { + foreach ($this->children as $child) { + $child->setParent(null); + if (null !== $this->getContext()) { + $this->getContext()->detachChild($child); + } + } + if ([] !== $this->children) { + $this->children = []; + $this->invalidate(); + } + + return $this; + } + + /** + * Get all child widgets. + * + * @return AbstractWidget[] + */ + public function all(): array + { + return $this->children; + } + + /** + * Set whether the container should fill available height. + * + * @return $this + */ + public function expandVertically(bool $expand): self + { + if ($this->verticallyExpanded !== $expand) { + $this->verticallyExpanded = $expand; + $this->invalidate(); + } + + return $this; + } + + /** + * Check if the container should fill available height. + * + * Returns true if explicitly set, or if any child needs to expand vertically. + * This allows vertical expansion to propagate up automatically from descendants. + */ + public function isVerticallyExpanded(): bool + { + if ($this->verticallyExpanded) { + return true; + } + + // Check if any child needs fill height + foreach ($this->all() as $child) { + if ($child instanceof VerticallyExpandableInterface && $child->isVerticallyExpanded()) { + return true; + } + } + + return false; + } + + /** + * Not called in the standard rendering pipeline. + * + * The Renderer dispatches all ContainerWidget instances to + * renderContainer(), which owns layout (direction, gap) and chrome + * (padding, border, background). This method only exists to satisfy + * the abstract contract from AbstractWidget. + * + * @return string[] + */ + public function render(RenderContext $context): array + { + throw new LogicException(\sprintf('%s rendering is handled by the Renderer via renderContainer(). This method should never be called directly.', static::class)); + } +} diff --git a/src/Symfony/Component/Tui/Widget/DirtyWidgetTrait.php b/src/Symfony/Component/Tui/Widget/DirtyWidgetTrait.php new file mode 100644 index 0000000000000..87eb836fedabd --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/DirtyWidgetTrait.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +/** + * Tracks render revisions for dirty widgets. + * + * @experimental + * + * @author Fabien Potencier + */ +trait DirtyWidgetTrait +{ + private int $renderRevision = 0; + + public function getRenderRevision(): int + { + return $this->renderRevision; + } + + public function invalidate(): void + { + ++$this->renderRevision; + } +} diff --git a/src/Symfony/Component/Tui/Widget/Editor/EditorDocument.php b/src/Symfony/Component/Tui/Widget/Editor/EditorDocument.php new file mode 100644 index 0000000000000..c108268faf38d --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/Editor/EditorDocument.php @@ -0,0 +1,732 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget\Editor; + +use Symfony\Component\Tui\Widget\Util\KillRing; +use Symfony\Component\Tui\Widget\Util\Line; +use Symfony\Component\Tui\Widget\Util\StringUtils; + +/** + * Multi-line text buffer with cursor, undo/redo, and kill ring. + * + * Pure model: no rendering, no terminal I/O, no scroll/viewport logic. + * The EditorWidget orchestrates input → document → viewport → render. + * + * @experimental + * + * @author Fabien Potencier + * + * @internal + */ +final class EditorDocument +{ + /** @var string[] */ + private array $lines = ['']; + private int $cursorLine = 0; + private int $cursorCol = 0; + + private KillRing $killRing; + + // Undo/Redo + /** @var array */ + private array $undoStack = []; + /** @var array */ + private array $redoStack = []; + + // Character jump mode + private ?string $jumpMode = null; // 'forward' or 'backward' + + // Paste handling + private int $pasteCount = 0; + /** @var array */ + private array $pasteMarkers = []; + + public function __construct(?KillRing $killRing = null) + { + $this->killRing = $killRing ?? new KillRing(); + } + + // --- Accessors --- + + /** + * @return string[] + */ + public function getLines(): array + { + return $this->lines; + } + + public function getCursorLine(): int + { + return $this->cursorLine; + } + + public function getCursorCol(): int + { + return $this->cursorCol; + } + + public function setCursorLine(int $line): void + { + $this->cursorLine = $line; + } + + public function setCursorCol(int $col): void + { + $this->cursorCol = $col; + } + + public function getKillRing(): KillRing + { + return $this->killRing; + } + + /** + * Get paste markers for large pastes. + * + * @return array + */ + public function getPasteMarkers(): array + { + return $this->pasteMarkers; + } + + public function getText(): string + { + $text = implode("\n", $this->lines); + + if ([] === $this->pasteMarkers) { + return $text; + } + + $replacements = []; + foreach ($this->pasteMarkers as $pasteMarker) { + $replacements[$pasteMarker['marker']] = $pasteMarker['content']; + } + + return strtr($text, $replacements); + } + + // --- Character Jump Mode --- + + public function getJumpMode(): ?string + { + return $this->jumpMode; + } + + public function setJumpMode(?string $mode): void + { + $this->jumpMode = $mode; + } + + // --- Text Mutation --- + + public function setText(string $text): bool + { + $text = StringUtils::sanitizeUtf8($text); + $text = str_replace(["\r\n", "\r"], "\n", $text); + $lines = '' === $text ? [''] : explode("\n", $text); + $needsReset = $lines !== $this->lines || 0 !== $this->cursorLine || 0 !== $this->cursorCol; + + $this->lines = $lines; + $this->cursorLine = 0; + $this->cursorCol = 0; + $this->undoStack = []; + $this->redoStack = []; + $this->pasteMarkers = []; + $this->pasteCount = 0; + + return $needsReset; + } + + public function insertText(string $text): void + { + $text = StringUtils::sanitizeUtf8($text); + if ('' === $text) { + return; + } + + $this->pushUndoSnapshot(); + $this->killRing->resetAction(); + + $this->insertTextAtCursor($text); + } + + public function insertNewLine(): void + { + $this->pushUndoSnapshot(); + $this->killRing->resetAction(); + + $currentLine = $this->lines[$this->cursorLine]; + $beforeCursor = substr($currentLine, 0, $this->cursorCol); + $afterCursor = substr($currentLine, $this->cursorCol); + + $this->lines[$this->cursorLine] = $beforeCursor; + array_splice($this->lines, $this->cursorLine + 1, 0, [$afterCursor]); + + ++$this->cursorLine; + $this->cursorCol = 0; + } + + public function deleteCharBackward(): bool + { + if (0 === $this->cursorCol && 0 === $this->cursorLine) { + return false; + } + + $this->pushUndoSnapshot(); + $this->killRing->resetAction(); + + if ($this->cursorCol > 0) { + $line = $this->currentLine(); + $line->deleteCharBackward(); + $this->applyLine($line); + } elseif ($this->cursorLine > 0) { + // Merge with previous line + $currentLine = $this->lines[$this->cursorLine]; + $prevLine = $this->lines[$this->cursorLine - 1]; + + $this->cursorCol = \strlen($prevLine); + $this->lines[$this->cursorLine - 1] = $prevLine.$currentLine; + + array_splice($this->lines, $this->cursorLine, 1); + --$this->cursorLine; + } + + return true; + } + + public function deleteCharForward(): bool + { + $currentLine = $this->lines[$this->cursorLine]; + + if ($this->cursorCol >= \strlen($currentLine) && $this->cursorLine >= \count($this->lines) - 1) { + return false; + } + + $this->pushUndoSnapshot(); + $this->killRing->resetAction(); + + if ($this->cursorCol < \strlen($currentLine)) { + $line = $this->currentLine(); + $line->deleteCharForward(); + $this->applyLine($line); + } elseif ($this->cursorLine < \count($this->lines) - 1) { + // Merge with next line + $nextLine = $this->lines[$this->cursorLine + 1]; + $this->lines[$this->cursorLine] = $currentLine.$nextLine; + array_splice($this->lines, $this->cursorLine + 1, 1); + } + + return true; + } + + public function deleteLine(): bool + { + if (1 === \count($this->lines) && '' === $this->lines[0]) { + return false; + } + + $this->pushUndoSnapshot(); + $this->killRing->resetAction(); + + if (\count($this->lines) > 1) { + array_splice($this->lines, $this->cursorLine, 1); + if ($this->cursorLine >= \count($this->lines)) { + --$this->cursorLine; + } + } else { + $this->lines = ['']; + } + + $this->cursorCol = min($this->cursorCol, \strlen($this->lines[$this->cursorLine])); + + return true; + } + + public function deleteToLineEnd(): bool + { + $line = $this->currentLine(); + $deletedText = $line->deleteToEnd(); + if ('' === $deletedText) { + return false; + } + + $this->pushUndoSnapshot(); + $this->killRing->add($deletedText, false); + $this->applyLine($line); + + return true; + } + + public function deleteToLineStart(): bool + { + $line = $this->currentLine(); + $deletedText = $line->deleteToStart(); + if ('' === $deletedText) { + return false; + } + + $this->pushUndoSnapshot(); + $this->killRing->add($deletedText, true); + $this->applyLine($line); + + return true; + } + + public function deleteWordBackward(): bool + { + if (0 === $this->cursorCol) { + return $this->deleteCharBackward(); + } + + $this->pushUndoSnapshot(); + + $line = $this->currentLine(); + $deletedText = $line->deleteWordBackward(); + $this->killRing->add($deletedText, true); + $this->applyLine($line); + + return true; + } + + public function deleteWordForward(): bool + { + $currentLine = $this->lines[$this->cursorLine]; + + if ($this->cursorCol >= \strlen($currentLine)) { + return $this->deleteCharForward(); + } + + $this->pushUndoSnapshot(); + + $line = $this->currentLine(); + $deletedText = $line->deleteWordForward(); + $this->killRing->add($deletedText, false); + $this->applyLine($line); + + return true; + } + + // --- Cursor Navigation --- + + public function isOnFirstLine(): bool + { + return 0 === $this->cursorLine; + } + + public function isOnLastLine(): bool + { + return $this->cursorLine >= \count($this->lines) - 1; + } + + public function moveToLineStart(): bool + { + if (0 !== $this->cursorCol) { + $this->cursorCol = 0; + + return true; + } + + return false; + } + + public function moveToLineEnd(): bool + { + $lineLength = \strlen($this->lines[$this->cursorLine]); + if ($this->cursorCol !== $lineLength) { + $this->cursorCol = $lineLength; + + return true; + } + + return false; + } + + public function moveCursorUp(): bool + { + if ($this->cursorLine > 0) { + --$this->cursorLine; + $this->cursorCol = min($this->cursorCol, \strlen($this->lines[$this->cursorLine])); + + return true; + } + + return false; + } + + public function moveCursorDown(): bool + { + if ($this->cursorLine < \count($this->lines) - 1) { + ++$this->cursorLine; + $this->cursorCol = min($this->cursorCol, \strlen($this->lines[$this->cursorLine])); + + return true; + } + + return false; + } + + public function moveCursorLeft(): bool + { + $line = $this->currentLine(); + if ($line->moveCursorLeft()) { + $this->cursorCol = $line->getCursor(); + + return true; + } + + if ($this->cursorLine > 0) { + --$this->cursorLine; + $this->cursorCol = \strlen($this->lines[$this->cursorLine]); + + return true; + } + + return false; + } + + public function moveCursorRight(): bool + { + $line = $this->currentLine(); + if ($line->moveCursorRight()) { + $this->cursorCol = $line->getCursor(); + + return true; + } + + if ($this->cursorLine < \count($this->lines) - 1) { + ++$this->cursorLine; + $this->cursorCol = 0; + + return true; + } + + return false; + } + + public function moveWordBackwards(): bool + { + $this->killRing->resetAction(); + $oldLine = $this->cursorLine; + $oldCol = $this->cursorCol; + + if (0 === $this->cursorCol) { + if ($this->cursorLine > 0) { + --$this->cursorLine; + $this->cursorCol = \strlen($this->lines[$this->cursorLine]); + } + + return $this->cursorLine !== $oldLine || $this->cursorCol !== $oldCol; + } + + $line = $this->currentLine(); + $line->moveWordBackward(); + $this->cursorCol = $line->getCursor(); + + return $this->cursorLine !== $oldLine || $this->cursorCol !== $oldCol; + } + + public function moveWordForwards(): bool + { + $this->killRing->resetAction(); + $oldLine = $this->cursorLine; + $oldCol = $this->cursorCol; + + if ($this->cursorCol >= \strlen($this->lines[$this->cursorLine])) { + if ($this->cursorLine < \count($this->lines) - 1) { + ++$this->cursorLine; + $this->cursorCol = 0; + } + + return $this->cursorLine !== $oldLine || $this->cursorCol !== $oldCol; + } + + $line = $this->currentLine(); + $line->moveWordForward(); + $this->cursorCol = $line->getCursor(); + + return $this->cursorLine !== $oldLine || $this->cursorCol !== $oldCol; + } + + /** + * Jump to the first occurrence of a character in the specified direction. + * Multi-line search. Case-sensitive. Skips the current cursor position. + */ + public function jumpToChar(string $char, string $direction): bool + { + $this->killRing->resetAction(); + $isForward = 'forward' === $direction; + $lineCount = \count($this->lines); + + $end = $isForward ? $lineCount : -1; + $step = $isForward ? 1 : -1; + + for ($lineIdx = $this->cursorLine; $lineIdx !== $end; $lineIdx += $step) { + $line = $this->lines[$lineIdx]; + $isCurrentLine = $lineIdx === $this->cursorLine; + + if ($isCurrentLine) { + if ($isForward) { + $nextByteOffset = $this->cursorCol; + grapheme_extract($line, 1, \GRAPHEME_EXTR_COUNT, $nextByteOffset, $nextByteOffset); + $idx = strpos($line, $char, $nextByteOffset); + } else { + $searchIn = 0 === $this->cursorCol ? false : substr($line, 0, $this->cursorCol); + $idx = false !== $searchIn ? strrpos($searchIn, $char) : false; + } + } else { + $idx = $isForward ? strpos($line, $char) : strrpos($line, $char); + } + + if (false !== $idx) { + $this->cursorLine = $lineIdx; + $this->cursorCol = $idx; + + return true; + } + } + + return false; + } + + // --- Undo/Redo --- + + public function undo(): bool + { + if ([] === $this->undoStack) { + return false; + } + + $this->redoStack[] = $this->createSnapshot(); + + $snapshot = array_pop($this->undoStack); + $this->restoreSnapshot($snapshot); + $this->killRing->resetAll(); + + return true; + } + + public function redo(): bool + { + if ([] === $this->redoStack) { + return false; + } + + $this->undoStack[] = $this->createSnapshot(); + + $snapshot = array_pop($this->redoStack); + $this->restoreSnapshot($snapshot); + $this->killRing->resetAll(); + + return true; + } + + // --- Kill Ring --- + + public function yank(): bool + { + $text = $this->killRing->peek(); + if (null === $text) { + return false; + } + + $this->pushUndoSnapshot(); + + $startLine = $this->cursorLine; + $startCol = $this->cursorCol; + + $this->insertTextAtCursor($text); + + $this->killRing->recordYank([ + 'startLine' => $startLine, + 'startCol' => $startCol, + 'endLine' => $this->cursorLine, + 'endCol' => $this->cursorCol, + ]); + + return true; + } + + public function yankPop(): bool + { + if (!$this->killRing->canYankPop()) { + return false; + } + + $this->pushUndoSnapshot(); + $this->deleteYankedText(); + + $text = $this->killRing->rotate(); + if (null === $text) { + return false; + } + + $startLine = $this->cursorLine; + $startCol = $this->cursorCol; + + $this->insertTextAtCursor($text); + + $this->killRing->recordYank([ + 'startLine' => $startLine, + 'startCol' => $startCol, + 'endLine' => $this->cursorLine, + 'endCol' => $this->cursorCol, + ]); + + return true; + } + + // --- Paste Handling --- + + /** + * Handle pasted content. + * + * Large pastes (>10 lines) create a marker for efficient display. + */ + public function handlePaste(string $content): void + { + $content = str_replace(["\r\n", "\r"], "\n", StringUtils::sanitizeUtf8($content)); + if ('' === $content) { + return; + } + + $lines = explode("\n", $content); + + // For large pastes, create a marker + if (\count($lines) > 10) { + ++$this->pasteCount; + $id = bin2hex(random_bytes(8)); + $marker = \sprintf('[paste #%d +%d lines <%s>]', $this->pasteCount, \count($lines), $id); + $this->pasteMarkers[] = ['marker' => $marker, 'content' => $content]; + $this->insertText($marker); + + return; + } + + // Insert first line at cursor + $this->insertText($lines[0]); + + // Insert remaining lines + for ($i = 1; $i < \count($lines); ++$i) { + $this->insertNewLine(); + $this->insertText($lines[$i]); + } + } + + // --- Internal --- + + private function insertTextAtCursor(string $text): void + { + $text = str_replace(["\r\n", "\r"], "\n", $text); + $lines = explode("\n", $text); + + if (1 === \count($lines)) { + $currentLine = $this->lines[$this->cursorLine]; + $this->lines[$this->cursorLine] = substr($currentLine, 0, $this->cursorCol) + .$text + .substr($currentLine, $this->cursorCol); + $this->cursorCol += \strlen($text); + + return; + } + + $currentLine = $this->lines[$this->cursorLine]; + $before = substr($currentLine, 0, $this->cursorCol); + $after = substr($currentLine, $this->cursorCol); + + $this->lines[$this->cursorLine] = $before.$lines[0]; + + for ($i = 1; $i < \count($lines) - 1; ++$i) { + array_splice($this->lines, $this->cursorLine + $i, 0, [$lines[$i]]); + } + + $lastLineIndex = $this->cursorLine + \count($lines) - 1; + array_splice($this->lines, $lastLineIndex, 0, [($lines[\count($lines) - 1] ?? '').$after]); + + $this->cursorLine = $lastLineIndex; + $this->cursorCol = \strlen($lines[\count($lines) - 1] ?? ''); + } + + private function currentLine(): Line + { + return new Line($this->lines[$this->cursorLine], $this->cursorCol); + } + + private function applyLine(Line $line): void + { + $this->lines[$this->cursorLine] = $line->getText(); + $this->cursorCol = $line->getCursor(); + } + + private function pushUndoSnapshot(): void + { + $this->undoStack[] = $this->createSnapshot(); + + if (\count($this->undoStack) > 100) { + array_shift($this->undoStack); + } + + $this->redoStack = []; + } + + /** + * @return array{lines: string[], cursorLine: int, cursorCol: int} + */ + private function createSnapshot(): array + { + return [ + 'lines' => $this->lines, + 'cursorLine' => $this->cursorLine, + 'cursorCol' => $this->cursorCol, + ]; + } + + /** + * @param array{lines: string[], cursorLine: int, cursorCol: int} $snapshot + */ + private function restoreSnapshot(array $snapshot): void + { + $this->lines = $snapshot['lines']; + $this->cursorLine = $snapshot['cursorLine']; + $this->cursorCol = $snapshot['cursorCol']; + } + + private function deleteYankedText(): void + { + $range = $this->killRing->getLastYankRange(); + if (null === $range) { + return; + } + + $startLine = $range['startLine']; + $startCol = $range['startCol']; + $endLine = $range['endLine']; + $endCol = $range['endCol']; + + if ($startLine === $endLine) { + $line = $this->lines[$startLine]; + $this->lines[$startLine] = substr($line, 0, $startCol) + .substr($line, $endCol); + } else { + $startText = substr($this->lines[$startLine], 0, $startCol); + $endText = substr($this->lines[$endLine], $endCol); + $this->lines[$startLine] = $startText.$endText; + + $removeCount = $endLine - $startLine; + array_splice($this->lines, $startLine + 1, $removeCount); + } + + $this->cursorLine = $startLine; + $this->cursorCol = $startCol; + } +} diff --git a/src/Symfony/Component/Tui/Widget/Editor/EditorRenderer.php b/src/Symfony/Component/Tui/Widget/Editor/EditorRenderer.php new file mode 100644 index 0000000000000..040fc7f469468 --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/Editor/EditorRenderer.php @@ -0,0 +1,222 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget\Editor; + +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Ansi\TextWrapper; +use Symfony\Component\Tui\Style\CursorShape; +use Symfony\Component\Tui\Style\Style; + +/** + * Renders editor content lines with cursor and word-wrap. + * + * This is a stateless helper: all state (document content, cursor position, + * scroll offset) is passed in from the EditorWidget. + * + * @experimental + * + * @author Fabien Potencier + * + * @internal + */ +final class EditorRenderer +{ + /** + * Render the full editor output: borders + content lines + padding. + * + * @param string[] $lines Document lines + * @param array{scrollOffset: int, visibleLineCount: int, linesAbove: int, linesBelow: int} $viewport Viewport parameters + * @param int $cursorLine Current cursor line + * @param int $cursorCol Current cursor column + * @param int $columns Terminal columns + * @param int $maxDisplayRows Maximum display rows + * @param bool $verticallyExpanded Whether to fill all rows + * @param bool $focused Whether the editor has focus + * @param CursorShape $cursorShape Cursor shape + * @param Style $frameStyle Style for borders + * + * @return string[] + */ + public function render( + array $lines, + array $viewport, + int $cursorLine, + int $cursorCol, + int $columns, + int $maxDisplayRows, + bool $verticallyExpanded, + bool $focused, + CursorShape $cursorShape, + Style $frameStyle, + ): array { + $result = []; + + // Top border (with scroll indicator if scrolled down) + if ($viewport['linesAbove'] > 0) { + $indicator = "─── ↑ {$viewport['linesAbove']} more "; + $remaining = $columns - AnsiUtils::visibleWidth($indicator); + $result[] = $frameStyle->apply($indicator.str_repeat('─', max(0, $remaining))); + } else { + $result[] = $frameStyle->apply(str_repeat('─', $columns)); + } + + // Render visible lines + $displayRowsRendered = 0; + for ($i = 0; $i < $viewport['visibleLineCount']; ++$i) { + $lineIndex = $viewport['scrollOffset'] + $i; + $line = $lines[$lineIndex] ?? ''; + $isCursorLine = $lineIndex === $cursorLine; + + $renderedLines = $this->renderLine($line, $isCursorLine, $cursorCol, $columns, $cursorShape, $focused); + foreach ($renderedLines as $renderedLine) { + $result[] = $renderedLine; + } + $displayRowsRendered += \count($renderedLines); + } + + // In fill mode, pad with empty rows to fill the allocated space + if ($verticallyExpanded && $displayRowsRendered < $maxDisplayRows) { + $emptyLine = str_repeat(' ', $columns); + for ($i = $displayRowsRendered; $i < $maxDisplayRows; ++$i) { + $result[] = $emptyLine; + } + } + + // Bottom border (with scroll indicator if more content below) + if ($viewport['linesBelow'] > 0) { + $indicator = "─── ↓ {$viewport['linesBelow']} more "; + $remaining = $columns - AnsiUtils::visibleWidth($indicator); + $result[] = $frameStyle->apply($indicator.str_repeat('─', max(0, $remaining))); + } else { + $result[] = $frameStyle->apply(str_repeat('─', $columns)); + } + + return $result; + } + + /** + * Render a logical line, possibly wrapped into multiple display lines. + * + * @return string[] Array of display lines (one or more if wrapped) + */ + private function renderLine(string $line, bool $isCursorLine, int $cursorCol, int $columns, CursorShape $cursorShape, bool $focused): array + { + $chunks = TextWrapper::wrapLineIntoChunks($line, $columns); + + $result = []; + $chunkCount = \count($chunks); + + foreach ($chunks as $i => $chunk) { + $chunkText = $chunk['text']; + $displayLine = rtrim($chunkText); + $isLastChunk = $i === $chunkCount - 1; + + // Determine if the cursor is in this chunk + $hasCursor = false; + $cursorPosInChunk = 0; + + if ($isCursorLine) { + if ($isLastChunk) { + if ($cursorCol >= $chunk['startIndex']) { + $hasCursor = true; + $cursorPosInChunk = $cursorCol - $chunk['startIndex']; + } + } elseif ($cursorCol >= $chunk['startIndex'] && $cursorCol < $chunk['endIndex']) { + $hasCursor = true; + $cursorPosInChunk = $cursorCol - $chunk['startIndex']; + } + } + + if ($hasCursor) { + $displayLine = $this->renderCursorInChunk($chunkText, $cursorPosInChunk, $columns, $cursorShape, $focused); + } + + // Pad to width + $visibleWidth = AnsiUtils::visibleWidth($displayLine); + $padding = max(0, $columns - $visibleWidth); + + $result[] = $displayLine.str_repeat(' ', $padding); + } + + return $result; + } + + /** + * Render a chunk of text with the cursor marker inserted at the given byte position. + */ + private function renderCursorInChunk(string $chunkText, int $cursorPosInChunk, int $columns, CursorShape $cursorShape, bool $focused): string + { + $atCursor = ''; + $afterCursor = ''; + $beforeCursor = ''; + $cursorCharIndex = 0; + + $graphemes = grapheme_str_split($chunkText); + if (false !== $graphemes) { + $bytePos = 0; + $found = false; + foreach ($graphemes as $index => $grapheme) { + $graphemeBytes = \strlen($grapheme); + if ($cursorPosInChunk < $bytePos) { + $cursorCharIndex = $index; + $found = true; + break; + } + if ($cursorPosInChunk < $bytePos + $graphemeBytes) { + $cursorCharIndex = $index; + $found = true; + break; + } + $bytePos += $graphemeBytes; + } + if (!$found || !isset($graphemes[$cursorCharIndex])) { + $cursorCharIndex = \count($graphemes); + } + + $beforeCursor = implode('', array_slice($graphemes, 0, $cursorCharIndex)); + if (isset($graphemes[$cursorCharIndex])) { + $atCursor = $graphemes[$cursorCharIndex]; + $afterCursor = implode('', array_slice($graphemes, $cursorCharIndex + 1)); + } + } + if (false === $graphemes) { + $beforeCursor = substr($chunkText, 0, $cursorPosInChunk); + $afterCursor = $cursorPosInChunk < \strlen($chunkText) ? substr($chunkText, $cursorPosInChunk + 1) : ''; + $atCursor = $chunkText[$cursorPosInChunk] ?? ''; + } + + $marker = $focused ? AnsiUtils::cursorMarker($cursorShape) : ''; + + if ('' !== $afterCursor || '' !== $atCursor) { + // Cursor is on a character + return $beforeCursor.$marker.$atCursor.$afterCursor; + } + + // Cursor is at the end of the line + $beforeCursorWidth = AnsiUtils::visibleWidth($beforeCursor); + if ($beforeCursorWidth < $columns) { + // Room for cursor after the text + return $beforeCursor.$marker.' '; + } + + // Full width, place cursor on the last grapheme + $graphemesFallback = grapheme_str_split($beforeCursor); + if (false !== $graphemesFallback && [] !== $graphemesFallback) { + /** @var string $lastGrapheme */ + $lastGrapheme = array_pop($graphemesFallback); + + return implode('', $graphemesFallback).$marker.$lastGrapheme; + } + + return $beforeCursor; + } +} diff --git a/src/Symfony/Component/Tui/Widget/Editor/EditorViewport.php b/src/Symfony/Component/Tui/Widget/Editor/EditorViewport.php new file mode 100644 index 0000000000000..8a8ecfd28343b --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/Editor/EditorViewport.php @@ -0,0 +1,161 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget\Editor; + +use Symfony\Component\Tui\Ansi\TextWrapper; + +/** + * Manages scroll offset and viewport calculations for the editor. + * + * Owns the scroll offset and computes which logical lines are visible + * in the terminal viewport, accounting for word-wrap. Also handles + * mouse cursor placement (display-row → logical line+col mapping). + * + * @experimental + * + * @author Fabien Potencier + * + * @internal + */ +final class EditorViewport +{ + private int $scrollOffset = 0; + + public function getScrollOffset(): int + { + return $this->scrollOffset; + } + + public function reset(): void + { + $this->scrollOffset = 0; + } + + /** + * Scroll by a full page. + * + * @param string[] $lines Document lines + * @param int $direction 1 for down, -1 for up + * @param int $pageSize Number of lines per page + * @param int $cursorLine Current cursor line + * @param int $cursorCol Current cursor column + * + * @return array{cursorLine: int, cursorCol: int}|null New cursor state, or null if unchanged + */ + public function pageScroll(array $lines, int $direction, int $pageSize, int $cursorLine, int $cursorCol): ?array + { + $targetLine = max(0, min(\count($lines) - 1, $cursorLine + $direction * $pageSize)); + + if ($targetLine !== $cursorLine) { + return [ + 'cursorLine' => $targetLine, + 'cursorCol' => min($cursorCol, \strlen($lines[$targetLine])), + ]; + } + + return null; + } + + /** + * Adjust scroll offset so the cursor is visible, and return viewport parameters. + * + * @param string[] $lines Document lines + * @param int $cursorLine Current cursor line + * @param int $maxDisplayRows Maximum display rows available + * @param int $columns Terminal columns + * @param bool $verticallyExpanded Whether to fill all available rows + * @param int $minVisibleLines Minimum visible lines + * + * @return array{scrollOffset: int, visibleLineCount: int, linesAbove: int, linesBelow: int} + */ + public function computeViewport(array $lines, int $cursorLine, int $maxDisplayRows, int $columns, bool $verticallyExpanded, int $minVisibleLines): array + { + $totalLines = \count($lines); + + // Calculate how many logical lines fit from the current scroll offset + $logicalLinesFitting = self::logicalLinesFitting($lines, $this->scrollOffset, $maxDisplayRows, $columns); + + // Adjust scroll offset to keep cursor visible + if ($cursorLine < $this->scrollOffset) { + $this->scrollOffset = $cursorLine; + } elseif ($cursorLine >= $this->scrollOffset + $logicalLinesFitting) { + $this->scrollOffset = self::scrollOffsetForCursorLine($lines, $cursorLine, $maxDisplayRows, $columns); + } + + // Clamp scroll offset to valid range + $this->scrollOffset = max(0, min($this->scrollOffset, max(0, $totalLines - 1))); + + // Recalculate after potential scroll offset change + $logicalLinesFitting = self::logicalLinesFitting($lines, $this->scrollOffset, $maxDisplayRows, $columns); + + // Calculate visible line count + if ($verticallyExpanded) { + $visibleLineCount = $logicalLinesFitting; + } else { + $visibleLineCount = min(max($minVisibleLines, $totalLines), $logicalLinesFitting); + } + + return [ + 'scrollOffset' => $this->scrollOffset, + 'visibleLineCount' => $visibleLineCount, + 'linesAbove' => $this->scrollOffset, + 'linesBelow' => max(0, $totalLines - $this->scrollOffset - $visibleLineCount), + ]; + } + + /** + * Calculate how many logical lines fit in a given number of display rows, + * starting from a given offset, accounting for wrapping. + * + * @param string[] $lines + */ + private static function logicalLinesFitting(array $lines, int $fromLine, int $maxDisplayRows, int $columns): int + { + $displayRows = 0; + $count = 0; + $totalLines = \count($lines); + + for ($i = $fromLine; $i < $totalLines; ++$i) { + $lineDisplayRows = \count(TextWrapper::wrapLineIntoChunks($lines[$i], $columns)); + if ($displayRows + $lineDisplayRows > $maxDisplayRows) { + break; + } + $displayRows += $lineDisplayRows; + ++$count; + } + + return max(1, $count); + } + + /** + * Find the scroll offset that places cursorLine as the last visible + * logical line, accounting for wrapping. + * + * @param string[] $lines + */ + private static function scrollOffsetForCursorLine(array $lines, int $cursorLine, int $maxDisplayRows, int $columns): int + { + $displayRows = 0; + $offset = $cursorLine; + + for ($i = $cursorLine; $i >= 0; --$i) { + $lineDisplayRows = \count(TextWrapper::wrapLineIntoChunks($lines[$i], $columns)); + if ($displayRows + $lineDisplayRows > $maxDisplayRows) { + break; + } + $displayRows += $lineDisplayRows; + $offset = $i; + } + + return $offset; + } +} diff --git a/src/Symfony/Component/Tui/Widget/EditorWidget.php b/src/Symfony/Component/Tui/Widget/EditorWidget.php new file mode 100644 index 0000000000000..ca9c8c561ca44 --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/EditorWidget.php @@ -0,0 +1,599 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Event\CancelEvent; +use Symfony\Component\Tui\Event\ChangeEvent; +use Symfony\Component\Tui\Event\SubmitEvent; +use Symfony\Component\Tui\Input\Key; +use Symfony\Component\Tui\Input\Keybindings; +use Symfony\Component\Tui\Render\RenderContext; +use Symfony\Component\Tui\Style\CursorShape; +use Symfony\Component\Tui\Widget\Editor\EditorDocument; +use Symfony\Component\Tui\Widget\Editor\EditorRenderer; +use Symfony\Component\Tui\Widget\Editor\EditorViewport; +use Symfony\Component\Tui\Widget\Util\KillRing; +use Symfony\Component\Tui\Widget\Util\StringUtils; + +/** + * Multi-line text editor. + * + * Orchestrates input routing between collaborators: + * - {@see EditorDocument}: text buffer, cursor, undo/redo, kill ring + * - {@see EditorViewport}: scroll offset, viewport calculations + * - {@see EditorRenderer}: line rendering with cursor and word-wrap + * + * @experimental + * + * @author Fabien Potencier + */ +class EditorWidget extends AbstractWidget implements FocusableInterface, VerticallyExpandableInterface +{ + use BracketedPasteTrait; + use FocusableTrait; + use KeybindingsTrait; + + private EditorDocument $document; + private EditorViewport $viewport; + private EditorRenderer $editorRenderer; + private int $minVisibleLines = 0; + private ?int $maxVisibleLines = null; + private bool $verticallyExpanded = false; + private ?int $lastMaxVisibleLines = null; + + private bool $submitted = false; + + public function __construct( + ?Keybindings $keybindings = null, + ?KillRing $killRing = null, + ) { + if (null !== $keybindings) { + $this->setKeybindings($keybindings); + } + $this->document = new EditorDocument($killRing); + $this->viewport = new EditorViewport(); + $this->editorRenderer = new EditorRenderer(); + } + + public function getText(): string + { + return $this->document->getText(); + } + + /** + * Check if the editor was submitted (Ctrl+Enter) vs cancelled (Escape). + */ + public function wasSubmitted(): bool + { + return $this->submitted; + } + + /** + * Get paste markers for large pastes. + * + * @return array + */ + public function getPasteMarkers(): array + { + return $this->document->getPasteMarkers(); + } + + /** + * @return $this + */ + public function setText(string $text): self + { + if ($this->document->setText($text)) { + $this->viewport->reset(); + $this->invalidate(); + } + + return $this; + } + + /** + * @return $this + */ + public function setMinVisibleLines(int $minVisibleLines): self + { + $minVisibleLines = max(0, $minVisibleLines); + if ($this->minVisibleLines !== $minVisibleLines) { + $this->minVisibleLines = $minVisibleLines; + $this->invalidate(); + } + + return $this; + } + + /** + * @return $this + */ + public function setMaxVisibleLines(?int $maxVisibleLines): self + { + if (null !== $maxVisibleLines) { + $maxVisibleLines = max(1, $maxVisibleLines); + } + + if ($this->maxVisibleLines !== $maxVisibleLines) { + $this->maxVisibleLines = $maxVisibleLines; + $this->invalidate(); + } + + return $this; + } + + /** + * @return $this + */ + public function expandVertically(bool $fill): self + { + if ($this->verticallyExpanded !== $fill) { + $this->verticallyExpanded = $fill; + $this->invalidate(); + } + + return $this; + } + + public function isVerticallyExpanded(): bool + { + return $this->verticallyExpanded; + } + + public function setFocused(bool $focused): self + { + if ($this->focused !== $focused) { + $this->focused = $focused; + $this->invalidate(); + $this->getContext()?->requestRender(); + } + + return $this; + } + + /** + * @param callable(SubmitEvent): void $callback + * + * @return $this + */ + public function onSubmit(callable $callback): self + { + return $this->on(SubmitEvent::class, $callback); + } + + /** + * @param callable(CancelEvent): void $callback + * + * @return $this + */ + public function onCancel(callable $callback): self + { + return $this->on(CancelEvent::class, $callback); + } + + /** + * @param callable(ChangeEvent): void $callback + * + * @return $this + */ + public function onChange(callable $callback): self + { + return $this->on(ChangeEvent::class, $callback); + } + + public function handleInput(string $data): void + { + if (null !== $this->onInput && ($this->onInput)($data)) { + return; + } + + // Handle bracketed paste + $pastedText = $this->processBracketedPaste($data); + if (null !== $pastedText) { + $this->document->handlePaste($pastedText); + $this->notifyChange(); + if ('' === $data) { + return; + } + } elseif ($this->isBufferingPaste()) { + return; + } + + $kb = $this->getKeybindings(); + + // Handle character jump mode (awaiting next character to jump to) + if (null !== $this->document->getJumpMode()) { + if ($kb->matches($data, 'jump_forward') || $kb->matches($data, 'jump_backward')) { + $this->document->setJumpMode(null); + + return; + } + + if (\ord($data[0]) >= 32 && !str_starts_with($data, "\x1b")) { + $direction = $this->document->getJumpMode(); + $this->document->setJumpMode(null); + if ($this->document->jumpToChar($data, $direction)) { + $this->invalidate(); + } + + return; + } + + // Control character - cancel and fall through to normal handling + $this->document->setJumpMode(null); + } + + // Copy (leave to parent) + if ($kb->matches($data, 'copy')) { + return; + } + + // Undo/Redo + if ($kb->matches($data, 'undo')) { + if ($this->document->undo()) { + $this->notifyChange(); + } + + return; + } + + if ($kb->matches($data, 'redo')) { + if ($this->document->redo()) { + $this->notifyChange(); + } + + return; + } + + // Kill ring + if ($kb->matches($data, 'yank')) { + if ($this->document->yank()) { + $this->notifyChange(); + } + + return; + } + + if ($kb->matches($data, 'yank_pop')) { + if ($this->document->yankPop()) { + $this->notifyChange(); + } + + return; + } + + // New line (check before submit to allow Shift+Enter) + if ($kb->matches($data, 'new_line')) { + $this->document->insertNewLine(); + $this->notifyChange(); + + return; + } + + // Submit + if ($kb->matches($data, 'submit')) { + $this->submitted = true; + $this->dispatch(new SubmitEvent($this, $this->getText())); + + return; + } + + // Navigation + if ($kb->matches($data, 'cursor_up')) { + if ($this->document->isOnFirstLine()) { + $this->document->moveToLineStart(); + } else { + $this->document->moveCursorUp(); + } + $this->invalidate(); + + return; + } + + if ($kb->matches($data, 'cursor_down')) { + if ($this->document->isOnLastLine()) { + $this->document->moveToLineEnd(); + } else { + $this->document->moveCursorDown(); + } + $this->invalidate(); + + return; + } + + if ($kb->matches($data, 'cursor_left')) { + if ($this->document->moveCursorLeft()) { + $this->invalidate(); + } + + return; + } + + if ($kb->matches($data, 'cursor_right')) { + if ($this->document->moveCursorRight()) { + $this->invalidate(); + } + + return; + } + + if ($kb->matches($data, 'cursor_line_start')) { + if ($this->document->moveToLineStart()) { + $this->invalidate(); + } + + return; + } + + if ($kb->matches($data, 'cursor_line_end')) { + if ($this->document->moveToLineEnd()) { + $this->invalidate(); + } + + return; + } + + if ($kb->matches($data, 'cursor_word_left')) { + if ($this->document->moveWordBackwards()) { + $this->invalidate(); + } + + return; + } + + if ($kb->matches($data, 'cursor_word_right')) { + if ($this->document->moveWordForwards()) { + $this->invalidate(); + } + + return; + } + + // Page scroll + if ($kb->matches($data, 'page_up')) { + $result = $this->viewport->pageScroll($this->document->getLines(), -1, $this->getPageSize(), $this->document->getCursorLine(), $this->document->getCursorCol()); + if (null !== $result) { + $this->document->setCursorLine($result['cursorLine']); + $this->document->setCursorCol($result['cursorCol']); + $this->invalidate(); + } + + return; + } + + if ($kb->matches($data, 'page_down')) { + $result = $this->viewport->pageScroll($this->document->getLines(), 1, $this->getPageSize(), $this->document->getCursorLine(), $this->document->getCursorCol()); + if (null !== $result) { + $this->document->setCursorLine($result['cursorLine']); + $this->document->setCursorCol($result['cursorCol']); + $this->invalidate(); + } + + return; + } + + // Character jump mode triggers + if ($kb->matches($data, 'jump_forward')) { + $this->document->setJumpMode('forward'); + + return; + } + + if ($kb->matches($data, 'jump_backward')) { + $this->document->setJumpMode('backward'); + + return; + } + + // Deletion (line-level, then word-level, then char-level) + if ($kb->matches($data, 'delete_line')) { + if ($this->document->deleteLine()) { + $this->notifyChange(); + } + + return; + } + + if ($kb->matches($data, 'delete_to_line_end')) { + if ($this->document->deleteToLineEnd()) { + $this->notifyChange(); + } + + return; + } + + if ($kb->matches($data, 'delete_to_line_start')) { + if ($this->document->deleteToLineStart()) { + $this->notifyChange(); + } + + return; + } + + if ($kb->matches($data, 'delete_word_backward')) { + if ($this->document->deleteWordBackward()) { + $this->notifyChange(); + } + + return; + } + + if ($kb->matches($data, 'delete_word_forward')) { + if ($this->document->deleteWordForward()) { + $this->notifyChange(); + } + + return; + } + + if ($kb->matches($data, 'delete_char_backward')) { + if ($this->document->deleteCharBackward()) { + $this->notifyChange(); + } + + return; + } + + if ($kb->matches($data, 'delete_char_forward')) { + if ($this->document->deleteCharForward()) { + $this->notifyChange(); + } + + return; + } + + // Cancel + if ($kb->matches($data, 'select_cancel')) { + $this->submitted = false; + $this->dispatch(new CancelEvent($this)); + + return; + } + + // Shift+Space - insert regular space + if ($this->getKeybindings()->matches($data, 'insert_space')) { + $this->document->insertText(' '); + $this->notifyChange(); + + return; + } + + // Regular character input + if (!StringUtils::hasControlChars($data)) { + $data = StringUtils::sanitizeUtf8($data); + if ('' === $data) { + return; + } + + $this->document->insertText($data); + $this->notifyChange(); + } + } + + /** + * @return string[] + */ + public function render(RenderContext $context): array + { + $columns = $context->getColumns(); + + // Calculate max visible lines based on context height or terminal + $minVisibleLines = $this->minVisibleLines; + if ($this->verticallyExpanded && $context->getRows() > 0) { + $maxDisplayRows = max(5, $context->getRows() - 2); + } else { + $terminalRows = $this->getContext()?->getTerminalRows() ?? 24; + $maxDisplayRows = max(5, $minVisibleLines, (int) floor($terminalRows * 0.3)); + } + + if (null !== $this->maxVisibleLines) { + $maxDisplayRows = min($maxDisplayRows, $this->maxVisibleLines); + } + + $this->lastMaxVisibleLines = $maxDisplayRows; + + // Compute viewport (adjusts scroll offset, returns visible range) + $viewport = $this->viewport->computeViewport( + $this->document->getLines(), + $this->document->getCursorLine(), + $maxDisplayRows, + $columns, + $this->verticallyExpanded && $context->getRows() > 0, + $minVisibleLines, + ); + + // Render content + $cursorStyle = $this->resolveElement('cursor'); + $result = $this->editorRenderer->render( + $this->document->getLines(), + $viewport, + $this->document->getCursorLine(), + $this->document->getCursorCol(), + $columns, + $maxDisplayRows, + $this->verticallyExpanded && $context->getRows() > 0, + $this->focused, + $cursorStyle->getCursorShape() ?? CursorShape::Block, + $this->resolveElement('frame'), + ); + + return $result; + } + + /** + * @return array + */ + protected static function getDefaultKeybindings(): array + { + return [ + // Cursor movement + 'cursor_up' => [Key::UP], + 'cursor_down' => [Key::DOWN], + 'cursor_left' => [Key::LEFT, 'ctrl+b'], + 'cursor_right' => [Key::RIGHT, 'ctrl+f'], + 'cursor_word_left' => ['alt+left', 'ctrl+left', 'alt+b'], + 'cursor_word_right' => ['alt+right', 'ctrl+right', 'alt+f'], + 'cursor_line_start' => [Key::HOME, 'ctrl+a'], + 'cursor_line_end' => [Key::END, 'ctrl+e'], + 'jump_forward' => ['ctrl+]'], + 'jump_backward' => ['ctrl+alt+]'], + 'page_up' => [Key::PAGE_UP], + 'page_down' => [Key::PAGE_DOWN], + + // Deletion + 'delete_char_backward' => [Key::BACKSPACE, 'shift+backspace'], + 'delete_char_forward' => [Key::DELETE, 'ctrl+d', 'shift+delete'], + 'delete_word_backward' => ['ctrl+w', 'alt+backspace'], + 'delete_word_forward' => ['alt+d', 'alt+delete'], + 'delete_line' => ['ctrl+shift+k'], + 'delete_to_line_start' => ['ctrl+u'], + 'delete_to_line_end' => ['ctrl+k'], + + // Text input + 'insert_space' => ['shift+space'], + 'new_line' => ['shift+enter'], + 'submit' => [Key::ENTER], + 'select_cancel' => [Key::ESCAPE, 'ctrl+c'], + + // Clipboard + 'copy' => ['ctrl+c'], + + // Kill ring + 'yank' => ['ctrl+y'], + 'yank_pop' => ['alt+y'], + + // Undo/Redo + 'undo' => ['ctrl+-'], + 'redo' => ['ctrl+shift+z'], + + // Tool output + 'expand_tools' => ['ctrl+o'], + ]; + } + + private function notifyChange(): void + { + $this->invalidate(); + $this->dispatch(new ChangeEvent($this, $this->getText())); + } + + private function getPageSize(): int + { + if (null !== $this->lastMaxVisibleLines) { + return $this->lastMaxVisibleLines; + } + + $terminalRows = $this->getContext()?->getTerminalRows() ?? 24; + + return max(5, (int) floor($terminalRows * 0.3)); + } +} diff --git a/src/Symfony/Component/Tui/Widget/Figlet/FigletFont.php b/src/Symfony/Component/Tui/Widget/Figlet/FigletFont.php new file mode 100644 index 0000000000000..0aba52ee3bcf1 --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/Figlet/FigletFont.php @@ -0,0 +1,205 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget\Figlet; + +use Symfony\Component\Tui\Exception\InvalidArgumentException; + +/** + * Parses and represents a FIGlet font (.flf file). + * + * The FIGlet font format stores ASCII art representations of characters. + * Each character is defined as a fixed number of lines (the font height). + * Lines are terminated by the font's "end mark" character (@), and the + * last line of each character uses a double end mark (@@). + * + * The "hardblank" character (typically $) is rendered as a visible space + * that prevents smushing; it's replaced with a regular space on output. + * + * @see https://github.com/cmatsuoka/figlet/blob/master/figfont.txt + * + * @experimental + * + * @author Fabien Potencier + */ +final class FigletFont +{ + /** @var array codepoint → array of lines */ + private array $characters = []; + + /** + * Load a font from a .flf file path. + */ + public static function load(string $path): self + { + if (!is_file($path)) { + throw new InvalidArgumentException(\sprintf('FIGlet font file "%s" does not exist.', $path)); + } + + $content = file_get_contents($path); + + if (false === $content) { + throw new InvalidArgumentException(\sprintf('Cannot read FIGlet font file "%s".', $path)); + } + + // Auto-detect ZIP-compressed .flf files (some font sites distribute them this way) + if (str_starts_with($content, "PK\x03\x04")) { + $content = self::extractFromZip($path); + } + + return self::parse($content); + } + + /** + * Parse a FIGlet font from its raw string content. + */ + public static function parse(string $content): self + { + $lines = explode("\n", $content); + + // Parse header: flf2a ... + $header = $lines[0]; + if (!str_starts_with($header, 'flf2')) { + throw new InvalidArgumentException('Invalid FIGlet font: missing flf2 signature.'); + } + + $hardblank = $header[5]; // Character after "flf2a" + $headerParts = preg_split('/\s+/', $header) ?: []; + $height = (int) ($headerParts[1] ?? 0); + $commentLines = (int) ($headerParts[5] ?? 0); + + $font = new self($height, $hardblank); + + // Character data starts after header + comments + $lineIndex = 1 + $commentLines; + + // Phase 1: Parse required ASCII characters (32–126 = 95 characters) + for ($codepoint = 32; $codepoint <= 126; ++$codepoint) { + $charLines = $font->readCharacterLines($lines, $lineIndex); + if (null === $charLines) { + break; + } + $font->characters[$codepoint] = $charLines; + } + + // Phase 2: Parse code-tagged characters (extended) + while ($lineIndex < \count($lines)) { + $tagLine = $lines[$lineIndex] ?? ''; + + // Code-tagged lines start with a number + if (!preg_match('/^(-?\d+)/', $tagLine, $matches)) { + break; + } + + $codepoint = (int) $matches[1]; + ++$lineIndex; + + $charLines = $font->readCharacterLines($lines, $lineIndex); + if (null === $charLines) { + break; + } + $font->characters[$codepoint] = $charLines; + } + + return $font; + } + + /** + * Get the font height in lines. + */ + public function getHeight(): int + { + return $this->height; + } + + /** + * Get the character art lines for a given codepoint. + * + * @return string[] Array of $height lines, or empty strings if character is not defined + */ + public function getCharacter(int $codepoint): array + { + return $this->characters[$codepoint] ?? array_fill(0, $this->height, ''); + } + + /** + * Check if a character is defined in this font. + */ + public function hasCharacter(int $codepoint): bool + { + return isset($this->characters[$codepoint]); + } + + private function __construct( + private int $height, + private string $hardblank, + ) { + } + + /** + * Read one character's worth of lines from the font data. + * + * @param string[] $lines All lines of the font file + * @param int $lineIndex Current read position (modified by reference) + * + * @return string[]|null The character lines, or null if not enough data + */ + private function readCharacterLines(array $lines, int &$lineIndex): ?array + { + $charLines = []; + + for ($row = 0; $row < $this->height; ++$row) { + if ($lineIndex >= \count($lines)) { + return null; + } + + $line = $lines[$lineIndex]; + ++$lineIndex; + + // Strip end marks (@ or @@) from the right + $line = rtrim($line, "\r\n"); + $line = preg_replace('/@{1,2}$/', '', $line); + + // Replace hardblank with space + $charLines[] = str_replace($this->hardblank, ' ', $line); + } + + return $charLines; + } + + /** + * Extract the first .flf file from a ZIP archive. + */ + private static function extractFromZip(string $path): string + { + $zip = new \ZipArchive(); + + if (true !== $zip->open($path)) { + throw new InvalidArgumentException(\sprintf('Cannot open ZIP archive "%s".', $path)); + } + + try { + for ($i = 0; $i < $zip->numFiles; ++$i) { + $name = $zip->getNameIndex($i); + if (false !== $name && str_ends_with($name, '.flf')) { + $content = $zip->getFromIndex($i); + if (false !== $content) { + return $content; + } + } + } + } finally { + $zip->close(); + } + + throw new InvalidArgumentException(\sprintf('No .flf file found inside ZIP archive "%s".', $path)); + } +} diff --git a/src/Symfony/Component/Tui/Widget/Figlet/FigletRenderer.php b/src/Symfony/Component/Tui/Widget/Figlet/FigletRenderer.php new file mode 100644 index 0000000000000..5218adb7e4ce2 --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/Figlet/FigletRenderer.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget\Figlet; + +use Symfony\Component\Tui\Render\Compositor; +use Symfony\Component\Tui\Style\Color; + +/** + * Renders text using a FIGlet font. + * + * Concatenates the ASCII art for each character horizontally, line by line. + * Strips trailing whitespace from each line and removes blank trailing lines. + * + * When a color is provided, each line is wrapped with the foreground ANSI + * escape code. Since trailing whitespace is already stripped, the colored + * output is safe for use with the {@see Compositor} + * in transparent mode: spaces at the end of lines won't carry styling that + * would prevent lower layers from showing through. + * + * @experimental + * + * @author Fabien Potencier + */ +final class FigletRenderer +{ + public function __construct( + private FigletFont $font, + ) { + } + + /** + * Render a string as FIGlet ASCII art. + * + * @param string|int|Color|null $color Optional foreground color applied to each line + * + * @return string[] One entry per output line + */ + public function render(string $text, string|int|Color|null $color = null): array + { + if ('' === $text) { + return []; + } + + $height = $this->font->getHeight(); + $outputLines = array_fill(0, $height, ''); + + // Build output by appending each character's art horizontally + $length = mb_strlen($text); + for ($i = 0; $i < $length; ++$i) { + $char = mb_substr($text, $i, 1); + $codepoint = mb_ord($char); + $charLines = $this->font->getCharacter($codepoint); + + for ($row = 0; $row < $height; ++$row) { + $outputLines[$row] .= $charLines[$row]; + } + } + + // Strip trailing whitespace from each line + $outputLines = array_map('rtrim', $outputLines); + + // Remove blank trailing lines + while ([] !== $outputLines && '' === end($outputLines)) { + array_pop($outputLines); + } + + if (null !== $color) { + $fgCode = Color::from($color)->toForegroundCode(); + $outputLines = array_map( + static fn (string $line) => '' !== $line ? $fgCode.$line."\x1b[0m" : $line, + $outputLines, + ); + } + + return $outputLines; + } +} diff --git a/src/Symfony/Component/Tui/Widget/Figlet/FontRegistry.php b/src/Symfony/Component/Tui/Widget/Figlet/FontRegistry.php new file mode 100644 index 0000000000000..8b9ad205d7737 --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/Figlet/FontRegistry.php @@ -0,0 +1,102 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget\Figlet; + +use Symfony\Component\Tui\Exception\InvalidArgumentException; + +/** + * Registry for FIGlet fonts. + * + * Maps font names to file paths and lazily loads FigletFont instances. + * Bundled fonts (big, small, slant, standard, mini) are registered + * by default. Custom fonts can be registered by name: + * + * $registry = new FontRegistry(); + * $registry->register('custom', '/path/to/custom.flf'); + * + * Fonts are referenced by name throughout the Style system: + * + * $stylesheet->addRule('.title', new Style(font: 'custom')); + * $widget->addStyleClass('font-custom'); + * + * @experimental + * + * @author Fabien Potencier + */ +final class FontRegistry +{ + private const string BUNDLED_FONTS_DIR = __DIR__.'/fonts'; + + private const array BUNDLED_FONTS = ['big', 'small', 'slant', 'standard', 'mini']; + + /** @var array name → file path */ + private array $paths = []; + + /** @var array name → loaded font (cache) */ + private array $fonts = []; + + public function __construct() + { + foreach (self::BUNDLED_FONTS as $name) { + $this->paths[$name] = self::BUNDLED_FONTS_DIR.'/'.$name.'.flf'; + } + } + + /** + * Register a font by name with a path to a .flf file. + * + * @return $this + */ + public function register(string $name, string $path): self + { + $this->paths[$name] = $path; + unset($this->fonts[$name]); // invalidate cache if re-registering + + return $this; + } + + /** + * Load and return a font by name. + * + * @throws InvalidArgumentException if the font name is not registered + */ + public function get(string $name): FigletFont + { + if (isset($this->fonts[$name])) { + return $this->fonts[$name]; + } + + if (!isset($this->paths[$name])) { + throw new InvalidArgumentException(\sprintf('Font "%s" is not registered. Available fonts: %s.', $name, implode(', ', array_keys($this->paths)))); + } + + return $this->fonts[$name] = FigletFont::load($this->paths[$name]); + } + + /** + * Check whether a font name is registered. + */ + public function has(string $name): bool + { + return isset($this->paths[$name]); + } + + /** + * Get all registered font names. + * + * @return string[] + */ + public function getNames(): array + { + return array_keys($this->paths); + } +} diff --git a/src/Symfony/Component/Tui/Widget/Figlet/fonts/big.flf b/src/Symfony/Component/Tui/Widget/Figlet/fonts/big.flf new file mode 100644 index 0000000000000..07c468c9e5906 --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/Figlet/fonts/big.flf @@ -0,0 +1,2204 @@ +flf2a$ 8 6 59 15 10 0 24463 153 +Big by Glenn Chappell 4/93 -- based on Standard +Includes ISO Latin-1 +Greek characters by Bruce Jakeway +figlet release 2.2 -- November 1996 +Permission is hereby given to modify this font, as long as the +modifier's name is placed on a comment line. + +Modified by Paul Burton 12/96 to include new parameter +supported by FIGlet and FIGWin. May also be slightly modified for better use +of new full-width/kern/smush alternatives, but default output is NOT changed. + $@ + $@ + $@ + $@ + $@ + $@ + $@ + $@@ + _ @ + | |@ + | |@ + | |@ + |_|@ + (_)@ + @ + @@ + _ _ @ + ( | )@ + V V @ + $ @ + $ @ + $ @ + @ + @@ + _ _ @ + _| || |_ @ + |_ __ _|@ + _| || |_ @ + |_ __ _|@ + |_||_| @ + @ + @@ + _ @ + | | @ + / __)@ + \__ \@ + ( /@ + |_| @ + @ + @@ + _ __@ + (_) / /@ + / / @ + / / @ + / / _ @ + /_/ (_)@ + @ + @@ + @ + ___ @ + ( _ ) @ + / _ \/\@ + | (_> <@ + \___/\/@ + @ + @@ + _ @ + ( )@ + |/ @ + $ @ + $ @ + $ @ + @ + @@ + __@ + / /@ + | | @ + | | @ + | | @ + | | @ + \_\@ + @@ + __ @ + \ \ @ + | |@ + | |@ + | |@ + | |@ + /_/ @ + @@ + _ @ + /\| |/\ @ + \ ` ' / @ + |_ _|@ + / , . \ @ + \/|_|\/ @ + @ + @@ + @ + _ @ + _| |_ @ + |_ _|@ + |_| @ + $ @ + @ + @@ + @ + @ + @ + @ + _ @ + ( )@ + |/ @ + @@ + @ + @ + ______ @ + |______|@ + $ @ + $ @ + @ + @@ + @ + @ + @ + @ + _ @ + (_)@ + @ + @@ + __@ + / /@ + / / @ + / / @ + / / @ + /_/ @ + @ + @@ + ___ @ + / _ \ @ + | | | |@ + | | | |@ + | |_| |@ + \___/ @ + @ + @@ + __ @ + /_ |@ + | |@ + | |@ + | |@ + |_|@ + @ + @@ + ___ @ + |__ \ @ + $) |@ + / / @ + / /_ @ + |____|@ + @ + @@ + ____ @ + |___ \ @ + __) |@ + |__ < @ + ___) |@ + |____/ @ + @ + @@ + _ _ @ + | || | @ + | || |_ @ + |__ _|@ + | | @ + |_| @ + @ + @@ + _____ @ + | ____|@ + | |__ @ + |___ \ @ + ___) |@ + |____/ @ + @ + @@ + __ @ + / / @ + / /_ @ + | '_ \ @ + | (_) |@ + \___/ @ + @ + @@ + ______ @ + |____ |@ + $/ / @ + / / @ + / / @ + /_/ @ + @ + @@ + ___ @ + / _ \ @ + | (_) |@ + > _ < @ + | (_) |@ + \___/ @ + @ + @@ + ___ @ + / _ \ @ + | (_) |@ + \__, |@ + / / @ + /_/ @ + @ + @@ + @ + _ @ + (_)@ + $ @ + _ @ + (_)@ + @ + @@ + @ + _ @ + (_)@ + $ @ + _ @ + ( )@ + |/ @ + @@ + __@ + / /@ + / / @ + < < @ + \ \ @ + \_\@ + @ + @@ + @ + ______ @ + |______|@ + ______ @ + |______|@ + @ + @ + @@ + __ @ + \ \ @ + \ \ @ + > >@ + / / @ + /_/ @ + @ + @@ + ___ @ + |__ \ @ + ) |@ + / / @ + |_| @ + (_) @ + @ + @@ + @ + ____ @ + / __ \ @ + / / _` |@ + | | (_| |@ + \ \__,_|@ + \____/ @ + @@ + @ + /\ @ + / \ @ + / /\ \ @ + / ____ \ @ + /_/ \_\@ + @ + @@ + ____ @ + | _ \ @ + | |_) |@ + | _ < @ + | |_) |@ + |____/ @ + @ + @@ + _____ @ + / ____|@ + | | $ @ + | | $ @ + | |____ @ + \_____|@ + @ + @@ + _____ @ + | __ \ @ + | | | |@ + | | | |@ + | |__| |@ + |_____/ @ + @ + @@ + ______ @ + | ____|@ + | |__ @ + | __| @ + | |____ @ + |______|@ + @ + @@ + ______ @ + | ____|@ + | |__ @ + | __| @ + | | @ + |_| @ + @ + @@ + _____ @ + / ____|@ + | | __ @ + | | |_ |@ + | |__| |@ + \_____|@ + @ + @@ + _ _ @ + | | | |@ + | |__| |@ + | __ |@ + | | | |@ + |_| |_|@ + @ + @@ + _____ @ + |_ _|@ + | | @ + | | @ + _| |_ @ + |_____|@ + @ + @@ + _ @ + | |@ + | |@ + _ | |@ + | |__| |@ + \____/ @ + @ + @@ + _ __@ + | |/ /@ + | ' / @ + | < @ + | . \ @ + |_|\_\@ + @ + @@ + _ @ + | | @ + | | @ + | | @ + | |____ @ + |______|@ + @ + @@ + __ __ @ + | \/ |@ + | \ / |@ + | |\/| |@ + | | | |@ + |_| |_|@ + @ + @@ + _ _ @ + | \ | |@ + | \| |@ + | . ` |@ + | |\ |@ + |_| \_|@ + @ + @@ + ____ @ + / __ \ @ + | | | |@ + | | | |@ + | |__| |@ + \____/ @ + @ + @@ + _____ @ + | __ \ @ + | |__) |@ + | ___/ @ + | | @ + |_| @ + @ + @@ + ____ @ + / __ \ @ + | | | |@ + | | | |@ + | |__| |@ + \___\_\@ + @ + @@ + _____ @ + | __ \ @ + | |__) |@ + | _ / @ + | | \ \ @ + |_| \_\@ + @ + @@ + _____ @ + / ____|@ + | (___ @ + \___ \ @ + ____) |@ + |_____/ @ + @ + @@ + _______ @ + |__ __|@ + | | @ + | | @ + | | @ + |_| @ + @ + @@ + _ _ @ + | | | |@ + | | | |@ + | | | |@ + | |__| |@ + \____/ @ + @ + @@ + __ __@ + \ \ / /@ + \ \ / / @ + \ \/ / @ + \ / @ + \/ @ + @ + @@ + __ __@ + \ \ / /@ + \ \ /\ / / @ + \ \/ \/ / @ + \ /\ / @ + \/ \/ @ + @ + @@ + __ __@ + \ \ / /@ + \ V / @ + > < @ + / . \ @ + /_/ \_\@ + @ + @@ + __ __@ + \ \ / /@ + \ \_/ / @ + \ / @ + | | @ + |_| @ + @ + @@ + ______@ + |___ /@ + $/ / @ + / / @ + / /__ @ + /_____|@ + @ + @@ + ___ @ + | _|@ + | | @ + | | @ + | | @ + | |_ @ + |___|@ + @@ + __ @ + \ \ @ + \ \ @ + \ \ @ + \ \ @ + \_\@ + @ + @@ + ___ @ + |_ |@ + | |@ + | |@ + | |@ + _| |@ + |___|@ + @@ + /\ @ + |/\|@ + $ @ + $ @ + $ @ + $ @ + @ + @@ + @ + @ + @ + @ + @ + $ @ + ______ @ + |______|@@ + _ @ + ( )@ + \|@ + $ @ + $ @ + $ @ + @ + @@ + @ + @ + __ _ @ + / _` |@ + | (_| |@ + \__,_|@ + @ + @@ + _ @ + | | @ + | |__ @ + | '_ \ @ + | |_) |@ + |_.__/ @ + @ + @@ + @ + @ + ___ @ + / __|@ + | (__ @ + \___|@ + @ + @@ + _ @ + | |@ + __| |@ + / _` |@ + | (_| |@ + \__,_|@ + @ + @@ + @ + @ + ___ @ + / _ \@ + | __/@ + \___|@ + @ + @@ + __ @ + / _|@ + | |_ @ + | _|@ + | | @ + |_| @ + @ + @@ + @ + @ + __ _ @ + / _` |@ + | (_| |@ + \__, |@ + __/ |@ + |___/ @@ + _ @ + | | @ + | |__ @ + | '_ \ @ + | | | |@ + |_| |_|@ + @ + @@ + _ @ + (_)@ + _ @ + | |@ + | |@ + |_|@ + @ + @@ + _ @ + (_)@ + _ @ + | |@ + | |@ + | |@ + _/ |@ + |__/ @@ + _ @ + | | @ + | | __@ + | |/ /@ + | < @ + |_|\_\@ + @ + @@ + _ @ + | |@ + | |@ + | |@ + | |@ + |_|@ + @ + @@ + @ + @ + _ __ ___ @ + | '_ ` _ \ @ + | | | | | |@ + |_| |_| |_|@ + @ + @@ + @ + @ + _ __ @ + | '_ \ @ + | | | |@ + |_| |_|@ + @ + @@ + @ + @ + ___ @ + / _ \ @ + | (_) |@ + \___/ @ + @ + @@ + @ + @ + _ __ @ + | '_ \ @ + | |_) |@ + | .__/ @ + | | @ + |_| @@ + @ + @ + __ _ @ + / _` |@ + | (_| |@ + \__, |@ + | |@ + |_|@@ + @ + @ + _ __ @ + | '__|@ + | | @ + |_| @ + @ + @@ + @ + @ + ___ @ + / __|@ + \__ \@ + |___/@ + @ + @@ + _ @ + | | @ + | |_ @ + | __|@ + | |_ @ + \__|@ + @ + @@ + @ + @ + _ _ @ + | | | |@ + | |_| |@ + \__,_|@ + @ + @@ + @ + @ + __ __@ + \ \ / /@ + \ V / @ + \_/ @ + @ + @@ + @ + @ + __ __@ + \ \ /\ / /@ + \ V V / @ + \_/\_/ @ + @ + @@ + @ + @ + __ __@ + \ \/ /@ + > < @ + /_/\_\@ + @ + @@ + @ + @ + _ _ @ + | | | |@ + | |_| |@ + \__, |@ + __/ |@ + |___/ @@ + @ + @ + ____@ + |_ /@ + / / @ + /___|@ + @ + @@ + __@ + / /@ + | | @ + / / @ + \ \ @ + | | @ + \_\@ + @@ + _ @ + | |@ + | |@ + | |@ + | |@ + | |@ + | |@ + |_|@@ + __ @ + \ \ @ + | | @ + \ \@ + / /@ + | | @ + /_/ @ + @@ + /\/|@ + |/\/ @ + $ @ + $ @ + $ @ + $ @ + @ + @@ + _ _ @ + (_)_(_) @ + / \ @ + / _ \ @ + / ___ \ @ + /_/ \_\@ + @ + @@ + _ _ @ + (_)_(_)@ + / _ \ @ + | | | |@ + | |_| |@ + \___/ @ + @ + @@ + _ _ @ + (_) (_)@ + | | | |@ + | | | |@ + | |_| |@ + \___/ @ + @ + @@ + _ _ @ + (_) (_)@ + __ _ @ + / _` |@ + | (_| |@ + \__,_|@ + @ + @@ + _ _ @ + (_) (_)@ + ___ @ + / _ \ @ + | (_) |@ + \___/ @ + @ + @@ + _ _ @ + (_) (_)@ + _ _ @ + | | | |@ + | |_| |@ + \__,_|@ + @ + @@ + ___ @ + / _ \ @ + | | ) |@ + | |< < @ + | | ) |@ + | ||_/ @ + |_| @ + @@ +160 NO-BREAK SPACE + $@ + $@ + $@ + $@ + $@ + $@ + $@ + $@@ +161 INVERTED EXCLAMATION MARK + _ @ + (_)@ + | |@ + | |@ + | |@ + |_|@ + @ + @@ +162 CENT SIGN + @ + _ @ + | | @ + / __)@ + | (__ @ + \ )@ + |_| @ + @@ +163 POUND SIGN + ___ @ + / ,_\ @ + _| |_ @ + |__ __| @ + | |____ @ + (_,_____|@ + @ + @@ +164 CURRENCY SIGN + @ + /\___/\@ + \ _ /@ + | (_) |@ + / ___ \@ + \/ \/@ + @ + @@ +165 YEN SIGN + __ __ @ + \ \ / / @ + _\ V /_ @ + |___ ___|@ + |___ ___|@ + |_| @ + @ + @@ +166 BROKEN BAR + _ @ + | |@ + | |@ + |_|@ + _ @ + | |@ + | |@ + |_|@@ +167 SECTION SIGN + __ @ + _/ _)@ + / \ \ @ + \ \\ \@ + \ \_/@ + (__/ @ + @ + @@ +168 DIAERESIS + _ _ @ + (_) (_)@ + $ $ @ + $ $ @ + $ $ @ + $ $ @ + @ + @@ +169 COPYRIGHT SIGN + ________ @ + / ____ \ @ + / / ___| \ @ + | | | |@ + | | |___ |@ + \ \____| / @ + \________/ @ + @@ +170 FEMININE ORDINAL INDICATOR + __ _ @ + / _` |@ + | (_| |@ + \__,_|@ + |_____|@ + $ @ + @ + @@ +171 LEFT-POINTING DOUBLE ANGLE QUOTATION MARK + ____@ + / / /@ + / / / @ + < < < @ + \ \ \ @ + \_\_\@ + @ + @@ +172 NOT SIGN + @ + @ + ______ @ + |____ |@ + |_|@ + $ @ + @ + @@ +173 SOFT HYPHEN + @ + @ + _____ @ + |_____|@ + $ @ + $ @ + @ + @@ +174 REGISTERED SIGN + ________ @ + / ____ \ @ + / | _ \ \ @ + | | |_) | |@ + | | _ < |@ + \ |_| \_\ / @ + \________/ @ + @@ +175 MACRON + ______ @ + |______|@ + $ @ + $ @ + $ @ + $ @ + @ + @@ +176 DEGREE SIGN + __ @ + / \ @ + | () |@ + \__/ @ + $ @ + $ @ + @ + @@ +177 PLUS-MINUS SIGN + _ @ + _| |_ @ + |_ _|@ + |_| @ + _____ @ + |_____|@ + @ + @@ +178 SUPERSCRIPT TWO + ___ @ + |_ )@ + / / @ + /___|@ + $ @ + $ @ + @ + @@ +179 SUPERSCRIPT THREE + ____@ + |__ /@ + |_ \@ + |___/@ + $ @ + $ @ + @ + @@ +180 ACUTE ACCENT + __@ + /_/@ + $ @ + $ @ + $ @ + $ @ + @ + @@ +181 MICRO SIGN + @ + @ + _ _ @ + | | | |@ + | |_| |@ + | ._,_|@ + | | @ + |_| @@ +182 PILCROW SIGN + ______ @ + / |@ + | (| || |@ + \__ || |@ + | || |@ + |_||_|@ + @ + @@ +183 MIDDLE DOT + @ + @ + _ @ + (_)@ + $ @ + $ @ + @ + @@ +184 CEDILLA + @ + @ + @ + @ + @ + _ @ + )_)@ + @@ +185 SUPERSCRIPT ONE + _ @ + / |@ + | |@ + |_|@ + $ @ + $ @ + @ + @@ +186 MASCULINE ORDINAL INDICATOR + ___ @ + / _ \ @ + | (_) |@ + \___/ @ + |_____|@ + $ @ + @ + @@ +187 RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK + ____ @ + \ \ \ @ + \ \ \ @ + > > >@ + / / / @ + /_/_/ @ + @ + @@ +188 VULGAR FRACTION ONE QUARTER + _ __ @ + / | / / @ + | |/ / _ @ + |_/ / | | @ + / /|_ _|@ + /_/ |_| @ + @ + @@ +189 VULGAR FRACTION ONE HALF + _ __ @ + / | / / @ + | |/ /__ @ + |_/ /_ )@ + / / / / @ + /_/ /___|@ + @ + @@ +190 VULGAR FRACTION THREE QUARTERS + ____ __ @ + |__ / / / @ + |_ \/ / _ @ + |___/ / | | @ + / /|_ _|@ + /_/ |_| @ + @ + @@ +191 INVERTED QUESTION MARK + _ @ + (_) @ + | | @ + / / @ + | (__ @ + \___|@ + @ + @@ +192 LATIN CAPITAL LETTER A WITH GRAVE + __ @ + \_\ @ + / \ @ + / _ \ @ + / ___ \ @ + /_/ \_\@ + @ + @@ +193 LATIN CAPITAL LETTER A WITH ACUTE + __ @ + /_/ @ + / \ @ + / _ \ @ + / ___ \ @ + /_/ \_\@ + @ + @@ +194 LATIN CAPITAL LETTER A WITH CIRCUMFLEX + //\ @ + |/_\| @ + / \ @ + / _ \ @ + / ___ \ @ + /_/ \_\@ + @ + @@ +195 LATIN CAPITAL LETTER A WITH TILDE + /\/| @ + |/\/ @ + / \ @ + / _ \ @ + / ___ \ @ + /_/ \_\@ + @ + @@ +196 LATIN CAPITAL LETTER A WITH DIAERESIS + _ _ @ + (_)_(_) @ + / \ @ + / _ \ @ + / ___ \ @ + /_/ \_\@ + @ + @@ +197 LATIN CAPITAL LETTER A WITH RING ABOVE + _ @ + (o) @ + / \ @ + / _ \ @ + / ___ \ @ + /_/ \_\@ + @ + @@ +198 LATIN CAPITAL LETTER AE + _______ @ + / ____|@ + / |__ @ + / /| __| @ + / ___ |____ @ + /_/ |______|@ + @ + @@ +199 LATIN CAPITAL LETTER C WITH CEDILLA + _____ @ + / ____|@ + | | $ @ + | | $ @ + | |____ @ + \_____|@ + )_) @ + @@ +200 LATIN CAPITAL LETTER E WITH GRAVE + __ @ + _\_\_ @ + | ____|@ + | _| @ + | |___ @ + |_____|@ + @ + @@ +201 LATIN CAPITAL LETTER E WITH ACUTE + __ @ + _/_/_ @ + | ____|@ + | _| @ + | |___ @ + |_____|@ + @ + @@ +202 LATIN CAPITAL LETTER E WITH CIRCUMFLEX + //\ @ + |/ \| @ + | ____|@ + | _| @ + | |___ @ + |_____|@ + @ + @@ +203 LATIN CAPITAL LETTER E WITH DIAERESIS + _ _ @ + (_) (_)@ + | ____|@ + | _| @ + | |___ @ + |_____|@ + @ + @@ +204 LATIN CAPITAL LETTER I WITH GRAVE + __ @ + \_\ @ + |_ _|@ + | | @ + | | @ + |___|@ + @ + @@ +205 LATIN CAPITAL LETTER I WITH ACUTE + __ @ + /_/ @ + |_ _|@ + | | @ + | | @ + |___|@ + @ + @@ +206 LATIN CAPITAL LETTER I WITH CIRCUMFLEX + //\ @ + |/_\|@ + |_ _|@ + | | @ + | | @ + |___|@ + @ + @@ +207 LATIN CAPITAL LETTER I WITH DIAERESIS + _ _ @ + (_)_(_)@ + |_ _| @ + | | @ + | | @ + |___| @ + @ + @@ +208 LATIN CAPITAL LETTER ETH + _____ @ + | __ \ @ + _| |_ | |@ + |__ __|| |@ + | |__| |@ + |_____/ @ + @ + @@ +209 LATIN CAPITAL LETTER N WITH TILDE + /\/| @ + |/\/_ @ + | \ | |@ + | \| |@ + | |\ |@ + |_| \_|@ + @ + @@ +210 LATIN CAPITAL LETTER O WITH GRAVE + __ @ + \_\ @ + / _ \ @ + | | | |@ + | |_| |@ + \___/ @ + @ + @@ +211 LATIN CAPITAL LETTER O WITH ACUTE + __ @ + /_/ @ + / _ \ @ + | | | |@ + | |_| |@ + \___/ @ + @ + @@ +212 LATIN CAPITAL LETTER O WITH CIRCUMFLEX + //\ @ + |/_\| @ + / _ \ @ + | | | |@ + | |_| |@ + \___/ @ + @ + @@ +213 LATIN CAPITAL LETTER O WITH TILDE + /\/| @ + |/\/ @ + / _ \ @ + | | | |@ + | |_| |@ + \___/ @ + @ + @@ +214 LATIN CAPITAL LETTER O WITH DIAERESIS + _ _ @ + (_)_(_)@ + / _ \ @ + | | | |@ + | |_| |@ + \___/ @ + @ + @@ +215 MULTIPLICATION SIGN + @ + @ + /\/\@ + > <@ + \/\/@ + $ @ + @ + @@ +216 LATIN CAPITAL LETTER O WITH STROKE + _____ @ + / __// @ + | | // |@ + | |//| |@ + | //_| |@ + //___/ @ + @ + @@ +217 LATIN CAPITAL LETTER U WITH GRAVE + __ @ + _\_\_ @ + | | | |@ + | | | |@ + | |_| |@ + \___/ @ + @ + @@ +218 LATIN CAPITAL LETTER U WITH ACUTE + __ @ + _/_/_ @ + | | | |@ + | | | |@ + | |_| |@ + \___/ @ + @ + @@ +219 LATIN CAPITAL LETTER U WITH CIRCUMFLEX + //\ @ + |/ \| @ + | | | |@ + | | | |@ + | |_| |@ + \___/ @ + @ + @@ +220 LATIN CAPITAL LETTER U WITH DIAERESIS + _ _ @ + (_) (_)@ + | | | |@ + | | | |@ + | |_| |@ + \___/ @ + @ + @@ +221 LATIN CAPITAL LETTER Y WITH ACUTE + __ @ + __/_/__@ + \ \ / /@ + \ V / @ + | | @ + |_| @ + @ + @@ +222 LATIN CAPITAL LETTER THORN + _ @ + | |___ @ + | __ \ @ + | |__) |@ + | ___/ @ + |_| @ + @ + @@ +223 LATIN SMALL LETTER SHARP S + ___ @ + / _ \ @ + | | ) |@ + | |< < @ + | | ) |@ + | ||_/ @ + |_| @ + @@ +224 LATIN SMALL LETTER A WITH GRAVE + __ @ + \_\ @ + __ _ @ + / _` |@ + | (_| |@ + \__,_|@ + @ + @@ +225 LATIN SMALL LETTER A WITH ACUTE + __ @ + /_/ @ + __ _ @ + / _` |@ + | (_| |@ + \__,_|@ + @ + @@ +226 LATIN SMALL LETTER A WITH CIRCUMFLEX + //\ @ + |/ \| @ + __ _ @ + / _` |@ + | (_| |@ + \__,_|@ + @ + @@ +227 LATIN SMALL LETTER A WITH TILDE + /\/| @ + |/\/ @ + __ _ @ + / _` |@ + | (_| |@ + \__,_|@ + @ + @@ +228 LATIN SMALL LETTER A WITH DIAERESIS + _ _ @ + (_) (_)@ + __ _ @ + / _` |@ + | (_| |@ + \__,_|@ + @ + @@ +229 LATIN SMALL LETTER A WITH RING ABOVE + __ @ + (()) @ + __ _ @ + / _` |@ + | (_| |@ + \__,_|@ + @ + @@ +230 LATIN SMALL LETTER AE + @ + @ + __ ____ @ + / _` _ \@ + | (_| __/@ + \__,____|@ + @ + @@ +231 LATIN SMALL LETTER C WITH CEDILLA + @ + @ + ___ @ + / __|@ + | (__ @ + \___|@ + )_) @ + @@ +232 LATIN SMALL LETTER E WITH GRAVE + __ @ + \_\ @ + ___ @ + / _ \@ + | __/@ + \___|@ + @ + @@ +233 LATIN SMALL LETTER E WITH ACUTE + __ @ + /_/ @ + ___ @ + / _ \@ + | __/@ + \___|@ + @ + @@ +234 LATIN SMALL LETTER E WITH CIRCUMFLEX + //\ @ + |/ \|@ + ___ @ + / _ \@ + | __/@ + \___|@ + @ + @@ +235 LATIN SMALL LETTER E WITH DIAERESIS + _ _ @ + (_) (_)@ + ___ @ + / _ \ @ + | __/ @ + \___| @ + @ + @@ +236 LATIN SMALL LETTER I WITH GRAVE + __ @ + \_\@ + _ @ + | |@ + | |@ + |_|@ + @ + @@ +237 LATIN SMALL LETTER I WITH ACUTE + __@ + /_/@ + _ @ + | |@ + | |@ + |_|@ + @ + @@ +238 LATIN SMALL LETTER I WITH CIRCUMFLEX + //\ @ + |/ \|@ + _ @ + | | @ + | | @ + |_| @ + @ + @@ +239 LATIN SMALL LETTER I WITH DIAERESIS + _ _ @ + (_) (_)@ + _ @ + | | @ + | | @ + |_| @ + @ + @@ +240 LATIN SMALL LETTER ETH + /\/\ @ + > < @ + \/\ \ @ + / _` |@ + | (_) |@ + \___/ @ + @ + @@ +241 LATIN SMALL LETTER N WITH TILDE + /\/| @ + |/\/ @ + _ __ @ + | '_ \ @ + | | | |@ + |_| |_|@ + @ + @@ +242 LATIN SMALL LETTER O WITH GRAVE + __ @ + \_\ @ + ___ @ + / _ \ @ + | (_) |@ + \___/ @ + @ + @@ +243 LATIN SMALL LETTER O WITH ACUTE + __ @ + /_/ @ + ___ @ + / _ \ @ + | (_) |@ + \___/ @ + @ + @@ +244 LATIN SMALL LETTER O WITH CIRCUMFLEX + //\ @ + |/ \| @ + ___ @ + / _ \ @ + | (_) |@ + \___/ @ + @ + @@ +245 LATIN SMALL LETTER O WITH TILDE + /\/| @ + |/\/ @ + ___ @ + / _ \ @ + | (_) |@ + \___/ @ + @ + @@ +246 LATIN SMALL LETTER O WITH DIAERESIS + _ _ @ + (_) (_)@ + ___ @ + / _ \ @ + | (_) |@ + \___/ @ + @ + @@ +247 DIVISION SIGN + _ @ + (_) @ + _______ @ + |_______|@ + _ @ + (_) @ + @ + @@ +248 LATIN SMALL LETTER O WITH STROKE + @ + @ + ____ @ + / _//\ @ + | (//) |@ + \//__/ @ + @ + @@ +249 LATIN SMALL LETTER U WITH GRAVE + __ @ + \_\ @ + _ _ @ + | | | |@ + | |_| |@ + \__,_|@ + @ + @@ +250 LATIN SMALL LETTER U WITH ACUTE + __ @ + /_/ @ + _ _ @ + | | | |@ + | |_| |@ + \__,_|@ + @ + @@ +251 LATIN SMALL LETTER U WITH CIRCUMFLEX + //\ @ + |/ \| @ + _ _ @ + | | | |@ + | |_| |@ + \__,_|@ + @ + @@ +252 LATIN SMALL LETTER U WITH DIAERESIS + _ _ @ + (_) (_)@ + _ _ @ + | | | |@ + | |_| |@ + \__,_|@ + @ + @@ +253 LATIN SMALL LETTER Y WITH ACUTE + __ @ + /_/ @ + _ _ @ + | | | |@ + | |_| |@ + \__, |@ + __/ |@ + |___/ @@ +254 LATIN SMALL LETTER THORN + _ @ + | | @ + | |__ @ + | '_ \ @ + | |_) |@ + | .__/ @ + | | @ + |_| @@ +255 LATIN SMALL LETTER Y WITH DIAERESIS + _ _ @ + (_) (_)@ + _ _ @ + | | | |@ + | |_| |@ + \__, |@ + __/ |@ + |___/ @@ +0x02BC MODIFIER LETTER APOSTROPHE + @ + @ + ))@ + @ + @ + @ + @ + @@ +0x02BD MODIFIER LETTER REVERSED COMMA + @ + @ + ((@ + @ + @ + @ + @ + @@ +0x037A GREEK YPOGEGRAMMENI + @ + @ + @ + @ + @ + @ + @ + ||@@ +0x0387 GREEK ANO TELEIA + @ + $ @ + _ @ + (_)@ + @ + $ @ + @ + @@ +0x0391 GREEK CAPITAL LETTER ALPHA + ___ @ + / _ \ @ + | |_| |@ + | _ |@ + | | | |@ + |_| |_|@ + @ + @@ +0x0392 GREEK CAPITAL LETTER BETA + ____ @ + | _ \ @ + | |_) )@ + | _ ( @ + | |_) )@ + |____/ @ + @ + @@ +0x0393 GREEK CAPITAL LETTER GAMMA + _____ @ + | ___)@ + | |$ @ + | |$ @ + | | @ + |_| @ + @ + @@ +0x0394 GREEK CAPITAL LETTER DELTA + @ + /\ @ + / \ @ + / /\ \ @ + / /__\ \ @ + /________\@ + @ + @@ +0x0395 GREEK CAPITAL LETTER EPSILON + _____ @ + | ___)@ + | |_ @ + | _) @ + | |___ @ + |_____)@ + @ + @@ +0x0396 GREEK CAPITAL LETTER ZETA + ______@ + (___ /@ + / / @ + / / @ + / /__ @ + /_____)@ + @ + @@ +0x0397 GREEK CAPITAL LETTER ETA + _ _ @ + | | | |@ + | |_| |@ + | _ |@ + | | | |@ + |_| |_|@ + @ + @@ +0x0398 GREEK CAPITAL LETTER THETA + ____ @ + / __ \ @ + | |__| |@ + | __ |@ + | |__| |@ + \____/ @ + @ + @@ +0x0399 GREEK CAPITAL LETTER IOTA + ___ @ + ( )@ + | | @ + | | @ + | | @ + (___)@ + @ + @@ +0x039A GREEK CAPITAL LETTER KAPPA + _ __@ + | | / /@ + | |/ / @ + | < @ + | |\ \ @ + |_| \_\@ + @ + @@ +0x039B GREEK CAPITAL LETTER LAMDA + @ + /\ @ + / \ @ + / /\ \ @ + / / \ \ @ + /_/ \_\@ + @ + @@ +0x039C GREEK CAPITAL LETTER MU + __ __ @ + | \ / |@ + | v |@ + | |\_/| |@ + | | | |@ + |_| |_|@ + @ + @@ +0x039D GREEK CAPITAL LETTER NU + _ _ @ + | \ | |@ + | \| |@ + | |@ + | |\ |@ + |_| \_|@ + @ + @@ +0x039E GREEK CAPITAL LETTER XI + _____ @ + (_____)@ + ___ @ + (___) @ + _____ @ + (_____)@ + @ + @@ +0x039F GREEK CAPITAL LETTER OMICRON + ___ @ + / _ \ @ + | | | |@ + | | | |@ + | |_| |@ + \___/ @ + @ + @@ +0x03A0 GREEK CAPITAL LETTER PI + _______ @ + ( _ )@ + | | | | @ + | | | | @ + | | | | @ + |_| |_| @ + @ + @@ +0x03A1 GREEK CAPITAL LETTER RHO + ____ @ + | _ \ @ + | |_) )@ + | __/ @ + | | @ + |_| @ + @ + @@ +0x03A3 GREEK CAPITAL LETTER SIGMA + ______ @ + \ ___)@ + \ \ @ + > > @ + / /__ @ + /_____)@ + @ + @@ +0x03A4 GREEK CAPITAL LETTER TAU + _____ @ + (_ _)@ + | | @ + | | @ + | | @ + |_| @ + @ + @@ +0x03A5 GREEK CAPITAL LETTER UPSILON + __ __ @ + (_ \ / _)@ + \ v / @ + | | @ + | | @ + |_| @ + @ + @@ +0x03A6 GREEK CAPITAL LETTER PHI + _ @ + _| |_ @ + / \ @ + ( (| |) )@ + \_ _/ @ + |_| @ + @ + @@ +0x03A7 GREEK CAPITAL LETTER CHI + __ __@ + \ \ / /@ + \ v / @ + > < @ + / ^ \ @ + /_/ \_\@ + @ + @@ +0x03A8 GREEK CAPITAL LETTER PSI + _ _ _ @ + | || || |@ + | \| |/ |@ + \_ _/ @ + | | @ + |_| @ + @ + @@ +0x03A9 GREEK CAPITAL LETTER OMEGA + ____ @ + / __ \ @ + | | | | @ + | | | | @ + _\ \/ /_ @ + (___||___)@ + @ + @@ +0x03B1 GREEK SMALL LETTER ALPHA + @ + @ + __ __@ + / \/ /@ + ( () < @ + \__/\_\@ + @ + @@ +0x03B2 GREEK SMALL LETTER BETA + ___ @ + / _ \ @ + | |_) )@ + | _ < @ + | |_) )@ + | __/ @ + | | @ + |_| @@ +0x03B3 GREEK SMALL LETTER GAMMA + @ + @ + _ _ @ + ( \ / )@ + \ v / @ + | | @ + | | @ + |_| @@ +0x03B4 GREEK SMALL LETTER DELTA + __ @ + / _) @ + \ \ @ + / _ \ @ + ( (_) )@ + \___/ @ + @ + @@ +0x03B5 GREEK SMALL LETTER EPSILON + @ + @ + ___ @ + / __)@ + > _) @ + \___)@ + @ + @@ +0x03B6 GREEK SMALL LETTER ZETA + _____ @ + \__ ) @ + / / @ + / / @ + | |__ @ + \__ \ @ + ) )@ + (_/ @@ +0x03B7 GREEK SMALL LETTER ETA + @ + @ + _ __ @ + | '_ \ @ + | | | |@ + |_| | |@ + | |@ + |_|@@ +0x03B8 GREEK SMALL LETTER THETA + ___ @ + / _ \ @ + | |_| |@ + | _ |@ + | |_| |@ + \___/ @ + @ + @@ +0x03B9 GREEK SMALL LETTER IOTA + @ + @ + _ @ + | | @ + | | @ + \_)@ + @ + @@ +0x03BA GREEK SMALL LETTER KAPPA + @ + @ + _ __@ + | |/ /@ + | < @ + |_|\_\@ + @ + @@ +0x03BB GREEK SMALL LETTER LAMDA + __ @ + \ \ @ + \ \ @ + > \ @ + / ^ \ @ + /_/ \_\@ + @ + @@ +0x03BC GREEK SMALL LETTER MU + @ + @ + _ _ @ + | | | |@ + | |_| |@ + | ._,_|@ + | | @ + |_| @@ +0x03BD GREEK SMALL LETTER NU + @ + @ + _ __@ + | |/ /@ + | / / @ + |__/ @ + @ + @@ +0x03BE GREEK SMALL LETTER XI + \=\__ @ + > __) @ + ( (_ @ + > _) @ + ( (__ @ + \__ \ @ + ) )@ + (_/ @@ +0x03BF GREEK SMALL LETTER OMICRON + @ + @ + ___ @ + / _ \ @ + ( (_) )@ + \___/ @ + @ + @@ +0x03C0 GREEK SMALL LETTER PI + @ + @ + ______ @ + ( __ )@ + | || | @ + |_||_| @ + @ + @@ +0x03C1 GREEK SMALL LETTER RHO + @ + @ + ___ @ + / _ \ @ + | |_) )@ + | __/ @ + | | @ + |_| @@ +0x03C2 GREEK SMALL LETTER FINAL SIGMA + @ + @ + ____ @ + / ___)@ + ( (__ @ + \__ \ @ + _) )@ + (__/ @@ +0x03C3 GREEK SMALL LETTER SIGMA + @ + @ + ____ @ + / ._)@ + ( () ) @ + \__/ @ + @ + @@ +0x03C4 GREEK SMALL LETTER TAU + @ + @ + ___ @ + ( )@ + | | @ + \_)@ + @ + @@ +0x03C5 GREEK SMALL LETTER UPSILON + @ + @ + _ _ @ + | | | |@ + | |_| |@ + \___/ @ + @ + @@ +0x03C6 GREEK SMALL LETTER PHI + _ @ + | | @ + _| |_ @ + / \ @ + ( (| |) )@ + \_ _/ @ + | | @ + |_| @@ +0x03C7 GREEK SMALL LETTER CHI + @ + @ + __ __@ + \ \ / /@ + \ v / @ + > < @ + / ^ \ @ + /_/ \_\@@ +0x03C8 GREEK SMALL LETTER PSI + @ + @ + _ _ _ @ + | || || |@ + | \| |/ |@ + \_ _/ @ + | | @ + |_| @@ +0x03C9 GREEK SMALL LETTER OMEGA + @ + @ + __ __ @ + / / _ \ \ @ + | |_/ \_| |@ + \___^___/ @ + @ + @@ +0x03D1 GREEK THETA SYMBOL + ___ @ + / _ \ @ + ( (_| |_ @ + _ \ _ _)@ + | |___| | @ + \_____/ @ + @ + @@ +0x03D5 GREEK PHI SYMBOL + @ + @ + _ __ @ + | | / \ @ + | || || )@ + \_ _/ @ + | | @ + |_| @@ +0x03D6 GREEK PI SYMBOL + @ + @ + _________ @ + ( _____ )@ + | |_/ \_| |@ + \___^___/ @ + @ + @@ +-0x0005 +alpha = a, beta = b, gamma = g, delta = d, epsilon = e @ +zeta = z, eta = h, theta = q, iota = i, lamda = l, mu = m@ +nu = n, xi = x, omicron = o, pi = p, rho = r, sigma = s @ +phi = f, chi = c, psi = y, omega = w, final sigma = V @ + pi symbol = v, theta symbol = J, phi symbol = j @ + middle dot = :, ypogegrammeni = _ @ + rough breathing = (, smooth breathing = ) @ + acute accent = ', grave accent = `, dialytika = ^ @@ diff --git a/src/Symfony/Component/Tui/Widget/Figlet/fonts/mini.flf b/src/Symfony/Component/Tui/Widget/Figlet/fonts/mini.flf new file mode 100644 index 0000000000000..3b72606ca0dd1 --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/Figlet/fonts/mini.flf @@ -0,0 +1,899 @@ +flf2a$ 4 3 10 0 10 0 1920 96 +Mini by Glenn Chappell 4/93 +Includes ISO Latin-1 +figlet release 2.1 -- 12 Aug 1994 +Permission is hereby given to modify this font, as long as the +modifier's name is placed on a comment line. + +Modified by Paul Burton 12/96 to include new parameter +supported by FIGlet and FIGWin. May also be slightly modified for better use +of new full-width/kern/smush alternatives, but default output is NOT changed. + +$$@ +$$@ +$$@ +$$@@ + @ + |$@ + o$@ + @@ + @ + ||$@ + @ + @@ + @ + -|-|-$@ + -|-|-$@ + @@ + _$@ + (|$ @ + _|)$@ + @@ + @ + O/$@ + /O$@ + @@ + @ + ()$ @ + (_X$@ + @@ + @ + /$@ + @ + @@ + @ + /$@ + |$ @ + \$@@ + @ + \$ @ + |$@ + /$ @@ + @ + \|/$@ + /|\$@ + @@ + @ + _|_$@ + |$ @ + @@ + @ + @ + o$@ + /$@@ + @ + __$@ + @ + @@ + @ + @ + o$@ + @@ + @ + /$@ + /$ @ + @@ + _$ @ + / \$@ + \_/$@ + @@ + @ + /|$@ + |$@ + @@ + _$ @ + )$@ + /_$@ + @@ + _$ @ + _)$@ + _)$@ + @@ + @ + |_|_$@ + |$ @ + @@ + _$ @ + |_$ @ + _)$@ + @@ + _$ @ + |_$ @ + |_)$@ + @@ + __$@ + /$@ + /$ @ + @@ + _$ @ + (_)$@ + (_)$@ + @@ + _$ @ + (_|$@ + |$@ + @@ + @ + o$@ + o$@ + @@ + @ + o$@ + o$@ + /$@@ + @ + /$@ + \$@ + @@ + @ + --$@ + --$@ + @@ + @ + \$@ + /$@ + @@ + _$ @ + )$@ + o$ @ + @@ + __$ @ + / \$@ + | (|/$@ + \__$ @@ + @ + /\$ @ + /--\$@ + @@ + _$ @ + |_)$@ + |_)$@ + @@ + _$@ + /$ @ + \_$@ + @@ + _$ @ + | \$@ + |_/$@ + @@ + _$@ + |_$@ + |_$@ + @@ + _$@ + |_$@ + |$ @ + @@ + __$@ + /__$@ + \_|$@ + @@ + @ + |_|$@ + | |$@ + @@ + ___$@ + |$ @ + _|_$@ + @@ + @ + |$@ + \_|$@ + @@ + @ + |/$@ + |\$@ + @@ + @ + |$ @ + |_$@ + @@ + @ + |\/|$@ + | |$@ + @@ + @ + |\ |$@ + | \|$@ + @@ + _$ @ + / \$@ + \_/$@ + @@ + _$ @ + |_)$@ + |$ @ + @@ + _$ @ + / \$@ + \_X$@ + @@ + _$ @ + |_)$@ + | \$@ + @@ + __$@ + (_$ @ + __)$@ + @@ + ___$@ + |$ @ + |$ @ + @@ + @ + | |$@ + |_|$@ + @@ + @ + \ /$@ + \/$ @ + @@ + @ + \ /$@ + \/\/$ @ + @@ + @ + \/$@ + /\$@ + @@ + @ + \_/$@ + |$ @ + @@ + __$@ + /$@ + /_$@ + @@ + _$@ + |$ @ + |_$@ + @@ + @ + \$ @ + \$@ + @@ + _$ @ + |$@ + _|$@ + @@ + /\$@ + @ + @ + @@ + @ + @ + @ + __$@@ + @ + \$@ + @ + @@ + @ + _.$@ + (_|$@ + @@ + @ + |_$ @ + |_)$@ + @@ + @ + _$@ + (_$@ + @@ + @ + _|$@ + (_|$@ + @@ + @ + _$ @ + (/_$@ + @@ + _$@ + _|_$@ + |$ @ + @@ + @ + _$ @ + (_|$@ + _|$@@ + @ + |_$ @ + | |$@ + @@ + @ + o$@ + |$@ + @@ + @ + o$@ + |$@ + _|$@@ + @ + |$ @ + |<$@ + @@ + @ + |$@ + |$@ + @@ + @ + ._ _$ @ + | | |$@ + @@ + @ + ._$ @ + | |$@ + @@ + @ + _$ @ + (_)$@ + @@ + @ + ._$ @ + |_)$@ + |$ @@ + @ + _.$@ + (_|$@ + |$@@ + @ + ._$@ + |$ @ + @@ + @ + _$@ + _>$@ + @@ + @ + _|_$@ + |_$@ + @@ + @ + @ + |_|$@ + @@ + @ + @ + \/$@ + @@ + @ + @ + \/\/$@ + @@ + @ + @ + ><$@ + @@ + @ + @ + \/$@ + /$ @@ + @ + _$ @ + /_$@ + @@ + ,-$@ + _|$ @ + |$ @ + `-$@@ + |$@ + |$@ + |$@ + |$@@ + -.$ @ + |_$@ + |$ @ + -'$ @@ + /\/$@ + @ + @ + @@ + o o$@ + /\$ @ + /--\$@ + @@ + o_o$@ + / \$@ + \_/$@ + @@ + o o$@ + | |$@ + |_|$@ + @@ + o o$@ + _.$@ + (_|$@ + @@ + o o$@ + _$ @ + (_)$@ + @@ + o o$@ + @ + |_|$@ + @@ + _$ @ + | )$@ + | )$@ + |$ @@ +160 NO-BREAK SPACE + $$@ + $$@ + $$@ + $$@@ +161 INVERTED EXCLAMATION MARK + @ + o$@ + |$@ + @@ +162 CENT SIGN + @ + |_$@ + (__$@ + |$ @@ +163 POUND SIGN + _$ @ + _/_`$ @ + |___$@ + @@ +164 CURRENCY SIGN + @ + `o'$@ + ' `$@ + @@ +165 YEN SIGN + @ + _\_/_$@ + --|--$@ + @@ +166 BROKEN BAR + |$@ + |$@ + |$@ + |$@@ +167 SECTION SIGN + _$@ + ($ @ + ()$@ + _)$@@ +168 DIAERESIS + o o$@ + @ + @ + @@ +169 COPYRIGHT SIGN + _$ @ + |C|$@ + `-'$@ + @@ +170 FEMININE ORDINAL INDICATOR + _.$@ + (_|$@ + ---$@ + @@ +171 LEFT-POINTING DOUBLE ANGLE QUOTATION MARK + @ + //$@ + \\$@ + @@ +172 NOT SIGN + @ + __$ @ + |$@ + @@ +173 SOFT HYPHEN + @ + _$@ + @ + @@ +174 REGISTERED SIGN + _$ @ + |R|$@ + `-'$@ + @@ +175 MACRON + __$@ + @ + @ + @@ +176 DEGREE SIGN + O$@ + @ + @ + @@ +177 PLUS-MINUS SIGN + @ + _|_$@ + _|_$@ + @@ +178 SUPERSCRIPT TWO + 2$@ + @ + @ + @@ +179 SUPERSCRIPT THREE + 3$@ + @ + @ + @@ +180 ACUTE ACCENT + /$@ + @ + @ + @@ +181 MICRO SIGN + @ + @ + |_|$@ + |$ @@ +182 PILCROW SIGN + __$ @ + (| |$@ + | |$@ + @@ +183 MIDDLE DOT + @ + o$@ + @ + @@ +184 CEDILLA + @ + @ + @ + S$@@ +185 SUPERSCRIPT ONE + 1$@ + @ + @ + @@ +186 MASCULINE ORDINAL INDICATOR + _$ @ + (_)$@ + ---$@ + @@ +187 RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK + @ + \\$@ + //$@ + @@ +188 VULGAR FRACTION ONE QUARTER + @ + 1/$@ + /4$@ + @@ +189 VULGAR FRACTION ONE HALF + @ + 1/$@ + /2$@ + @@ +190 VULGAR FRACTION THREE QUARTERS + @ + 3/$@ + /4$@ + @@ +191 INVERTED QUESTION MARK + @ + o$@ + (_$@ + @@ +192 LATIN CAPITAL LETTER A WITH GRAVE + \$ @ + /\$ @ + /--\$@ + @@ +193 LATIN CAPITAL LETTER A WITH ACUTE + /$ @ + /\$ @ + /--\$@ + @@ +194 LATIN CAPITAL LETTER A WITH CIRCUMFLEX + /\$ @ + /\$ @ + /--\$@ + @@ +195 LATIN CAPITAL LETTER A WITH TILDE + /\/$@ + /\$ @ + /--\$@ + @@ +196 LATIN CAPITAL LETTER A WITH DIAERESIS + o o$@ + /\$ @ + /--\$@ + @@ +197 LATIN CAPITAL LETTER A WITH RING ABOVE + O$ @ + / \$ @ + /---\$@ + @@ +198 LATIN CAPITAL LETTER AE + _$@ + /|_$@ + /-|_$@ + @@ +199 LATIN CAPITAL LETTER C WITH CEDILLA + _$@ + /$ @ + \_$@ + S$@@ +200 LATIN CAPITAL LETTER E WITH GRAVE + \_$@ + |_$@ + |_$@ + @@ +201 LATIN CAPITAL LETTER E WITH ACUTE + _/$@ + |_$ @ + |_$ @ + @@ +202 LATIN CAPITAL LETTER E WITH CIRCUMFLEX + /\$@ + |_$ @ + |_$ @ + @@ +203 LATIN CAPITAL LETTER E WITH DIAERESIS + o_o$@ + |_$ @ + |_$ @ + @@ +204 LATIN CAPITAL LETTER I WITH GRAVE + \__$@ + |$ @ + _|_$@ + @@ +205 LATIN CAPITAL LETTER I WITH ACUTE + __/$@ + |$ @ + _|_$@ + @@ +206 LATIN CAPITAL LETTER I WITH CIRCUMFLEX + /\$@ + ___$@ + _|_$@ + @@ +207 LATIN CAPITAL LETTER I WITH DIAERESIS + o_o$@ + |$ @ + _|_$@ + @@ +208 LATIN CAPITAL LETTER ETH + _$ @ + _|_\$@ + |_/$@ + @@ +209 LATIN CAPITAL LETTER N WITH TILDE + /\/$@ + |\ |$@ + | \|$@ + @@ +210 LATIN CAPITAL LETTER O WITH GRAVE + \$ @ + / \$@ + \_/$@ + @@ +211 LATIN CAPITAL LETTER O WITH ACUTE + /$ @ + / \$@ + \_/$@ + @@ +212 LATIN CAPITAL LETTER O WITH CIRCUMFLEX + /\$@ + / \$@ + \_/$@ + @@ +213 LATIN CAPITAL LETTER O WITH TILDE + /\/$@ + / \$@ + \_/$@ + @@ +214 LATIN CAPITAL LETTER O WITH DIAERESIS + o_o$@ + / \$@ + \_/$@ + @@ +215 MULTIPLICATION SIGN + @ + @ + X$@ + @@ +216 LATIN CAPITAL LETTER O WITH STROKE + __$ @ + / /\$@ + \/_/$@ + @@ +217 LATIN CAPITAL LETTER U WITH GRAVE + \$ @ + | |$@ + |_|$@ + @@ +218 LATIN CAPITAL LETTER U WITH ACUTE + /$ @ + | |$@ + |_|$@ + @@ +219 LATIN CAPITAL LETTER U WITH CIRCUMFLEX + /\$@ + | |$@ + |_|$@ + @@ +220 LATIN CAPITAL LETTER U WITH DIAERESIS + o o$@ + | |$@ + |_|$@ + @@ +221 LATIN CAPITAL LETTER Y WITH ACUTE + /$ @ + \_/$@ + |$ @ + @@ +222 LATIN CAPITAL LETTER THORN + |_$ @ + |_)$@ + |$ @ + @@ +223 LATIN SMALL LETTER SHARP S + _$ @ + | )$@ + | )$@ + |$ @@ +224 LATIN SMALL LETTER A WITH GRAVE + \$ @ + _.$@ + (_|$@ + @@ +225 LATIN SMALL LETTER A WITH ACUTE + /$ @ + _.$@ + (_|$@ + @@ +226 LATIN SMALL LETTER A WITH CIRCUMFLEX + /\$@ + _.$@ + (_|$@ + @@ +227 LATIN SMALL LETTER A WITH TILDE + /\/$@ + _.$@ + (_|$@ + @@ +228 LATIN SMALL LETTER A WITH DIAERESIS + o o$@ + _.$@ + (_|$@ + @@ +229 LATIN SMALL LETTER A WITH RING ABOVE + O$ @ + _.$@ + (_|$@ + @@ +230 LATIN SMALL LETTER AE + @ + ___$ @ + (_|/_$@ + @@ +231 LATIN SMALL LETTER C WITH CEDILLA + @ + _$@ + (_$@ + S$@@ +232 LATIN SMALL LETTER E WITH GRAVE + \$ @ + _$ @ + (/_$@ + @@ +233 LATIN SMALL LETTER E WITH ACUTE + /$ @ + _$ @ + (/_$@ + @@ +234 LATIN SMALL LETTER E WITH CIRCUMFLEX + /\$@ + _$ @ + (/_$@ + @@ +235 LATIN SMALL LETTER E WITH DIAERESIS + o o$@ + _$ @ + (/_$@ + @@ +236 LATIN SMALL LETTER I WITH GRAVE + \$@ + @ + |$@ + @@ +237 LATIN SMALL LETTER I WITH ACUTE + /$@ + @ + |$@ + @@ +238 LATIN SMALL LETTER I WITH CIRCUMFLEX + /\$@ + @ + |$ @ + @@ +239 LATIN SMALL LETTER I WITH DIAERESIS + o o$@ + @ + |$ @ + @@ +240 LATIN SMALL LETTER ETH + X$ @ + \$ @ + (_|$@ + @@ +241 LATIN SMALL LETTER N WITH TILDE + /\/$@ + ._$ @ + | |$@ + @@ +242 LATIN SMALL LETTER O WITH GRAVE + \$ @ + _$ @ + (_)$@ + @@ +243 LATIN SMALL LETTER O WITH ACUTE + /$ @ + _$ @ + (_)$@ + @@ +244 LATIN SMALL LETTER O WITH CIRCUMFLEX + /\$@ + _$ @ + (_)$@ + @@ +245 LATIN SMALL LETTER O WITH TILDE + /\/$@ + _$ @ + (_)$@ + @@ +246 LATIN SMALL LETTER O WITH DIAERESIS + o o$@ + _$ @ + (_)$@ + @@ +247 DIVISION SIGN + o$ @ + ---$@ + o$ @ + @@ +248 LATIN SMALL LETTER O WITH STROKE + @ + _$ @ + (/)$@ + @@ +249 LATIN SMALL LETTER U WITH GRAVE + \$ @ + @ + |_|$@ + @@ +250 LATIN SMALL LETTER U WITH ACUTE + /$ @ + @ + |_|$@ + @@ +251 LATIN SMALL LETTER U WITH CIRCUMFLEX + /\$@ + @ + |_|$@ + @@ +252 LATIN SMALL LETTER U WITH DIAERESIS + o o$@ + @ + |_|$@ + @@ +253 LATIN SMALL LETTER Y WITH ACUTE + /$@ + @ + \/$@ + /$ @@ +254 LATIN SMALL LETTER THORN + @ + |_$ @ + |_)$@ + |$ @@ +255 LATIN SMALL LETTER Y WITH DIAERESIS + oo$@ + @ + \/$@ + /$ @@ diff --git a/src/Symfony/Component/Tui/Widget/Figlet/fonts/slant.flf b/src/Symfony/Component/Tui/Widget/Figlet/fonts/slant.flf new file mode 100644 index 0000000000000..43fe3986e8895 --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/Figlet/fonts/slant.flf @@ -0,0 +1,1295 @@ +flf2a$ 6 5 16 15 10 0 18319 96 +Slant by Glenn Chappell 3/93 -- based on Standard +Includes ISO Latin-1 +figlet release 2.1 -- 12 Aug 1994 +Permission is hereby given to modify this font, as long as the +modifier's name is placed on a comment line. + +Modified by Paul Burton 12/96 to include new parameter +supported by FIGlet and FIGWin. May also be slightly modified for better use +of new full-width/kern/smush alternatives, but default output is NOT changed. + + $$@ + $$ @ + $$ @ + $$ @ + $$ @ +$$ @@ + __@ + / /@ + / / @ + /_/ @ +(_) @ + @@ + _ _ @ +( | )@ +|/|/ @ + $ @ +$ @ + @@ + __ __ @ + __/ // /_@ + /_ _ __/@ +/_ _ __/ @ + /_//_/ @ + @@ + __@ + _/ /@ + / __/@ + (_ ) @ +/ _/ @ +/_/ @@ + _ __@ + (_)_/_/@ + _/_/ @ + _/_/_ @ +/_/ (_) @ + @@ + ___ @ + ( _ ) @ + / __ \/|@ +/ /_/ < @ +\____/\/ @ + @@ + _ @ + ( )@ + |/ @ + $ @ +$ @ + @@ + __@ + _/_/@ + / / @ + / / @ +/ / @ +|_| @@ + _ @ + | |@ + / /@ + / / @ + _/_/ @ +/_/ @@ + @ + __/|_@ + | /@ +/_ __| @ + |/ @ + @@ + @ + __ @ + __/ /_@ +/_ __/@ + /_/ @ + @@ + @ + @ + @ + _ @ +( )@ +|/ @@ + @ + @ + ______@ +/_____/@ + $ @ + @@ + @ + @ + @ + _ @ +(_)@ + @@ + __@ + _/_/@ + _/_/ @ + _/_/ @ +/_/ @ + @@ + ____ @ + / __ \@ + / / / /@ +/ /_/ / @ +\____/ @ + @@ + ___@ + < /@ + / / @ + / / @ +/_/ @ + @@ + ___ @ + |__ \@ + __/ /@ + / __/ @ +/____/ @ + @@ + _____@ + |__ /@ + /_ < @ + ___/ / @ +/____/ @ + @@ + __ __@ + / // /@ + / // /_@ +/__ __/@ + /_/ @ + @@ + ______@ + / ____/@ + /___ \ @ + ____/ / @ +/_____/ @ + @@ + _____@ + / ___/@ + / __ \ @ +/ /_/ / @ +\____/ @ + @@ + _____@ +/__ /@ + / / @ + / / @ +/_/ @ + @@ + ____ @ + ( __ )@ + / __ |@ +/ /_/ / @ +\____/ @ + @@ + ____ @ + / __ \@ + / /_/ /@ + \__, / @ +/____/ @ + @@ + @ + _ @ + (_)@ + _ @ +(_) @ + @@ + @ + _ @ + (_)@ + _ @ +( ) @ +|/ @@ + __@ + / /@ +/ / @ +\ \ @ + \_\@ + @@ + @ + _____@ + /____/@ +/____/ @ + $ @ + @@ +__ @ +\ \ @ + \ \@ + / /@ +/_/ @ + @@ + ___ @ + /__ \@ + / _/@ + /_/ @ +(_) @ + @@ + ______ @ + / ____ \@ + / / __ `/@ +/ / /_/ / @ +\ \__,_/ @ + \____/ @@ + ___ @ + / |@ + / /| |@ + / ___ |@ +/_/ |_|@ + @@ + ____ @ + / __ )@ + / __ |@ + / /_/ / @ +/_____/ @ + @@ + ______@ + / ____/@ + / / @ +/ /___ @ +\____/ @ + @@ + ____ @ + / __ \@ + / / / /@ + / /_/ / @ +/_____/ @ + @@ + ______@ + / ____/@ + / __/ @ + / /___ @ +/_____/ @ + @@ + ______@ + / ____/@ + / /_ @ + / __/ @ +/_/ @ + @@ + ______@ + / ____/@ + / / __ @ +/ /_/ / @ +\____/ @ + @@ + __ __@ + / / / /@ + / /_/ / @ + / __ / @ +/_/ /_/ @ + @@ + ____@ + / _/@ + / / @ + _/ / @ +/___/ @ + @@ + __@ + / /@ + __ / / @ +/ /_/ / @ +\____/ @ + @@ + __ __@ + / //_/@ + / ,< @ + / /| | @ +/_/ |_| @ + @@ + __ @ + / / @ + / / @ + / /___@ +/_____/@ + @@ + __ ___@ + / |/ /@ + / /|_/ / @ + / / / / @ +/_/ /_/ @ + @@ + _ __@ + / | / /@ + / |/ / @ + / /| / @ +/_/ |_/ @ + @@ + ____ @ + / __ \@ + / / / /@ +/ /_/ / @ +\____/ @ + @@ + ____ @ + / __ \@ + / /_/ /@ + / ____/ @ +/_/ @ + @@ + ____ @ + / __ \@ + / / / /@ +/ /_/ / @ +\___\_\ @ + @@ + ____ @ + / __ \@ + / /_/ /@ + / _, _/ @ +/_/ |_| @ + @@ + _____@ + / ___/@ + \__ \ @ + ___/ / @ +/____/ @ + @@ + ______@ + /_ __/@ + / / @ + / / @ +/_/ @ + @@ + __ __@ + / / / /@ + / / / / @ +/ /_/ / @ +\____/ @ + @@ + _ __@ +| | / /@ +| | / / @ +| |/ / @ +|___/ @ + @@ + _ __@ +| | / /@ +| | /| / / @ +| |/ |/ / @ +|__/|__/ @ + @@ + _ __@ + | |/ /@ + | / @ + / | @ +/_/|_| @ + @@ +__ __@ +\ \/ /@ + \ / @ + / / @ +/_/ @ + @@ + _____@ +/__ /@ + / / @ + / /__@ +/____/@ + @@ + ___@ + / _/@ + / / @ + / / @ + / / @ +/__/ @@ +__ @ +\ \ @ + \ \ @ + \ \ @ + \_\@ + @@ + ___@ + / /@ + / / @ + / / @ + _/ / @ +/__/ @@ + //|@ + |/||@ + $ @ + $ @ +$ @ + @@ + @ + @ + @ + @ + ______@ +/_____/@@ + _ @ + ( )@ + V @ + $ @ +$ @ + @@ + @ + ____ _@ + / __ `/@ +/ /_/ / @ +\__,_/ @ + @@ + __ @ + / /_ @ + / __ \@ + / /_/ /@ +/_.___/ @ + @@ + @ + _____@ + / ___/@ +/ /__ @ +\___/ @ + @@ + __@ + ____/ /@ + / __ / @ +/ /_/ / @ +\__,_/ @ + @@ + @ + ___ @ + / _ \@ +/ __/@ +\___/ @ + @@ + ____@ + / __/@ + / /_ @ + / __/ @ +/_/ @ + @@ + @ + ____ _@ + / __ `/@ + / /_/ / @ + \__, / @ +/____/ @@ + __ @ + / /_ @ + / __ \@ + / / / /@ +/_/ /_/ @ + @@ + _ @ + (_)@ + / / @ + / / @ +/_/ @ + @@ + _ @ + (_)@ + / / @ + / / @ + __/ / @ +/___/ @@ + __ @ + / /__@ + / //_/@ + / ,< @ +/_/|_| @ + @@ + __@ + / /@ + / / @ + / / @ +/_/ @ + @@ + @ + ____ ___ @ + / __ `__ \@ + / / / / / /@ +/_/ /_/ /_/ @ + @@ + @ + ____ @ + / __ \@ + / / / /@ +/_/ /_/ @ + @@ + @ + ____ @ + / __ \@ +/ /_/ /@ +\____/ @ + @@ + @ + ____ @ + / __ \@ + / /_/ /@ + / .___/ @ +/_/ @@ + @ + ____ _@ + / __ `/@ +/ /_/ / @ +\__, / @ + /_/ @@ + @ + _____@ + / ___/@ + / / @ +/_/ @ + @@ + @ + _____@ + / ___/@ + (__ ) @ +/____/ @ + @@ + __ @ + / /_@ + / __/@ +/ /_ @ +\__/ @ + @@ + @ + __ __@ + / / / /@ +/ /_/ / @ +\__,_/ @ + @@ + @ + _ __@ +| | / /@ +| |/ / @ +|___/ @ + @@ + @ + _ __@ +| | /| / /@ +| |/ |/ / @ +|__/|__/ @ + @@ + @ + _ __@ + | |/_/@ + _> < @ +/_/|_| @ + @@ + @ + __ __@ + / / / /@ + / /_/ / @ + \__, / @ +/____/ @@ + @ + ____@ +/_ /@ + / /_@ +/___/@ + @@ + __@ + _/_/@ + _/_/ @ +< < @ +/ / @ +\_\ @@ + __@ + / /@ + / / @ + / / @ + / / @ +/_/ @@ + _ @ + | |@ + / /@ + _>_>@ + _/_/ @ +/_/ @@ + /\//@ + //\/ @ + $ @ + $ @ +$ @ + @@ + _ _ @ + (_)(_)@ + / _ | @ + / __ | @ +/_/ |_| @ + @@ + _ _ @ + (_)_(_)@ + / __ \ @ +/ /_/ / @ +\____/ @ + @@ + _ _ @ + (_) (_)@ + / / / / @ +/ /_/ / @ +\____/ @ + @@ + _ _ @ + (_)_(_)@ + / __ `/ @ +/ /_/ / @ +\__,_/ @ + @@ + _ _ @ + (_)_(_)@ + / __ \ @ +/ /_/ / @ +\____/ @ + @@ + _ _ @ + (_) (_)@ + / / / / @ +/ /_/ / @ +\__,_/ @ + @@ + ____ @ + / __ \@ + / / / /@ + / /_| | @ + / //__/ @ +/_/ @@ +160 NO-BREAK SPACE + $$@ + $$ @ + $$ @ + $$ @ + $$ @ +$$ @@ +161 INVERTED EXCLAMATION MARK + _ @ + (_)@ + / / @ + / / @ +/_/ @ + @@ +162 CENT SIGN + __@ + __/ /@ + / ___/@ +/ /__ @ +\ _/ @ +/_/ @@ +163 POUND SIGN + ____ @ + / ,__\@ + __/ /_ @ + _/ /___ @ +(_,____/ @ + @@ +164 CURRENCY SIGN + /|___/|@ + | __ / @ + / /_/ / @ + /___ | @ +|/ |/ @ + @@ +165 YEN SIGN + ____@ + _| / /@ + /_ __/@ +/_ __/ @ + /_/ @ + @@ +166 BROKEN BAR + __@ + / /@ + /_/ @ + __ @ + / / @ +/_/ @@ +167 SECTION SIGN + __ @ + _/ _)@ + / | | @ + | || | @ + | |_/ @ +(__/ @@ +168 DIAERESIS + _ _ @ + (_) (_)@ + $ $ @ + $ $ @ +$ $ @ + @@ +169 COPYRIGHT SIGN + ______ @ + / _____\ @ + / / ___/ |@ + / / /__ / @ +| \___/ / @ + \______/ @@ +170 FEMININE ORDINAL INDICATOR + ___ _@ + / _ `/@ + _\_,_/ @ +/____/ @ + $ @ + @@ +171 LEFT-POINTING DOUBLE ANGLE QUOTATION MARK + ____@ + / / /@ +/ / / @ +\ \ \ @ + \_\_\@ + @@ +172 NOT SIGN + @ + ______@ +/___ /@ + /_/ @ + $ @ + @@ +173 SOFT HYPHEN + @ + @ + _____@ +/____/@ + $ @ + @@ +174 REGISTERED SIGN + ______ @ + / ___ \ @ + / / _ \ |@ + / / , _/ / @ +| /_/|_| / @ + \______/ @@ +175 MACRON + ______@ +/_____/@ + $ @ + $ @ +$ @ + @@ +176 DEGREE SIGN + ___ @ + / _ \@ +/ // /@ +\___/ @ + $ @ + @@ +177 PLUS-MINUS SIGN + __ @ + __/ /_@ + /_ __/@ + __/_/_ @ +/_____/ @ + @@ +178 SUPERSCRIPT TWO + ___ @ + |_ |@ + / __/ @ +/____/ @ + $ @ + @@ +179 SUPERSCRIPT THREE + ____@ + |_ /@ + _/_ < @ +/____/ @ + $ @ + @@ +180 ACUTE ACCENT + __@ + /_/@ + $ @ + $ @ +$ @ + @@ +181 MICRO SIGN + @ + __ __@ + / / / /@ + / /_/ / @ + / ._,_/ @ +/_/ @@ +182 PILCROW SIGN + _______@ + / _ /@ +/ (/ / / @ +\_ / / @ + /_/_/ @ + @@ +183 MIDDLE DOT + @ + _ @ +(_)@ + $ @ +$ @ + @@ +184 CEDILLA + @ + @ + @ + @ + _ @ +/_)@@ +185 SUPERSCRIPT ONE + ___@ + < /@ + / / @ +/_/ @ +$ @ + @@ +186 MASCULINE ORDINAL INDICATOR + ___ @ + / _ \@ + _\___/@ +/____/ @ + $ @ + @@ +187 RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK +____ @ +\ \ \ @ + \ \ \@ + / / /@ +/_/_/ @ + @@ +188 VULGAR FRACTION ONE QUARTER + ___ __ @ + < / _/_/ @ + / /_/_/___@ +/_//_// / /@ + /_/ /_ _/@ + /_/ @@ +189 VULGAR FRACTION ONE HALF + ___ __ @ + < / _/_/__ @ + / /_/_/|_ |@ +/_//_/ / __/ @ + /_/ /____/ @ + @@ +190 VULGAR FRACTION THREE QUARTERS + ____ __ @ + |_ / _/_/ @ + _/_ < _/_/___@ +/____//_// / /@ + /_/ /_ _/@ + /_/ @@ +191 INVERTED QUESTION MARK + _ @ + (_)@ + _/ / @ +/ _/_ @ +\___/ @ + @@ +192 LATIN CAPITAL LETTER A WITH GRAVE + __ @ + _\_\@ + / _ |@ + / __ |@ +/_/ |_|@ + @@ +193 LATIN CAPITAL LETTER A WITH ACUTE + __@ + _/_/@ + / _ |@ + / __ |@ +/_/ |_|@ + @@ +194 LATIN CAPITAL LETTER A WITH CIRCUMFLEX + //|@ + _|/||@ + / _ | @ + / __ | @ +/_/ |_| @ + @@ +195 LATIN CAPITAL LETTER A WITH TILDE + /\//@ + _//\/ @ + / _ | @ + / __ | @ +/_/ |_| @ + @@ +196 LATIN CAPITAL LETTER A WITH DIAERESIS + _ _ @ + (_)(_)@ + / _ | @ + / __ | @ +/_/ |_| @ + @@ +197 LATIN CAPITAL LETTER A WITH RING ABOVE + (())@ + / |@ + / /| |@ + / ___ |@ +/_/ |_|@ + @@ +198 LATIN CAPITAL LETTER AE + __________@ + / ____/@ + / /| __/ @ + / __ /___ @ +/_/ /_____/ @ + @@ +199 LATIN CAPITAL LETTER C WITH CEDILLA + ______@ + / ____/@ + / / @ +/ /___ @ +\____/ @ + /_) @@ +200 LATIN CAPITAL LETTER E WITH GRAVE + __ @ + _\_\@ + / __/@ + / _/ @ +/___/ @ + @@ +201 LATIN CAPITAL LETTER E WITH ACUTE + __@ + _/_/@ + / __/@ + / _/ @ +/___/ @ + @@ +202 LATIN CAPITAL LETTER E WITH CIRCUMFLEX + //|@ + _|/||@ + / __/ @ + / _/ @ +/___/ @ + @@ +203 LATIN CAPITAL LETTER E WITH DIAERESIS + _ _ @ + (_)(_)@ + / __/ @ + / _/ @ +/___/ @ + @@ +204 LATIN CAPITAL LETTER I WITH GRAVE + __ @ + _\_\@ + / _/@ + _/ / @ +/___/ @ + @@ +205 LATIN CAPITAL LETTER I WITH ACUTE + __@ + _/_/@ + / _/@ + _/ / @ +/___/ @ + @@ +206 LATIN CAPITAL LETTER I WITH CIRCUMFLEX + //|@ + _|/||@ + / _/ @ + _/ / @ +/___/ @ + @@ +207 LATIN CAPITAL LETTER I WITH DIAERESIS + _ _ @ + (_)(_)@ + / _/ @ + _/ / @ +/___/ @ + @@ +208 LATIN CAPITAL LETTER ETH + ____ @ + / __ \@ + __/ /_/ /@ +/_ __/ / @ + /_____/ @ + @@ +209 LATIN CAPITAL LETTER N WITH TILDE + /\//@ + _//\/ @ + / |/ / @ + / / @ +/_/|_/ @ + @@ +210 LATIN CAPITAL LETTER O WITH GRAVE + __ @ + __\_\@ + / __ \@ +/ /_/ /@ +\____/ @ + @@ +211 LATIN CAPITAL LETTER O WITH ACUTE + __@ + __/_/@ + / __ \@ +/ /_/ /@ +\____/ @ + @@ +212 LATIN CAPITAL LETTER O WITH CIRCUMFLEX + //|@ + _|/||@ + / __ \@ +/ /_/ /@ +\____/ @ + @@ +213 LATIN CAPITAL LETTER O WITH TILDE + /\//@ + _//\/ @ + / __ \ @ +/ /_/ / @ +\____/ @ + @@ +214 LATIN CAPITAL LETTER O WITH DIAERESIS + _ _ @ + (_)_(_)@ + / __ \ @ +/ /_/ / @ +\____/ @ + @@ +215 MULTIPLICATION SIGN + @ + @ + /|/|@ + > < @ +|/|/ @ + @@ +216 LATIN CAPITAL LETTER O WITH STROKE + _____ @ + / _// \@ + / //// /@ +/ //// / @ +\_//__/ @ + @@ +217 LATIN CAPITAL LETTER U WITH GRAVE + __ @ + __\_\_@ + / / / /@ +/ /_/ / @ +\____/ @ + @@ +218 LATIN CAPITAL LETTER U WITH ACUTE + __ @ + __/_/_@ + / / / /@ +/ /_/ / @ +\____/ @ + @@ +219 LATIN CAPITAL LETTER U WITH CIRCUMFLEX + //| @ + _|/||_@ + / / / /@ +/ /_/ / @ +\____/ @ + @@ +220 LATIN CAPITAL LETTER U WITH DIAERESIS + _ _ @ + (_) (_)@ + / / / / @ +/ /_/ / @ +\____/ @ + @@ +221 LATIN CAPITAL LETTER Y WITH ACUTE + __ @ +__/_/_@ +\ \/ /@ + \ / @ + /_/ @ + @@ +222 LATIN CAPITAL LETTER THORN + __ @ + / /_ @ + / __ \@ + / ____/@ +/_/ @ + @@ +223 LATIN SMALL LETTER SHARP S + ____ @ + / __ \@ + / / / /@ + / /_| | @ + / //__/ @ +/_/ @@ +224 LATIN SMALL LETTER A WITH GRAVE + __ @ + __\_\_@ + / __ `/@ +/ /_/ / @ +\__,_/ @ + @@ +225 LATIN SMALL LETTER A WITH ACUTE + __ @ + __/_/_@ + / __ `/@ +/ /_/ / @ +\__,_/ @ + @@ +226 LATIN SMALL LETTER A WITH CIRCUMFLEX + //| @ + _|/||_@ + / __ `/@ +/ /_/ / @ +\__,_/ @ + @@ +227 LATIN SMALL LETTER A WITH TILDE + /\//@ + _//\/_@ + / __ `/@ +/ /_/ / @ +\__,_/ @ + @@ +228 LATIN SMALL LETTER A WITH DIAERESIS + _ _ @ + (_)_(_)@ + / __ `/ @ +/ /_/ / @ +\__,_/ @ + @@ +229 LATIN SMALL LETTER A WITH RING ABOVE + __ @ + __(())@ + / __ `/@ +/ /_/ / @ +\__,_/ @ + @@ +230 LATIN SMALL LETTER AE + @ + ____ ___ @ + / __ ` _ \@ +/ /_/ __/@ +\__,_____/ @ + @@ +231 LATIN SMALL LETTER C WITH CEDILLA + @ + _____@ + / ___/@ +/ /__ @ +\___/ @ +/_) @@ +232 LATIN SMALL LETTER E WITH GRAVE + __ @ + _\_\@ + / _ \@ +/ __/@ +\___/ @ + @@ +233 LATIN SMALL LETTER E WITH ACUTE + __@ + _/_/@ + / _ \@ +/ __/@ +\___/ @ + @@ +234 LATIN SMALL LETTER E WITH CIRCUMFLEX + //|@ + _|/||@ + / _ \ @ +/ __/ @ +\___/ @ + @@ +235 LATIN SMALL LETTER E WITH DIAERESIS + _ _ @ + (_)(_)@ + / _ \ @ +/ __/ @ +\___/ @ + @@ +236 LATIN SMALL LETTER I WITH GRAVE + __ @ + \_\@ + / / @ + / / @ +/_/ @ + @@ +237 LATIN SMALL LETTER I WITH ACUTE + __@ + /_/@ + / / @ + / / @ +/_/ @ + @@ +238 LATIN SMALL LETTER I WITH CIRCUMFLEX + //|@ + |/||@ + / / @ + / / @ +/_/ @ + @@ +239 LATIN SMALL LETTER I WITH DIAERESIS + _ _ @ + (_)_(_)@ + / / @ + / / @ +/_/ @ + @@ +240 LATIN SMALL LETTER ETH + || @ + =||=@ + ___ || @ +/ __` | @ +\____/ @ + @@ +241 LATIN SMALL LETTER N WITH TILDE + /\//@ + _//\/ @ + / __ \ @ + / / / / @ +/_/ /_/ @ + @@ +242 LATIN SMALL LETTER O WITH GRAVE + __ @ + __\_\@ + / __ \@ +/ /_/ /@ +\____/ @ + @@ +243 LATIN SMALL LETTER O WITH ACUTE + __@ + __/_/@ + / __ \@ +/ /_/ /@ +\____/ @ + @@ +244 LATIN SMALL LETTER O WITH CIRCUMFLEX + //|@ + _|/||@ + / __ \@ +/ /_/ /@ +\____/ @ + @@ +245 LATIN SMALL LETTER O WITH TILDE + /\//@ + _//\/ @ + / __ \ @ +/ /_/ / @ +\____/ @ + @@ +246 LATIN SMALL LETTER O WITH DIAERESIS + _ _ @ + (_)_(_)@ + / __ \ @ +/ /_/ / @ +\____/ @ + @@ +247 DIVISION SIGN + @ + _ @ + __(_)_@ +/_____/@ + (_) @ + @@ +248 LATIN SMALL LETTER O WITH STROKE + @ + _____ @ + / _// \@ +/ //// /@ +\_//__/ @ + @@ +249 LATIN SMALL LETTER U WITH GRAVE + __ @ + __\_\_@ + / / / /@ +/ /_/ / @ +\__,_/ @ + @@ +250 LATIN SMALL LETTER U WITH ACUTE + __ @ + __/_/_@ + / / / /@ +/ /_/ / @ +\__,_/ @ + @@ +251 LATIN SMALL LETTER U WITH CIRCUMFLEX + //| @ + _|/||_@ + / / / /@ +/ /_/ / @ +\__,_/ @ + @@ +252 LATIN SMALL LETTER U WITH DIAERESIS + _ _ @ + (_) (_)@ + / / / / @ +/ /_/ / @ +\__,_/ @ + @@ +253 LATIN SMALL LETTER Y WITH ACUTE + __ @ + __/_/_@ + / / / /@ + / /_/ / @ + \__, / @ +/____/ @@ +254 LATIN SMALL LETTER THORN + __ @ + / /_ @ + / __ \@ + / /_/ /@ + / .___/ @ +/_/ @@ +255 LATIN SMALL LETTER Y WITH DIAERESIS + _ _ @ + (_) (_)@ + / / / / @ + / /_/ / @ + \__, / @ +/____/ @@ diff --git a/src/Symfony/Component/Tui/Widget/Figlet/fonts/small.flf b/src/Symfony/Component/Tui/Widget/Figlet/fonts/small.flf new file mode 100644 index 0000000000000..c6b5bfcdef143 --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/Figlet/fonts/small.flf @@ -0,0 +1,1097 @@ +flf2a$ 5 4 13 15 10 0 22415 96 +Small by Glenn Chappell 4/93 -- based on Standard +Includes ISO Latin-1 +figlet release 2.1 -- 12 Aug 1994 +Permission is hereby given to modify this font, as long as the +modifier's name is placed on a comment line. + +Modified by Paul Burton 12/96 to include new parameter +supported by FIGlet and FIGWin. May also be slightly modified for better use +of new full-width/kern/smush alternatives, but default output is NOT changed. + + $@ + $@ + $@ + $@ + $@@ + _ @ + | |@ + |_|@ + (_)@ + @@ + _ _ @ + ( | )@ + V V @ + $ @ + @@ + _ _ @ + _| | |_ @ + |_ . _|@ + |_ _|@ + |_|_| @@ + @ + ||_@ + (_-<@ + / _/@ + || @@ + _ __ @ + (_)/ / @ + / /_ @ + /_/(_)@ + @@ + __ @ + / _|___ @ + > _|_ _|@ + \_____| @ + @@ + _ @ + ( )@ + |/ @ + $ @ + @@ + __@ + / /@ + | | @ + | | @ + \_\@@ + __ @ + \ \ @ + | |@ + | |@ + /_/ @@ + @ + _/\_@ + > <@ + \/ @ + @@ + _ @ + _| |_ @ + |_ _|@ + |_| @ + @@ + @ + @ + _ @ + ( )@ + |/ @@ + @ + ___ @ + |___|@ + $ @ + @@ + @ + @ + _ @ + (_)@ + @@ + __@ + / /@ + / / @ + /_/ @ + @@ + __ @ + / \ @ + | () |@ + \__/ @ + @@ + _ @ + / |@ + | |@ + |_|@ + @@ + ___ @ + |_ )@ + / / @ + /___|@ + @@ + ____@ + |__ /@ + |_ \@ + |___/@ + @@ + _ _ @ + | | | @ + |_ _|@ + |_| @ + @@ + ___ @ + | __|@ + |__ \@ + |___/@ + @@ + __ @ + / / @ + / _ \@ + \___/@ + @@ + ____ @ + |__ |@ + / / @ + /_/ @ + @@ + ___ @ + ( _ )@ + / _ \@ + \___/@ + @@ + ___ @ + / _ \@ + \_, /@ + /_/ @ + @@ + _ @ + (_)@ + _ @ + (_)@ + @@ + _ @ + (_)@ + _ @ + ( )@ + |/ @@ + __@ + / /@ + < < @ + \_\@ + @@ + @ + ___ @ + |___|@ + |___|@ + @@ + __ @ + \ \ @ + > >@ + /_/ @ + @@ + ___ @ + |__ \@ + /_/@ + (_) @ + @@ + ____ @ + / __ \ @ + / / _` |@ + \ \__,_|@ + \____/ @@ + _ @ + /_\ @ + / _ \ @ + /_/ \_\@ + @@ + ___ @ + | _ )@ + | _ \@ + |___/@ + @@ + ___ @ + / __|@ + | (__ @ + \___|@ + @@ + ___ @ + | \ @ + | |) |@ + |___/ @ + @@ + ___ @ + | __|@ + | _| @ + |___|@ + @@ + ___ @ + | __|@ + | _| @ + |_| @ + @@ + ___ @ + / __|@ + | (_ |@ + \___|@ + @@ + _ _ @ + | || |@ + | __ |@ + |_||_|@ + @@ + ___ @ + |_ _|@ + | | @ + |___|@ + @@ + _ @ + _ | |@ + | || |@ + \__/ @ + @@ + _ __@ + | |/ /@ + | ' < @ + |_|\_\@ + @@ + _ @ + | | @ + | |__ @ + |____|@ + @@ + __ __ @ + | \/ |@ + | |\/| |@ + |_| |_|@ + @@ + _ _ @ + | \| |@ + | .` |@ + |_|\_|@ + @@ + ___ @ + / _ \ @ + | (_) |@ + \___/ @ + @@ + ___ @ + | _ \@ + | _/@ + |_| @ + @@ + ___ @ + / _ \ @ + | (_) |@ + \__\_\@ + @@ + ___ @ + | _ \@ + | /@ + |_|_\@ + @@ + ___ @ + / __|@ + \__ \@ + |___/@ + @@ + _____ @ + |_ _|@ + | | @ + |_| @ + @@ + _ _ @ + | | | |@ + | |_| |@ + \___/ @ + @@ + __ __@ + \ \ / /@ + \ V / @ + \_/ @ + @@ + __ __@ + \ \ / /@ + \ \/\/ / @ + \_/\_/ @ + @@ + __ __@ + \ \/ /@ + > < @ + /_/\_\@ + @@ + __ __@ + \ \ / /@ + \ V / @ + |_| @ + @@ + ____@ + |_ /@ + / / @ + /___|@ + @@ + __ @ + | _|@ + | | @ + | | @ + |__|@@ + __ @ + \ \ @ + \ \ @ + \_\@ + @@ + __ @ + |_ |@ + | |@ + | |@ + |__|@@ + /\ @ + |/\|@ + $ @ + $ @ + @@ + @ + @ + @ + ___ @ + |___|@@ + _ @ + ( )@ + \|@ + $ @ + @@ + @ + __ _ @ + / _` |@ + \__,_|@ + @@ + _ @ + | |__ @ + | '_ \@ + |_.__/@ + @@ + @ + __ @ + / _|@ + \__|@ + @@ + _ @ + __| |@ + / _` |@ + \__,_|@ + @@ + @ + ___ @ + / -_)@ + \___|@ + @@ + __ @ + / _|@ + | _|@ + |_| @ + @@ + @ + __ _ @ + / _` |@ + \__, |@ + |___/ @@ + _ @ + | |_ @ + | ' \ @ + |_||_|@ + @@ + _ @ + (_)@ + | |@ + |_|@ + @@ + _ @ + (_)@ + | |@ + _/ |@ + |__/ @@ + _ @ + | |__@ + | / /@ + |_\_\@ + @@ + _ @ + | |@ + | |@ + |_|@ + @@ + @ + _ __ @ + | ' \ @ + |_|_|_|@ + @@ + @ + _ _ @ + | ' \ @ + |_||_|@ + @@ + @ + ___ @ + / _ \@ + \___/@ + @@ + @ + _ __ @ + | '_ \@ + | .__/@ + |_| @@ + @ + __ _ @ + / _` |@ + \__, |@ + |_|@@ + @ + _ _ @ + | '_|@ + |_| @ + @@ + @ + ___@ + (_-<@ + /__/@ + @@ + _ @ + | |_ @ + | _|@ + \__|@ + @@ + @ + _ _ @ + | || |@ + \_,_|@ + @@ + @ + __ __@ + \ V /@ + \_/ @ + @@ + @ + __ __ __@ + \ V V /@ + \_/\_/ @ + @@ + @ + __ __@ + \ \ /@ + /_\_\@ + @@ + @ + _ _ @ + | || |@ + \_, |@ + |__/ @@ + @ + ___@ + |_ /@ + /__|@ + @@ + __@ + / /@ + _| | @ + | | @ + \_\@@ + _ @ + | |@ + | |@ + | |@ + |_|@@ + __ @ + \ \ @ + | |_@ + | | @ + /_/ @@ + /\/|@ + |/\/ @ + $ @ + $ @ + @@ + _ _ @ + (_)(_)@ + /--\ @ + /_/\_\@ + @@ + _ _ @ + (_)(_)@ + / __ \@ + \____/@ + @@ + _ _ @ + (_) (_)@ + | |_| |@ + \___/ @ + @@ + _ _ @ + (_)(_)@ + / _` |@ + \__,_|@ + @@ + _ _ @ + (_)_(_)@ + / _ \ @ + \___/ @ + @@ + _ _ @ + (_)(_)@ + | || |@ + \_,_|@ + @@ + ___ @ + / _ \@ + | |< <@ + | ||_/@ + |_| @@ +160 NO-BREAK SPACE + $@ + $@ + $@ + $@ + $@@ +161 INVERTED EXCLAMATION MARK + _ @ + (_)@ + | |@ + |_|@ + @@ +162 CENT SIGN + @ + || @ + / _)@ + \ _)@ + || @@ +163 POUND SIGN + __ @ + _/ _\ @ + |_ _|_ @ + (_,___|@ + @@ +164 CURRENCY SIGN + /\_/\@ + \ . /@ + / _ \@ + \/ \/@ + @@ +165 YEN SIGN + __ __ @ + \ V / @ + |__ __|@ + |__ __|@ + |_| @@ +166 BROKEN BAR + _ @ + | |@ + |_|@ + | |@ + |_|@@ +167 SECTION SIGN + __ @ + / _)@ + /\ \ @ + \ \/ @ + (__/ @@ +168 DIAERESIS + _ _ @ + (_)(_)@ + $ $ @ + $ $ @ + @@ +169 COPYRIGHT SIGN + ____ @ + / __ \ @ + / / _| \@ + \ \__| /@ + \____/ @@ +170 FEMININE ORDINAL INDICATOR + __ _ @ + / _` |@ + \__,_|@ + |____|@ + @@ +171 LEFT-POINTING DOUBLE ANGLE QUOTATION MARK + ____@ + / / /@ + < < < @ + \_\_\@ + @@ +172 NOT SIGN + ____ @ + |__ |@ + |_|@ + $ @ + @@ +173 SOFT HYPHEN + @ + __ @ + |__|@ + $ @ + @@ +174 REGISTERED SIGN + ____ @ + / __ \ @ + / | -) \@ + \ ||\\ /@ + \____/ @@ +175 MACRON + ___ @ + |___|@ + $ @ + $ @ + @@ +176 DEGREE SIGN + _ @ + /.\@ + \_/@ + $ @ + @@ +177 PLUS-MINUS SIGN + _ @ + _| |_ @ + |_ _|@ + _|_|_ @ + |_____|@@ +178 SUPERSCRIPT TWO + __ @ + |_ )@ + /__|@ + $ @ + @@ +179 SUPERSCRIPT THREE + ___@ + |_ /@ + |__)@ + $ @ + @@ +180 ACUTE ACCENT + __@ + /_/@ + $ @ + $ @ + @@ +181 MICRO SIGN + @ + _ _ @ + | || |@ + | .,_|@ + |_| @@ +182 PILCROW SIGN + ____ @ + / |@ + \_ | |@ + |_|_|@ + @@ +183 MIDDLE DOT + @ + _ @ + (_)@ + $ @ + @@ +184 CEDILLA + @ + @ + @ + _ @ + )_)@@ +185 SUPERSCRIPT ONE + _ @ + / |@ + |_|@ + $ @ + @@ +186 MASCULINE ORDINAL INDICATOR + ___ @ + / _ \@ + \___/@ + |___|@ + @@ +187 RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK + ____ @ + \ \ \ @ + > > >@ + /_/_/ @ + @@ +188 VULGAR FRACTION ONE QUARTER + _ __ @ + / |/ /__ @ + |_/ /_' |@ + /_/ |_|@ + @@ +189 VULGAR FRACTION ONE HALF + _ __ @ + / |/ /_ @ + |_/ /_ )@ + /_//__|@ + @@ +190 VULGAR FRACTION THREE QUARTERS + ___ __ @ + |_ // /__ @ + |__) /_' |@ + /_/ |_|@ + @@ +191 INVERTED QUESTION MARK + _ @ + (_) @ + / /_ @ + \___|@ + @@ +192 LATIN CAPITAL LETTER A WITH GRAVE + __ @ + \_\ @ + /--\ @ + /_/\_\@ + @@ +193 LATIN CAPITAL LETTER A WITH ACUTE + __ @ + /_/ @ + /--\ @ + /_/\_\@ + @@ +194 LATIN CAPITAL LETTER A WITH CIRCUMFLEX + /\ @ + |/\| @ + /--\ @ + /_/\_\@ + @@ +195 LATIN CAPITAL LETTER A WITH TILDE + /\/|@ + |/\/ @ + /--\ @ + /_/\_\@ + @@ +196 LATIN CAPITAL LETTER A WITH DIAERESIS + _ _ @ + (_)(_)@ + /--\ @ + /_/\_\@ + @@ +197 LATIN CAPITAL LETTER A WITH RING ABOVE + __ @ + (()) @ + /--\ @ + /_/\_\@ + @@ +198 LATIN CAPITAL LETTER AE + ____ @ + /, __|@ + / _ _| @ + /_/|___|@ + @@ +199 LATIN CAPITAL LETTER C WITH CEDILLA + ___ @ + / __|@ + | (__ @ + \___|@ + )_) @@ +200 LATIN CAPITAL LETTER E WITH GRAVE + __ @ + \_\@ + | -<@ + |__<@ + @@ +201 LATIN CAPITAL LETTER E WITH ACUTE + __@ + /_/@ + | -<@ + |__<@ + @@ +202 LATIN CAPITAL LETTER E WITH CIRCUMFLEX + /\ @ + |/\|@ + | -<@ + |__<@ + @@ +203 LATIN CAPITAL LETTER E WITH DIAERESIS + _ _ @ + (_)(_)@ + | -< @ + |__< @ + @@ +204 LATIN CAPITAL LETTER I WITH GRAVE + __ @ + \_\ @ + |_ _|@ + |___|@ + @@ +205 LATIN CAPITAL LETTER I WITH ACUTE + __ @ + /_/ @ + |_ _|@ + |___|@ + @@ +206 LATIN CAPITAL LETTER I WITH CIRCUMFLEX + //\ @ + |/_\|@ + |_ _|@ + |___|@ + @@ +207 LATIN CAPITAL LETTER I WITH DIAERESIS + _ _ @ + (_)_(_)@ + |_ _| @ + |___| @ + @@ +208 LATIN CAPITAL LETTER ETH + ____ @ + | __ \ @ + |_ _|) |@ + |____/ @ + @@ +209 LATIN CAPITAL LETTER N WITH TILDE + /\/|@ + |/\/ @ + | \| |@ + |_|\_|@ + @@ +210 LATIN CAPITAL LETTER O WITH GRAVE + __ @ + \_\_ @ + / __ \@ + \____/@ + @@ +211 LATIN CAPITAL LETTER O WITH ACUTE + __ @ + _/_/ @ + / __ \@ + \____/@ + @@ +212 LATIN CAPITAL LETTER O WITH CIRCUMFLEX + /\ @ + |/\| @ + / __ \@ + \____/@ + @@ +213 LATIN CAPITAL LETTER O WITH TILDE + /\/|@ + |/\/ @ + / __ \@ + \____/@ + @@ +214 LATIN CAPITAL LETTER O WITH DIAERESIS + _ _ @ + (_)(_)@ + / __ \@ + \____/@ + @@ +215 MULTIPLICATION SIGN + @ + /\/\@ + > <@ + \/\/@ + @@ +216 LATIN CAPITAL LETTER O WITH STROKE + ____ @ + / _//\ @ + | (//) |@ + \//__/ @ + @@ +217 LATIN CAPITAL LETTER U WITH GRAVE + __ @ + _\_\_ @ + | |_| |@ + \___/ @ + @@ +218 LATIN CAPITAL LETTER U WITH ACUTE + __ @ + _/_/_ @ + | |_| |@ + \___/ @ + @@ +219 LATIN CAPITAL LETTER U WITH CIRCUMFLEX + //\ @ + |/ \| @ + | |_| |@ + \___/ @ + @@ +220 LATIN CAPITAL LETTER U WITH DIAERESIS + _ _ @ + (_) (_)@ + | |_| |@ + \___/ @ + @@ +221 LATIN CAPITAL LETTER Y WITH ACUTE + __ @ + _/_/_@ + \ V /@ + |_| @ + @@ +222 LATIN CAPITAL LETTER THORN + _ @ + | |_ @ + | -_)@ + |_| @ + @@ +223 LATIN SMALL LETTER SHARP S + ___ @ + / _ \@ + | |< <@ + | ||_/@ + |_| @@ +224 LATIN SMALL LETTER A WITH GRAVE + __ @ + \_\_ @ + / _` |@ + \__,_|@ + @@ +225 LATIN SMALL LETTER A WITH ACUTE + __ @ + _/_/ @ + / _` |@ + \__,_|@ + @@ +226 LATIN SMALL LETTER A WITH CIRCUMFLEX + /\ @ + |/\| @ + / _` |@ + \__,_|@ + @@ +227 LATIN SMALL LETTER A WITH TILDE + /\/|@ + |/\/ @ + / _` |@ + \__,_|@ + @@ +228 LATIN SMALL LETTER A WITH DIAERESIS + _ _ @ + (_)(_)@ + / _` |@ + \__,_|@ + @@ +229 LATIN SMALL LETTER A WITH RING ABOVE + __ @ + (()) @ + / _` |@ + \__,_|@ + @@ +230 LATIN SMALL LETTER AE + @ + __ ___ @ + / _` -_)@ + \__,___|@ + @@ +231 LATIN SMALL LETTER C WITH CEDILLA + @ + __ @ + / _|@ + \__|@ + )_)@@ +232 LATIN SMALL LETTER E WITH GRAVE + __ @ + \_\ @ + / -_)@ + \___|@ + @@ +233 LATIN SMALL LETTER E WITH ACUTE + __ @ + /_/ @ + / -_)@ + \___|@ + @@ +234 LATIN SMALL LETTER E WITH CIRCUMFLEX + //\ @ + |/_\|@ + / -_)@ + \___|@ + @@ +235 LATIN SMALL LETTER E WITH DIAERESIS + _ _ @ + (_)_(_)@ + / -_) @ + \___| @ + @@ +236 LATIN SMALL LETTER I WITH GRAVE + __ @ + \_\@ + | |@ + |_|@ + @@ +237 LATIN SMALL LETTER I WITH ACUTE + __@ + /_/@ + | |@ + |_|@ + @@ +238 LATIN SMALL LETTER I WITH CIRCUMFLEX + //\ @ + |/_\|@ + | | @ + |_| @ + @@ +239 LATIN SMALL LETTER I WITH DIAERESIS + _ _ @ + (_)_(_)@ + | | @ + |_| @ + @@ +240 LATIN SMALL LETTER ETH + \\/\ @ + \/\\ @ + / _` |@ + \___/ @ + @@ +241 LATIN SMALL LETTER N WITH TILDE + /\/| @ + |/\/ @ + | ' \ @ + |_||_|@ + @@ +242 LATIN SMALL LETTER O WITH GRAVE + __ @ + \_\ @ + / _ \@ + \___/@ + @@ +243 LATIN SMALL LETTER O WITH ACUTE + __ @ + /_/ @ + / _ \@ + \___/@ + @@ +244 LATIN SMALL LETTER O WITH CIRCUMFLEX + //\ @ + |/_\|@ + / _ \@ + \___/@ + @@ +245 LATIN SMALL LETTER O WITH TILDE + /\/|@ + |/\/ @ + / _ \@ + \___/@ + @@ +246 LATIN SMALL LETTER O WITH DIAERESIS + _ _ @ + (_)_(_)@ + / _ \ @ + \___/ @ + @@ +247 DIVISION SIGN + _ @ + (_) @ + |___|@ + (_) @ + @@ +248 LATIN SMALL LETTER O WITH STROKE + @ + ___ @ + / //\@ + \//_/@ + @@ +249 LATIN SMALL LETTER U WITH GRAVE + __ @ + \_\_ @ + | || |@ + \_,_|@ + @@ +250 LATIN SMALL LETTER U WITH ACUTE + __ @ + _/_/ @ + | || |@ + \_,_|@ + @@ +251 LATIN SMALL LETTER U WITH CIRCUMFLEX + /\ @ + |/\| @ + | || |@ + \_,_|@ + @@ +252 LATIN SMALL LETTER U WITH DIAERESIS + _ _ @ + (_)(_)@ + | || |@ + \_,_|@ + @@ +253 LATIN SMALL LETTER Y WITH ACUTE + __ @ + _/_/ @ + | || |@ + \_, |@ + |__/ @@ +254 LATIN SMALL LETTER THORN + _ @ + | |__ @ + | '_ \@ + | .__/@ + |_| @@ +255 LATIN SMALL LETTER Y WITH DIAERESIS + _ _ @ + (_)(_)@ + | || |@ + \_, |@ + |__/ @@ diff --git a/src/Symfony/Component/Tui/Widget/Figlet/fonts/standard.flf b/src/Symfony/Component/Tui/Widget/Figlet/fonts/standard.flf new file mode 100644 index 0000000000000..bb15241b0aef4 --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/Figlet/fonts/standard.flf @@ -0,0 +1,2238 @@ +flf2a$ 6 5 16 15 15 0 24463 229 +Standard by Glenn Chappell & Ian Chai 3/93 -- based on Frank's .sig +Includes ISO Latin-1 +figlet release 2.1 -- 12 Aug 1994 +Modified for figlet 2.2 by John Cowan + to add Latin-{2,3,4,5} support (Unicode U+0100-017F). +Permission is hereby given to modify this font, as long as the +modifier's name is placed on a comment line. + +--- + +Modified by Paul Burton 12/96 to include new parameter +supported by FIGlet and FIGWin. May also be slightly modified for better use +of new full-width/kern/smush alternatives, but default output is NOT changed. + +Modified 2012-05 by Patrick Gillespie (patorjk@gmail.com) to add the 0xCA0 character. + $@ + $@ + $@ + $@ + $@ + $@@ + _ @ + | |@ + | |@ + |_|@ + (_)@ + @@ + _ _ @ + ( | )@ + V V @ + $ @ + $ @ + @@ + _ _ @ + _| || |_ @ + |_ .. _|@ + |_ _|@ + |_||_| @ + @@ + _ @ + | | @ + / __)@ + \__ \@ + ( /@ + |_| @@ + _ __@ + (_)/ /@ + / / @ + / /_ @ + /_/(_)@ + @@ + ___ @ + ( _ ) @ + / _ \/\@ + | (_> <@ + \___/\/@ + @@ + _ @ + ( )@ + |/ @ + $ @ + $ @ + @@ + __@ + / /@ + | | @ + | | @ + | | @ + \_\@@ + __ @ + \ \ @ + | |@ + | |@ + | |@ + /_/ @@ + @ + __/\__@ + \ /@ + /_ _\@ + \/ @ + @@ + @ + _ @ + _| |_ @ + |_ _|@ + |_| @ + @@ + @ + @ + @ + _ @ + ( )@ + |/ @@ + @ + @ + _____ @ + |_____|@ + $ @ + @@ + @ + @ + @ + _ @ + (_)@ + @@ + __@ + / /@ + / / @ + / / @ + /_/ @ + @@ + ___ @ + / _ \ @ + | | | |@ + | |_| |@ + \___/ @ + @@ + _ @ + / |@ + | |@ + | |@ + |_|@ + @@ + ____ @ + |___ \ @ + __) |@ + / __/ @ + |_____|@ + @@ + _____ @ + |___ / @ + |_ \ @ + ___) |@ + |____/ @ + @@ + _ _ @ + | || | @ + | || |_ @ + |__ _|@ + |_| @ + @@ + ____ @ + | ___| @ + |___ \ @ + ___) |@ + |____/ @ + @@ + __ @ + / /_ @ + | '_ \ @ + | (_) |@ + \___/ @ + @@ + _____ @ + |___ |@ + / / @ + / / @ + /_/ @ + @@ + ___ @ + ( _ ) @ + / _ \ @ + | (_) |@ + \___/ @ + @@ + ___ @ + / _ \ @ + | (_) |@ + \__, |@ + /_/ @ + @@ + @ + _ @ + (_)@ + _ @ + (_)@ + @@ + @ + _ @ + (_)@ + _ @ + ( )@ + |/ @@ + __@ + / /@ + / / @ + \ \ @ + \_\@ + @@ + @ + _____ @ + |_____|@ + |_____|@ + $ @ + @@ + __ @ + \ \ @ + \ \@ + / /@ + /_/ @ + @@ + ___ @ + |__ \@ + / /@ + |_| @ + (_) @ + @@ + ____ @ + / __ \ @ + / / _` |@ + | | (_| |@ + \ \__,_|@ + \____/ @@ + _ @ + / \ @ + / _ \ @ + / ___ \ @ + /_/ \_\@ + @@ + ____ @ + | __ ) @ + | _ \ @ + | |_) |@ + |____/ @ + @@ + ____ @ + / ___|@ + | | @ + | |___ @ + \____|@ + @@ + ____ @ + | _ \ @ + | | | |@ + | |_| |@ + |____/ @ + @@ + _____ @ + | ____|@ + | _| @ + | |___ @ + |_____|@ + @@ + _____ @ + | ___|@ + | |_ @ + | _| @ + |_| @ + @@ + ____ @ + / ___|@ + | | _ @ + | |_| |@ + \____|@ + @@ + _ _ @ + | | | |@ + | |_| |@ + | _ |@ + |_| |_|@ + @@ + ___ @ + |_ _|@ + | | @ + | | @ + |___|@ + @@ + _ @ + | |@ + _ | |@ + | |_| |@ + \___/ @ + @@ + _ __@ + | |/ /@ + | ' / @ + | . \ @ + |_|\_\@ + @@ + _ @ + | | @ + | | @ + | |___ @ + |_____|@ + @@ + __ __ @ + | \/ |@ + | |\/| |@ + | | | |@ + |_| |_|@ + @@ + _ _ @ + | \ | |@ + | \| |@ + | |\ |@ + |_| \_|@ + @@ + ___ @ + / _ \ @ + | | | |@ + | |_| |@ + \___/ @ + @@ + ____ @ + | _ \ @ + | |_) |@ + | __/ @ + |_| @ + @@ + ___ @ + / _ \ @ + | | | |@ + | |_| |@ + \__\_\@ + @@ + ____ @ + | _ \ @ + | |_) |@ + | _ < @ + |_| \_\@ + @@ + ____ @ + / ___| @ + \___ \ @ + ___) |@ + |____/ @ + @@ + _____ @ + |_ _|@ + | | @ + | | @ + |_| @ + @@ + _ _ @ + | | | |@ + | | | |@ + | |_| |@ + \___/ @ + @@ + __ __@ + \ \ / /@ + \ \ / / @ + \ V / @ + \_/ @ + @@ + __ __@ + \ \ / /@ + \ \ /\ / / @ + \ V V / @ + \_/\_/ @ + @@ + __ __@ + \ \/ /@ + \ / @ + / \ @ + /_/\_\@ + @@ + __ __@ + \ \ / /@ + \ V / @ + | | @ + |_| @ + @@ + _____@ + |__ /@ + / / @ + / /_ @ + /____|@ + @@ + __ @ + | _|@ + | | @ + | | @ + | | @ + |__|@@ + __ @ + \ \ @ + \ \ @ + \ \ @ + \_\@ + @@ + __ @ + |_ |@ + | |@ + | |@ + | |@ + |__|@@ + /\ @ + |/\|@ + $ @ + $ @ + $ @ + @@ + @ + @ + @ + @ + _____ @ + |_____|@@ + _ @ + ( )@ + \|@ + $ @ + $ @ + @@ + @ + __ _ @ + / _` |@ + | (_| |@ + \__,_|@ + @@ + _ @ + | |__ @ + | '_ \ @ + | |_) |@ + |_.__/ @ + @@ + @ + ___ @ + / __|@ + | (__ @ + \___|@ + @@ + _ @ + __| |@ + / _` |@ + | (_| |@ + \__,_|@ + @@ + @ + ___ @ + / _ \@ + | __/@ + \___|@ + @@ + __ @ + / _|@ + | |_ @ + | _|@ + |_| @ + @@ + @ + __ _ @ + / _` |@ + | (_| |@ + \__, |@ + |___/ @@ + _ @ + | |__ @ + | '_ \ @ + | | | |@ + |_| |_|@ + @@ + _ @ + (_)@ + | |@ + | |@ + |_|@ + @@ + _ @ + (_)@ + | |@ + | |@ + _/ |@ + |__/ @@ + _ @ + | | __@ + | |/ /@ + | < @ + |_|\_\@ + @@ + _ @ + | |@ + | |@ + | |@ + |_|@ + @@ + @ + _ __ ___ @ + | '_ ` _ \ @ + | | | | | |@ + |_| |_| |_|@ + @@ + @ + _ __ @ + | '_ \ @ + | | | |@ + |_| |_|@ + @@ + @ + ___ @ + / _ \ @ + | (_) |@ + \___/ @ + @@ + @ + _ __ @ + | '_ \ @ + | |_) |@ + | .__/ @ + |_| @@ + @ + __ _ @ + / _` |@ + | (_| |@ + \__, |@ + |_|@@ + @ + _ __ @ + | '__|@ + | | @ + |_| @ + @@ + @ + ___ @ + / __|@ + \__ \@ + |___/@ + @@ + _ @ + | |_ @ + | __|@ + | |_ @ + \__|@ + @@ + @ + _ _ @ + | | | |@ + | |_| |@ + \__,_|@ + @@ + @ + __ __@ + \ \ / /@ + \ V / @ + \_/ @ + @@ + @ + __ __@ + \ \ /\ / /@ + \ V V / @ + \_/\_/ @ + @@ + @ + __ __@ + \ \/ /@ + > < @ + /_/\_\@ + @@ + @ + _ _ @ + | | | |@ + | |_| |@ + \__, |@ + |___/ @@ + @ + ____@ + |_ /@ + / / @ + /___|@ + @@ + __@ + / /@ + | | @ + < < @ + | | @ + \_\@@ + _ @ + | |@ + | |@ + | |@ + | |@ + |_|@@ + __ @ + \ \ @ + | | @ + > >@ + | | @ + /_/ @@ + /\/|@ + |/\/ @ + $ @ + $ @ + $ @ + @@ + _ _ @ + (_)_(_)@ + /_\ @ + / _ \ @ + /_/ \_\@ + @@ + _ _ @ + (_)_(_)@ + / _ \ @ + | |_| |@ + \___/ @ + @@ + _ _ @ + (_) (_)@ + | | | |@ + | |_| |@ + \___/ @ + @@ + _ _ @ + (_)_(_)@ + / _` |@ + | (_| |@ + \__,_|@ + @@ + _ _ @ + (_)_(_)@ + / _ \ @ + | (_) |@ + \___/ @ + @@ + _ _ @ + (_) (_)@ + | | | |@ + | |_| |@ + \__,_|@ + @@ + ___ @ + / _ \@ + | |/ /@ + | |\ \@ + | ||_/@ + |_| @@ +160 NO-BREAK SPACE + $@ + $@ + $@ + $@ + $@ + $@@ +161 INVERTED EXCLAMATION MARK + _ @ + (_)@ + | |@ + | |@ + |_|@ + @@ +162 CENT SIGN + _ @ + | | @ + / __)@ + | (__ @ + \ )@ + |_| @@ +163 POUND SIGN + ___ @ + / ,_\ @ + _| |_ @ + | |___ @ + (_,____|@ + @@ +164 CURRENCY SIGN + /\___/\@ + \ _ /@ + | (_) |@ + / ___ \@ + \/ \/@ + @@ +165 YEN SIGN + __ __ @ + \ V / @ + |__ __|@ + |__ __|@ + |_| @ + @@ +166 BROKEN BAR + _ @ + | |@ + |_|@ + _ @ + | |@ + |_|@@ +167 SECTION SIGN + __ @ + _/ _)@ + / \ \ @ + \ \\ \@ + \ \_/@ + (__/ @@ +168 DIAERESIS + _ _ @ + (_) (_)@ + $ $ @ + $ $ @ + $ $ @ + @@ +169 COPYRIGHT SIGN + _____ @ + / ___ \ @ + / / __| \ @ + | | (__ |@ + \ \___| / @ + \_____/ @@ +170 FEMININE ORDINAL INDICATOR + __ _ @ + / _` |@ + \__,_|@ + |____|@ + $ @ + @@ +171 LEFT-POINTING DOUBLE ANGLE QUOTATION MARK + ____@ + / / /@ + / / / @ + \ \ \ @ + \_\_\@ + @@ +172 NOT SIGN + @ + _____ @ + |___ |@ + |_|@ + $ @ + @@ +173 SOFT HYPHEN + @ + @ + ____ @ + |____|@ + $ @ + @@ +174 REGISTERED SIGN + _____ @ + / ___ \ @ + / | _ \ \ @ + | | / |@ + \ |_|_\ / @ + \_____/ @@ +175 MACRON + _____ @ + |_____|@ + $ @ + $ @ + $ @ + @@ +176 DEGREE SIGN + __ @ + / \ @ + | () |@ + \__/ @ + $ @ + @@ +177 PLUS-MINUS SIGN + _ @ + _| |_ @ + |_ _|@ + _|_|_ @ + |_____|@ + @@ +178 SUPERSCRIPT TWO + ___ @ + |_ )@ + / / @ + /___|@ + $ @ + @@ +179 SUPERSCRIPT THREE + ____@ + |__ /@ + |_ \@ + |___/@ + $ @ + @@ +180 ACUTE ACCENT + __@ + /_/@ + $ @ + $ @ + $ @ + @@ +181 MICRO SIGN + @ + _ _ @ + | | | |@ + | |_| |@ + | ._,_|@ + |_| @@ +182 PILCROW SIGN + _____ @ + / |@ + | (| | |@ + \__ | |@ + |_|_|@ + @@ +183 MIDDLE DOT + @ + _ @ + (_)@ + $ @ + $ @ + @@ +184 CEDILLA + @ + @ + @ + @ + _ @ + )_)@@ +185 SUPERSCRIPT ONE + _ @ + / |@ + | |@ + |_|@ + $ @ + @@ +186 MASCULINE ORDINAL INDICATOR + ___ @ + / _ \@ + \___/@ + |___|@ + $ @ + @@ +187 RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK + ____ @ + \ \ \ @ + \ \ \@ + / / /@ + /_/_/ @ + @@ +188 VULGAR FRACTION ONE QUARTER + _ __ @ + / | / / _ @ + | |/ / | | @ + |_/ /|_ _|@ + /_/ |_| @ + @@ +189 VULGAR FRACTION ONE HALF + _ __ @ + / | / /__ @ + | |/ /_ )@ + |_/ / / / @ + /_/ /___|@ + @@ +190 VULGAR FRACTION THREE QUARTERS + ____ __ @ + |__ / / / _ @ + |_ \/ / | | @ + |___/ /|_ _|@ + /_/ |_| @ + @@ +191 INVERTED QUESTION MARK + _ @ + (_) @ + | | @ + / /_ @ + \___|@ + @@ +192 LATIN CAPITAL LETTER A WITH GRAVE + __ @ + \_\ @ + /_\ @ + / _ \ @ + /_/ \_\@ + @@ +193 LATIN CAPITAL LETTER A WITH ACUTE + __ @ + /_/ @ + /_\ @ + / _ \ @ + /_/ \_\@ + @@ +194 LATIN CAPITAL LETTER A WITH CIRCUMFLEX + //\ @ + |/_\| @ + /_\ @ + / _ \ @ + /_/ \_\@ + @@ +195 LATIN CAPITAL LETTER A WITH TILDE + /\/| @ + |/\/ @ + /_\ @ + / _ \ @ + /_/ \_\@ + @@ +196 LATIN CAPITAL LETTER A WITH DIAERESIS + _ _ @ + (_)_(_)@ + /_\ @ + / _ \ @ + /_/ \_\@ + @@ +197 LATIN CAPITAL LETTER A WITH RING ABOVE + _ @ + (o) @ + /_\ @ + / _ \ @ + /_/ \_\@ + @@ +198 LATIN CAPITAL LETTER AE + ______ @ + / ____|@ + / _ _| @ + / __ |___ @ + /_/ |_____|@ + @@ +199 LATIN CAPITAL LETTER C WITH CEDILLA + ____ @ + / ___|@ + | | @ + | |___ @ + \____|@ + )_) @@ +200 LATIN CAPITAL LETTER E WITH GRAVE + __ @ + _\_\_ @ + | ____|@ + | _|_ @ + |_____|@ + @@ +201 LATIN CAPITAL LETTER E WITH ACUTE + __ @ + _/_/_ @ + | ____|@ + | _|_ @ + |_____|@ + @@ +202 LATIN CAPITAL LETTER E WITH CIRCUMFLEX + //\ @ + |/_\| @ + | ____|@ + | _|_ @ + |_____|@ + @@ +203 LATIN CAPITAL LETTER E WITH DIAERESIS + _ _ @ + (_)_(_)@ + | ____|@ + | _|_ @ + |_____|@ + @@ +204 LATIN CAPITAL LETTER I WITH GRAVE + __ @ + \_\ @ + |_ _|@ + | | @ + |___|@ + @@ +205 LATIN CAPITAL LETTER I WITH ACUTE + __ @ + /_/ @ + |_ _|@ + | | @ + |___|@ + @@ +206 LATIN CAPITAL LETTER I WITH CIRCUMFLEX + //\ @ + |/_\|@ + |_ _|@ + | | @ + |___|@ + @@ +207 LATIN CAPITAL LETTER I WITH DIAERESIS + _ _ @ + (_)_(_)@ + |_ _| @ + | | @ + |___| @ + @@ +208 LATIN CAPITAL LETTER ETH + ____ @ + | _ \ @ + _| |_| |@ + |__ __| |@ + |____/ @ + @@ +209 LATIN CAPITAL LETTER N WITH TILDE + /\/|@ + |/\/ @ + | \| |@ + | .` |@ + |_|\_|@ + @@ +210 LATIN CAPITAL LETTER O WITH GRAVE + __ @ + \_\ @ + / _ \ @ + | |_| |@ + \___/ @ + @@ +211 LATIN CAPITAL LETTER O WITH ACUTE + __ @ + /_/ @ + / _ \ @ + | |_| |@ + \___/ @ + @@ +212 LATIN CAPITAL LETTER O WITH CIRCUMFLEX + //\ @ + |/_\| @ + / _ \ @ + | |_| |@ + \___/ @ + @@ +213 LATIN CAPITAL LETTER O WITH TILDE + /\/| @ + |/\/ @ + / _ \ @ + | |_| |@ + \___/ @ + @@ +214 LATIN CAPITAL LETTER O WITH DIAERESIS + _ _ @ + (_)_(_)@ + / _ \ @ + | |_| |@ + \___/ @ + @@ +215 MULTIPLICATION SIGN + @ + @ + /\/\@ + > <@ + \/\/@ + @@ +216 LATIN CAPITAL LETTER O WITH STROKE + ____ @ + / _// @ + | |// |@ + | //| |@ + //__/ @ + @@ +217 LATIN CAPITAL LETTER U WITH GRAVE + __ @ + _\_\_ @ + | | | |@ + | |_| |@ + \___/ @ + @@ +218 LATIN CAPITAL LETTER U WITH ACUTE + __ @ + _/_/_ @ + | | | |@ + | |_| |@ + \___/ @ + @@ +219 LATIN CAPITAL LETTER U WITH CIRCUMFLEX + //\ @ + |/ \| @ + | | | |@ + | |_| |@ + \___/ @ + @@ +220 LATIN CAPITAL LETTER U WITH DIAERESIS + _ _ @ + (_) (_)@ + | | | |@ + | |_| |@ + \___/ @ + @@ +221 LATIN CAPITAL LETTER Y WITH ACUTE + __ @ + __/_/__@ + \ \ / /@ + \ V / @ + |_| @ + @@ +222 LATIN CAPITAL LETTER THORN + _ @ + | |___ @ + | __ \@ + | ___/@ + |_| @ + @@ +223 LATIN SMALL LETTER SHARP S + ___ @ + / _ \@ + | |/ /@ + | |\ \@ + | ||_/@ + |_| @@ +224 LATIN SMALL LETTER A WITH GRAVE + __ @ + \_\_ @ + / _` |@ + | (_| |@ + \__,_|@ + @@ +225 LATIN SMALL LETTER A WITH ACUTE + __ @ + /_/_ @ + / _` |@ + | (_| |@ + \__,_|@ + @@ +226 LATIN SMALL LETTER A WITH CIRCUMFLEX + //\ @ + |/_\| @ + / _` |@ + | (_| |@ + \__,_|@ + @@ +227 LATIN SMALL LETTER A WITH TILDE + /\/| @ + |/\/_ @ + / _` |@ + | (_| |@ + \__,_|@ + @@ +228 LATIN SMALL LETTER A WITH DIAERESIS + _ _ @ + (_)_(_)@ + / _` |@ + | (_| |@ + \__,_|@ + @@ +229 LATIN SMALL LETTER A WITH RING ABOVE + __ @ + (()) @ + / _ '|@ + | (_| |@ + \__,_|@ + @@ +230 LATIN SMALL LETTER AE + @ + __ ____ @ + / _` _ \@ + | (_| __/@ + \__,____|@ + @@ +231 LATIN SMALL LETTER C WITH CEDILLA + @ + ___ @ + / __|@ + | (__ @ + \___|@ + )_) @@ +232 LATIN SMALL LETTER E WITH GRAVE + __ @ + \_\ @ + / _ \@ + | __/@ + \___|@ + @@ +233 LATIN SMALL LETTER E WITH ACUTE + __ @ + /_/ @ + / _ \@ + | __/@ + \___|@ + @@ +234 LATIN SMALL LETTER E WITH CIRCUMFLEX + //\ @ + |/_\|@ + / _ \@ + | __/@ + \___|@ + @@ +235 LATIN SMALL LETTER E WITH DIAERESIS + _ _ @ + (_)_(_)@ + / _ \ @ + | __/ @ + \___| @ + @@ +236 LATIN SMALL LETTER I WITH GRAVE + __ @ + \_\@ + | |@ + | |@ + |_|@ + @@ +237 LATIN SMALL LETTER I WITH ACUTE + __@ + /_/@ + | |@ + | |@ + |_|@ + @@ +238 LATIN SMALL LETTER I WITH CIRCUMFLEX + //\ @ + |/_\|@ + | | @ + | | @ + |_| @ + @@ +239 LATIN SMALL LETTER I WITH DIAERESIS + _ _ @ + (_)_(_)@ + | | @ + | | @ + |_| @ + @@ +240 LATIN SMALL LETTER ETH + /\/\ @ + > < @ + _\/\ |@ + / __` |@ + \____/ @ + @@ +241 LATIN SMALL LETTER N WITH TILDE + /\/| @ + |/\/ @ + | '_ \ @ + | | | |@ + |_| |_|@ + @@ +242 LATIN SMALL LETTER O WITH GRAVE + __ @ + \_\ @ + / _ \ @ + | (_) |@ + \___/ @ + @@ +243 LATIN SMALL LETTER O WITH ACUTE + __ @ + /_/ @ + / _ \ @ + | (_) |@ + \___/ @ + @@ +244 LATIN SMALL LETTER O WITH CIRCUMFLEX + //\ @ + |/_\| @ + / _ \ @ + | (_) |@ + \___/ @ + @@ +245 LATIN SMALL LETTER O WITH TILDE + /\/| @ + |/\/ @ + / _ \ @ + | (_) |@ + \___/ @ + @@ +246 LATIN SMALL LETTER O WITH DIAERESIS + _ _ @ + (_)_(_)@ + / _ \ @ + | (_) |@ + \___/ @ + @@ +247 DIVISION SIGN + @ + _ @ + _(_)_ @ + |_____|@ + (_) @ + @@ +248 LATIN SMALL LETTER O WITH STROKE + @ + ____ @ + / _//\ @ + | (//) |@ + \//__/ @ + @@ +249 LATIN SMALL LETTER U WITH GRAVE + __ @ + _\_\_ @ + | | | |@ + | |_| |@ + \__,_|@ + @@ +250 LATIN SMALL LETTER U WITH ACUTE + __ @ + _/_/_ @ + | | | |@ + | |_| |@ + \__,_|@ + @@ +251 LATIN SMALL LETTER U WITH CIRCUMFLEX + //\ @ + |/ \| @ + | | | |@ + | |_| |@ + \__,_|@ + @@ +252 LATIN SMALL LETTER U WITH DIAERESIS + _ _ @ + (_) (_)@ + | | | |@ + | |_| |@ + \__,_|@ + @@ +253 LATIN SMALL LETTER Y WITH ACUTE + __ @ + _/_/_ @ + | | | |@ + | |_| |@ + \__, |@ + |___/ @@ +254 LATIN SMALL LETTER THORN + _ @ + | |__ @ + | '_ \ @ + | |_) |@ + | .__/ @ + |_| @@ +255 LATIN SMALL LETTER Y WITH DIAERESIS + _ _ @ + (_) (_)@ + | | | |@ + | |_| |@ + \__, |@ + |___/ @@ +0x0100 LATIN CAPITAL LETTER A WITH MACRON + ____ @ + /___/ @ + /_\ @ + / _ \ @ + /_/ \_\@ + @@ +0x0101 LATIN SMALL LETTER A WITH MACRON + ___ @ + /_ _/@ + / _` |@ + | (_| |@ + \__,_|@ + @@ +0x0102 LATIN CAPITAL LETTER A WITH BREVE + _ _ @ + \\_// @ + /_\ @ + / _ \ @ + /_/ \_\@ + @@ +0x0103 LATIN SMALL LETTER A WITH BREVE + \_/ @ + ___ @ + / _` |@ + | (_| |@ + \__,_|@ + @@ +0x0104 LATIN CAPITAL LETTER A WITH OGONEK + @ + _ @ + /_\ @ + / _ \ @ + /_/ \_\@ + (_(@@ +0x0105 LATIN SMALL LETTER A WITH OGONEK + @ + __ _ @ + / _` |@ + | (_| |@ + \__,_|@ + (_(@@ +0x0106 LATIN CAPITAL LETTER C WITH ACUTE + __ @ + _/_/ @ + / ___|@ + | |___ @ + \____|@ + @@ +0x0107 LATIN SMALL LETTER C WITH ACUTE + __ @ + /__/@ + / __|@ + | (__ @ + \___|@ + @@ +0x0108 LATIN CAPITAL LETTER C WITH CIRCUMFLEX + /\ @ + _//\\@ + / ___|@ + | |___ @ + \____|@ + @@ +0x0109 LATIN SMALL LETTER C WITH CIRCUMFLEX + /\ @ + /_\ @ + / __|@ + | (__ @ + \___|@ + @@ +0x010A LATIN CAPITAL LETTER C WITH DOT ABOVE + [] @ + ____ @ + / ___|@ + | |___ @ + \____|@ + @@ +0x010B LATIN SMALL LETTER C WITH DOT ABOVE + [] @ + ___ @ + / __|@ + | (__ @ + \___|@ + @@ +0x010C LATIN CAPITAL LETTER C WITH CARON + \\// @ + _\/_ @ + / ___|@ + | |___ @ + \____|@ + @@ +0x010D LATIN SMALL LETTER C WITH CARON + \\//@ + _\/ @ + / __|@ + | (__ @ + \___|@ + @@ +0x010E LATIN CAPITAL LETTER D WITH CARON + \\// @ + __\/ @ + | _ \ @ + | |_| |@ + |____/ @ + @@ +0x010F LATIN SMALL LETTER D WITH CARON + \/ _ @ + __| |@ + / _` |@ + | (_| |@ + \__,_|@ + @@ +0x0110 LATIN CAPITAL LETTER D WITH STROKE + ____ @ + |_ __ \ @ + /| |/ | |@ + /|_|/_| |@ + |_____/ @ + @@ +0x0111 LATIN SMALL LETTER D WITH STROKE + ---|@ + __| |@ + / _` |@ + | (_| |@ + \__,_|@ + @@ +0x0112 LATIN CAPITAL LETTER E WITH MACRON + ____ @ + /___/ @ + | ____|@ + | _|_ @ + |_____|@ + @@ +0x0113 LATIN SMALL LETTER E WITH MACRON + ____@ + /_ _/@ + / _ \ @ + | __/ @ + \___| @ + @@ +0x0114 LATIN CAPITAL LETTER E WITH BREVE + _ _ @ + \\_// @ + | ____|@ + | _|_ @ + |_____|@ + @@ +0x0115 LATIN SMALL LETTER E WITH BREVE + \\ //@ + -- @ + / _ \ @ + | __/ @ + \___| @ + @@ +0x0116 LATIN CAPITAL LETTER E WITH DOT ABOVE + [] @ + _____ @ + | ____|@ + | _|_ @ + |_____|@ + @@ +0x0117 LATIN SMALL LETTER E WITH DOT ABOVE + [] @ + __ @ + / _ \@ + | __/@ + \___|@ + @@ +0x0118 LATIN CAPITAL LETTER E WITH OGONEK + @ + _____ @ + | ____|@ + | _|_ @ + |_____|@ + (__(@@ +0x0119 LATIN SMALL LETTER E WITH OGONEK + @ + ___ @ + / _ \@ + | __/@ + \___|@ + (_(@@ +0x011A LATIN CAPITAL LETTER E WITH CARON + \\// @ + __\/_ @ + | ____|@ + | _|_ @ + |_____|@ + @@ +0x011B LATIN SMALL LETTER E WITH CARON + \\//@ + \/ @ + / _ \@ + | __/@ + \___|@ + @@ +0x011C LATIN CAPITAL LETTER G WITH CIRCUMFLEX + _/\_ @ + / ___|@ + | | _ @ + | |_| |@ + \____|@ + @@ +0x011D LATIN SMALL LETTER G WITH CIRCUMFLEX + /\ @ + _/_ \@ + / _` |@ + | (_| |@ + \__, |@ + |___/ @@ +0x011E LATIN CAPITAL LETTER G WITH BREVE + _\/_ @ + / ___|@ + | | _ @ + | |_| |@ + \____|@ + @@ +0x011F LATIN SMALL LETTER G WITH BREVE + \___/ @ + __ _ @ + / _` |@ + | (_| |@ + \__, |@ + |___/ @@ +0x0120 LATIN CAPITAL LETTER G WITH DOT ABOVE + _[]_ @ + / ___|@ + | | _ @ + | |_| |@ + \____|@ + @@ +0x0121 LATIN SMALL LETTER G WITH DOT ABOVE + [] @ + __ _ @ + / _` |@ + | (_| |@ + \__, |@ + |___/ @@ +0x0122 LATIN CAPITAL LETTER G WITH CEDILLA + ____ @ + / ___|@ + | | _ @ + | |_| |@ + \____|@ + )__) @@ +0x0123 LATIN SMALL LETTER G WITH CEDILLA + @ + __ _ @ + / _` |@ + | (_| |@ + \__, |@ + |_))))@@ +0x0124 LATIN CAPITAL LETTER H WITH CIRCUMFLEX + _/ \_ @ + | / \ |@ + | |_| |@ + | _ |@ + |_| |_|@ + @@ +0x0125 LATIN SMALL LETTER H WITH CIRCUMFLEX + _ /\ @ + | |//\ @ + | '_ \ @ + | | | |@ + |_| |_|@ + @@ +0x0126 LATIN CAPITAL LETTER H WITH STROKE + _ _ @ + | |=| |@ + | |_| |@ + | _ |@ + |_| |_|@ + @@ +0x0127 LATIN SMALL LETTER H WITH STROKE + _ @ + |=|__ @ + | '_ \ @ + | | | |@ + |_| |_|@ + @@ +0x0128 LATIN CAPITAL LETTER I WITH TILDE + /\//@ + |_ _|@ + | | @ + | | @ + |___|@ + @@ +0x0129 LATIN SMALL LETTER I WITH TILDE + @ + /\/@ + | |@ + | |@ + |_|@ + @@ +0x012A LATIN CAPITAL LETTER I WITH MACRON + /___/@ + |_ _|@ + | | @ + | | @ + |___|@ + @@ +0x012B LATIN SMALL LETTER I WITH MACRON + ____@ + /___/@ + | | @ + | | @ + |_| @ + @@ +0x012C LATIN CAPITAL LETTER I WITH BREVE + \__/@ + |_ _|@ + | | @ + | | @ + |___|@ + @@ +0x012D LATIN SMALL LETTER I WITH BREVE + @ + \_/@ + | |@ + | |@ + |_|@ + @@ +0x012E LATIN CAPITAL LETTER I WITH OGONEK + ___ @ + |_ _|@ + | | @ + | | @ + |___|@ + (__(@@ +0x012F LATIN SMALL LETTER I WITH OGONEK + _ @ + (_) @ + | | @ + | | @ + |_|_@ + (_(@@ +0x0130 LATIN CAPITAL LETTER I WITH DOT ABOVE + _[] @ + |_ _|@ + | | @ + | | @ + |___|@ + @@ +0x0131 LATIN SMALL LETTER DOTLESS I + @ + _ @ + | |@ + | |@ + |_|@ + @@ +0x0132 LATIN CAPITAL LIGATURE IJ + ___ _ @ + |_ _|| |@ + | | | |@ + | |_| |@ + |__|__/ @ + @@ +0x0133 LATIN SMALL LIGATURE IJ + _ _ @ + (_) (_)@ + | | | |@ + | | | |@ + |_|_/ |@ + |__/ @@ +0x0134 LATIN CAPITAL LETTER J WITH CIRCUMFLEX + /\ @ + /_\|@ + _ | | @ + | |_| | @ + \___/ @ + @@ +0x0135 LATIN SMALL LETTER J WITH CIRCUMFLEX + /\@ + /_\@ + | |@ + | |@ + _/ |@ + |__/ @@ +0x0136 LATIN CAPITAL LETTER K WITH CEDILLA + _ _ @ + | |/ / @ + | ' / @ + | . \ @ + |_|\_\ @ + )__)@@ +0x0137 LATIN SMALL LETTER K WITH CEDILLA + _ @ + | | __@ + | |/ /@ + | < @ + |_|\_\@ + )_)@@ +0x0138 LATIN SMALL LETTER KRA + @ + _ __ @ + | |/ \@ + | < @ + |_|\_\@ + @@ +0x0139 LATIN CAPITAL LETTER L WITH ACUTE + _ //@ + | | // @ + | | @ + | |___ @ + |_____|@ + @@ +0x013A LATIN SMALL LETTER L WITH ACUTE + //@ + | |@ + | |@ + | |@ + |_|@ + @@ +0x013B LATIN CAPITAL LETTER L WITH CEDILLA + _ @ + | | @ + | | @ + | |___ @ + |_____|@ + )__)@@ +0x013C LATIN SMALL LETTER L WITH CEDILLA + _ @ + | | @ + | | @ + | | @ + |_| @ + )_)@@ +0x013D LATIN CAPITAL LETTER L WITH CARON + _ \\//@ + | | \/ @ + | | @ + | |___ @ + |_____|@ + @@ +0x013E LATIN SMALL LETTER L WITH CARON + _ \\//@ + | | \/ @ + | | @ + | | @ + |_| @ + @@ +0x013F LATIN CAPITAL LETTER L WITH MIDDLE DOT + _ @ + | | @ + | | [] @ + | |___ @ + |_____|@ + @@ +0x0140 LATIN SMALL LETTER L WITH MIDDLE DOT + _ @ + | | @ + | | []@ + | | @ + |_| @ + @@ +0x0141 LATIN CAPITAL LETTER L WITH STROKE + __ @ + | // @ + |//| @ + // |__ @ + |_____|@ + @@ +0x0142 LATIN SMALL LETTER L WITH STROKE + _ @ + | |@ + |//@ + //|@ + |_|@ + @@ +0x0143 LATIN CAPITAL LETTER N WITH ACUTE + _/ /_ @ + | \ | |@ + | \| |@ + | |\ |@ + |_| \_|@ + @@ +0x0144 LATIN SMALL LETTER N WITH ACUTE + _ @ + _ /_/ @ + | '_ \ @ + | | | |@ + |_| |_|@ + @@ +0x0145 LATIN CAPITAL LETTER N WITH CEDILLA + _ _ @ + | \ | |@ + | \| |@ + | |\ |@ + |_| \_|@ + )_) @@ +0x0146 LATIN SMALL LETTER N WITH CEDILLA + @ + _ __ @ + | '_ \ @ + | | | |@ + |_| |_|@ + )_) @@ +0x0147 LATIN CAPITAL LETTER N WITH CARON + _\/ _ @ + | \ | |@ + | \| |@ + | |\ |@ + |_| \_|@ + @@ +0x0148 LATIN SMALL LETTER N WITH CARON + \\// @ + _\/_ @ + | '_ \ @ + | | | |@ + |_| |_|@ + @@ +0x0149 LATIN SMALL LETTER N PRECEDED BY APOSTROPHE + @ + _ __ @ + ( )| '_\ @ + |/| | | |@ + |_| |_|@ + @@ +0x014A LATIN CAPITAL LETTER ENG + _ _ @ + | \ | |@ + | \| |@ + | |\ |@ + |_| \ |@ + )_)@@ +0x014B LATIN SMALL LETTER ENG + _ __ @ + | '_ \ @ + | | | |@ + |_| | |@ + | |@ + |__ @@ +0x014C LATIN CAPITAL LETTER O WITH MACRON + ____ @ + /_ _/ @ + / _ \ @ + | (_) |@ + \___/ @ + @@ +0x014D LATIN SMALL LETTER O WITH MACRON + ____ @ + /_ _/ @ + / _ \ @ + | (_) |@ + \___/ @ + @@ +0x014E LATIN CAPITAL LETTER O WITH BREVE + \ / @ + _-_ @ + / _ \ @ + | |_| |@ + \___/ @ + @@ +0x014F LATIN SMALL LETTER O WITH BREVE + \ / @ + _-_ @ + / _ \ @ + | |_| |@ + \___/ @ + @@ +0x0150 LATIN CAPITAL LETTER O WITH DOUBLE ACUTE + ___ @ + /_/_/@ + / _ \ @ + | |_| |@ + \___/ @ + @@ +0x0151 LATIN SMALL LETTER O WITH DOUBLE ACUTE + ___ @ + /_/_/@ + / _ \ @ + | |_| |@ + \___/ @ + @@ +0x0152 LATIN CAPITAL LIGATURE OE + ___ ___ @ + / _ \| __|@ + | | | | | @ + | |_| | |__@ + \___/|____@ + @@ +0x0153 LATIN SMALL LIGATURE OE + @ + ___ ___ @ + / _ \ / _ \@ + | (_) | __/@ + \___/ \___|@ + @@ +0x0154 LATIN CAPITAL LETTER R WITH ACUTE + _/_/ @ + | _ \ @ + | |_) |@ + | _ < @ + |_| \_\@ + @@ +0x0155 LATIN SMALL LETTER R WITH ACUTE + __@ + _ /_/@ + | '__|@ + | | @ + |_| @ + @@ +0x0156 LATIN CAPITAL LETTER R WITH CEDILLA + ____ @ + | _ \ @ + | |_) |@ + | _ < @ + |_| \_\@ + )_) @@ +0x0157 LATIN SMALL LETTER R WITH CEDILLA + @ + _ __ @ + | '__|@ + | | @ + |_| @ + )_) @@ +0x0158 LATIN CAPITAL LETTER R WITH CARON + _\_/ @ + | _ \ @ + | |_) |@ + | _ < @ + |_| \_\@ + @@ +0x0159 LATIN SMALL LETTER R WITH CARON + \\// @ + _\/_ @ + | '__|@ + | | @ + |_| @ + @@ +0x015A LATIN CAPITAL LETTER S WITH ACUTE + _/_/ @ + / ___| @ + \___ \ @ + ___) |@ + |____/ @ + @@ +0x015B LATIN SMALL LETTER S WITH ACUTE + __@ + _/_/@ + / __|@ + \__ \@ + |___/@ + @@ +0x015C LATIN CAPITAL LETTER S WITH CIRCUMFLEX + _/\_ @ + / ___| @ + \___ \ @ + ___) |@ + |____/ @ + @@ +0x015D LATIN SMALL LETTER S WITH CIRCUMFLEX + @ + /_\_@ + / __|@ + \__ \@ + |___/@ + @@ +0x015E LATIN CAPITAL LETTER S WITH CEDILLA + ____ @ + / ___| @ + \___ \ @ + ___) |@ + |____/ @ + )__)@@ +0x015F LATIN SMALL LETTER S WITH CEDILLA + @ + ___ @ + / __|@ + \__ \@ + |___/@ + )_)@@ +0x0160 LATIN CAPITAL LETTER S WITH CARON + _\_/ @ + / ___| @ + \___ \ @ + ___) |@ + |____/ @ + @@ +0x0161 LATIN SMALL LETTER S WITH CARON + \\//@ + _\/ @ + / __|@ + \__ \@ + |___/@ + @@ +0x0162 LATIN CAPITAL LETTER T WITH CEDILLA + _____ @ + |_ _|@ + | | @ + | | @ + |_| @ + )__)@@ +0x0163 LATIN SMALL LETTER T WITH CEDILLA + _ @ + | |_ @ + | __|@ + | |_ @ + \__|@ + )_)@@ +0x0164 LATIN CAPITAL LETTER T WITH CARON + _____ @ + |_ _|@ + | | @ + | | @ + |_| @ + @@ +0x0165 LATIN SMALL LETTER T WITH CARON + \/ @ + | |_ @ + | __|@ + | |_ @ + \__|@ + @@ +0x0166 LATIN CAPITAL LETTER T WITH STROKE + _____ @ + |_ _|@ + | | @ + -|-|- @ + |_| @ + @@ +0x0167 LATIN SMALL LETTER T WITH STROKE + _ @ + | |_ @ + | __|@ + |-|_ @ + \__|@ + @@ +0x0168 LATIN CAPITAL LETTER U WITH TILDE + @ + _/\/_ @ + | | | |@ + | |_| |@ + \___/ @ + @@ +0x0169 LATIN SMALL LETTER U WITH TILDE + @ + _/\/_ @ + | | | |@ + | |_| |@ + \__,_|@ + @@ +0x016A LATIN CAPITAL LETTER U WITH MACRON + ____ @ + /__ _/@ + | | | |@ + | |_| |@ + \___/ @ + @@ +0x016B LATIN SMALL LETTER U WITH MACRON + ____ @ + / _ /@ + | | | |@ + | |_| |@ + \__,_|@ + @@ +0x016C LATIN CAPITAL LETTER U WITH BREVE + @ + \_/_ @ + | | | |@ + | |_| |@ + \____|@ + @@ +0x016D LATIN SMALL LETTER U WITH BREVE + @ + \_/_ @ + | | | |@ + | |_| |@ + \__,_|@ + @@ +0x016E LATIN CAPITAL LETTER U WITH RING ABOVE + O @ + __ _ @ + | | | |@ + | |_| |@ + \___/ @ + @@ +0x016F LATIN SMALL LETTER U WITH RING ABOVE + O @ + __ __ @ + | | | |@ + | |_| |@ + \__,_|@ + @@ +0x0170 LATIN CAPITAL LETTER U WITH DOUBLE ACUTE + -- --@ + /_//_/@ + | | | |@ + | |_| |@ + \___/ @ + @@ +0x0171 LATIN SMALL LETTER U WITH DOUBLE ACUTE + ____@ + _/_/_/@ + | | | |@ + | |_| |@ + \__,_|@ + @@ +0x0172 LATIN CAPITAL LETTER U WITH OGONEK + _ _ @ + | | | |@ + | | | |@ + | |_| |@ + \___/ @ + (__(@@ +0x0173 LATIN SMALL LETTER U WITH OGONEK + @ + _ _ @ + | | | |@ + | |_| |@ + \__,_|@ + (_(@@ +0x0174 LATIN CAPITAL LETTER W WITH CIRCUMFLEX + __ /\ __@ + \ \ //\\/ /@ + \ \ /\ / / @ + \ V V / @ + \_/\_/ @ + @@ +0x0175 LATIN SMALL LETTER W WITH CIRCUMFLEX + /\ @ + __ //\\__@ + \ \ /\ / /@ + \ V V / @ + \_/\_/ @ + @@ +0x0176 LATIN CAPITAL LETTER Y WITH CIRCUMFLEX + /\ @ + __//\\ @ + \ \ / /@ + \ V / @ + |_| @ + @@ +0x0177 LATIN SMALL LETTER Y WITH CIRCUMFLEX + /\ @ + //\\ @ + | | | |@ + | |_| |@ + \__, |@ + |___/ @@ +0x0178 LATIN CAPITAL LETTER Y WITH DIAERESIS + [] []@ + __ _@ + \ \ / /@ + \ V / @ + |_| @ + @@ +0x0179 LATIN CAPITAL LETTER Z WITH ACUTE + __/_/@ + |__ /@ + / / @ + / /_ @ + /____|@ + @@ +0x017A LATIN SMALL LETTER Z WITH ACUTE + _ @ + _/_/@ + |_ /@ + / / @ + /___|@ + @@ +0x017B LATIN CAPITAL LETTER Z WITH DOT ABOVE + __[]_@ + |__ /@ + / / @ + / /_ @ + /____|@ + @@ +0x017C LATIN SMALL LETTER Z WITH DOT ABOVE + [] @ + ____@ + |_ /@ + / / @ + /___|@ + @@ +0x017D LATIN CAPITAL LETTER Z WITH CARON + _\_/_@ + |__ /@ + / / @ + / /_ @ + /____|@ + @@ +0x017E LATIN SMALL LETTER Z WITH CARON + \\//@ + _\/_@ + |_ /@ + / / @ + /___|@ + @@ +0x017F LATIN SMALL LETTER LONG S + __ @ + / _|@ + |-| | @ + |-| | @ + |_| @ + @@ +0x02C7 CARON + \\//@ + \/ @ + $@ + $@ + $@ + $@@ +0x02D8 BREVE + \\_//@ + \_/ @ + $@ + $@ + $@ + $@@ +0x02D9 DOT ABOVE + []@ + $@ + $@ + $@ + $@ + $@@ +0x02DB OGONEK + $@ + $@ + $@ + $@ + $@ + )_) @@ +0x02DD DOUBLE ACUTE ACCENT + _ _ @ + /_/_/@ + $@ + $@ + $@ + $@@ +0xCA0 KANNADA LETTER TTHA + _____)@ + /_ ___/@ + / _ \ @ + | (_) | @ + $\___/$ @ + @@ diff --git a/src/Symfony/Component/Tui/Widget/FocusableInterface.php b/src/Symfony/Component/Tui/Widget/FocusableInterface.php new file mode 100644 index 0000000000000..10fb7160bb22d --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/FocusableInterface.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Input\Keybindings; + +/** + * Interface for widgets that can receive focus. + * + * Widgets that accept user input (text editors, inputs, lists) implement + * this interface so the focus manager can route keyboard events to them. + * + * Widgets that display a text cursor should emit + * {@see AnsiUtils::cursorMarker()} at the cursor position when focused + * so the terminal's hardware cursor handles blinking natively and IME + * candidate windows appear at the right spot. + * + * @experimental + * + * @author Fabien Potencier + */ +interface FocusableInterface +{ + /** + * Check if the widget currently has focus. + */ + public function isFocused(): bool; + + /** + * Set the focus state of the widget. + * + * @return $this + */ + public function setFocused(bool $focused): self; + + /** + * Register a callback invoked before handleInput(). + * + * The callback receives the raw input string and should return true + * to consume the event (preventing handleInput() from processing it) + * or false to let the widget handle it normally. + * + * @param (callable(string): bool)|null $callback + * + * @return $this + */ + public function onInput(?callable $callback): static; + + /** + * Handle keyboard/terminal input when focused. + */ + public function handleInput(string $data): void; + + /** + * Get the keybindings for this widget. + * + * Resolution order (later overrides earlier): + * 1. Widget defaults (from getDefaultKeybindings()) + * 2. Global keybindings from the TUI (via WidgetContext) + * 3. Explicit keybindings set on this widget (via setKeybindings()) + */ + public function getKeybindings(): Keybindings; + + /** + * Set explicit keybindings for this widget. + * + * When set, these keybindings take priority over the TUI's default. + * + * @return $this + */ + public function setKeybindings(?Keybindings $keybindings): static; +} diff --git a/src/Symfony/Component/Tui/Widget/FocusableTrait.php b/src/Symfony/Component/Tui/Widget/FocusableTrait.php new file mode 100644 index 0000000000000..2a6955c7e4aab --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/FocusableTrait.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +/** + * Default implementation of focus state for focusable widgets. + * + * Invalidates the widget when focus changes. Override setFocused() + * for custom behavior (e.g. cursor blinker management). + * + * @experimental + * + * @author Fabien Potencier + */ +trait FocusableTrait +{ + private bool $focused = false; + + public function isFocused(): bool + { + return $this->focused; + } + + /** + * @return $this + */ + public function setFocused(bool $focused): self + { + if ($this->focused !== $focused) { + $this->focused = $focused; + $this->invalidate(); + } + + return $this; + } +} diff --git a/src/Symfony/Component/Tui/Widget/InputWidget.php b/src/Symfony/Component/Tui/Widget/InputWidget.php new file mode 100644 index 0000000000000..f9f098a28a1ea --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/InputWidget.php @@ -0,0 +1,450 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Event\CancelEvent; +use Symfony\Component\Tui\Event\ChangeEvent; +use Symfony\Component\Tui\Event\SubmitEvent; +use Symfony\Component\Tui\Input\Key; +use Symfony\Component\Tui\Input\Keybindings; +use Symfony\Component\Tui\Render\RenderContext; +use Symfony\Component\Tui\Style\CursorShape; +use Symfony\Component\Tui\Widget\Util\Line; +use Symfony\Component\Tui\Widget\Util\StringUtils; + +/** + * Single-line text input with horizontal scrolling. + * + * @experimental + * + * @author Fabien Potencier + */ +class InputWidget extends AbstractWidget implements FocusableInterface +{ + use BracketedPasteTrait; + use FocusableTrait; + use KeybindingsTrait; + + private Line $line; + private string $prompt = '> '; + private bool $submitted = false; + + public function __construct( + ?Keybindings $keybindings = null, + ) { + if (null !== $keybindings) { + $this->setKeybindings($keybindings); + } + $this->line = new Line(); + } + + /** + * @param callable(SubmitEvent): void $callback + * + * @return $this + */ + public function onSubmit(callable $callback): self + { + return $this->on(SubmitEvent::class, $callback); + } + + /** + * @param callable(CancelEvent): void $callback + * + * @return $this + */ + public function onCancel(callable $callback): self + { + return $this->on(CancelEvent::class, $callback); + } + + /** + * @param callable(ChangeEvent): void $callback + * + * @return $this + */ + public function onChange(callable $callback): self + { + return $this->on(ChangeEvent::class, $callback); + } + + public function getValue(): string + { + return $this->line->getText(); + } + + /** + * Check if the input was submitted (Enter pressed) vs cancelled (Escape pressed). + */ + public function wasSubmitted(): bool + { + return $this->submitted; + } + + /** + * @return $this + */ + public function setPrompt(string $prompt): self + { + if ($this->prompt !== $prompt) { + $this->prompt = $prompt; + $this->invalidate(); + } + + return $this; + } + + /** + * @return $this + */ + public function setValue(string $value): self + { + // When setting a new value, move cursor to the end of the string + $newCursor = \strlen($value); + if ($this->line->getText() !== $value || $this->line->getCursor() !== $newCursor) { + $this->line->setText($value); + $this->line->setCursor($newCursor); + $this->invalidate(); + } + + return $this; + } + + public function setFocused(bool $focused): self + { + if ($this->focused !== $focused) { + $this->focused = $focused; + $this->invalidate(); + $this->getContext()?->requestRender(); + } + + return $this; + } + + public function handleInput(string $data): void + { + if (null !== $this->onInput && ($this->onInput)($data)) { + return; + } + + $beforeValue = $this->line->getText(); + $beforeCursor = $this->line->getCursor(); + + try { + // Handle bracketed paste mode + $pastedText = $this->processBracketedPaste($data); + if (null !== $pastedText) { + $this->handlePaste($pastedText); + if ('' === $data) { + return; + } + } elseif ($this->isBufferingPaste()) { + return; + } + + $kb = $this->getKeybindings(); + + // Cancel + if ($kb->matches($data, 'select_cancel')) { + $this->submitted = false; + $this->dispatch(new CancelEvent($this)); + + return; + } + + // Submit + if ($kb->matches($data, 'submit') || "\n" === $data) { + $this->submitted = true; + $this->dispatch(new SubmitEvent($this, $this->line->getText())); + + return; + } + + // Deletion (line-level, then word-level, then char-level) + if ($kb->matches($data, 'delete_to_line_start')) { + if ('' !== $this->line->deleteToStart()) { + $this->notifyChange(); + } + + return; + } + + if ($kb->matches($data, 'delete_to_line_end')) { + if ('' !== $this->line->deleteToEnd()) { + $this->notifyChange(); + } + + return; + } + + if ($kb->matches($data, 'delete_word_backward')) { + if ('' !== $this->line->deleteWordBackward()) { + $this->notifyChange(); + } + + return; + } + + if ($kb->matches($data, 'delete_char_backward')) { + if ($this->line->deleteCharBackward()) { + $this->notifyChange(); + } + + return; + } + + if ($kb->matches($data, 'delete_char_forward')) { + if ($this->line->deleteCharForward()) { + $this->notifyChange(); + } + + return; + } + + // Cursor movement + if ($kb->matches($data, 'cursor_left')) { + $this->line->moveCursorLeft(); + + return; + } + + if ($kb->matches($data, 'cursor_right')) { + $this->line->moveCursorRight(); + + return; + } + + if ($kb->matches($data, 'cursor_line_start')) { + $this->line->moveCursorToStart(); + + return; + } + + if ($kb->matches($data, 'cursor_line_end')) { + $this->line->moveCursorToEnd(); + + return; + } + + if ($kb->matches($data, 'cursor_word_left')) { + $this->line->moveWordBackward(); + + return; + } + + if ($kb->matches($data, 'cursor_word_right')) { + $this->line->moveWordForward(); + + return; + } + + // Regular character input + if (!StringUtils::hasControlChars($data)) { + $this->line->insert($data); + $this->notifyChange(); + } + } finally { + if ($this->line->getText() === $beforeValue && $this->line->getCursor() !== $beforeCursor) { + $this->invalidate(); + } + } + } + + /** + * @return string[] + */ + public function render(RenderContext $context): array + { + $columns = $context->getColumns(); + $prompt = $this->prompt; + $availableColumns = $columns - AnsiUtils::visibleWidth($prompt); + + if ($availableColumns <= 0) { + return [$prompt]; + } + + $value = $this->line->getText(); + $cursor = $this->line->getCursor(); + + // Split into graphemes for width-aware scrolling + $graphemes = grapheme_str_split($value) ?: []; + + // Find cursor grapheme index from byte offset + $cursorGraphemeIndex = \count($graphemes); + $bytePos = 0; + foreach ($graphemes as $i => $g) { + if ($cursor < $bytePos + \strlen($g)) { + $cursorGraphemeIndex = $i; + break; + } + $bytePos += \strlen($g); + } + + $totalWidth = AnsiUtils::visibleWidth($value); + + if ($totalWidth < $availableColumns) { + $visibleGraphemes = $graphemes; + $cursorVisibleIndex = $cursorGraphemeIndex; + } else { + // Horizontal scrolling in grapheme/display-width space + $atEnd = $cursorGraphemeIndex === \count($graphemes); + $scrollColumns = $atEnd ? $availableColumns - 1 : $availableColumns; + $halfColumns = (int) floor($scrollColumns / 2); + + // Measure display width of graphemes before cursor + $widthBeforeCursor = AnsiUtils::visibleWidth(implode('', \array_slice($graphemes, 0, $cursorGraphemeIndex))); + + if ($widthBeforeCursor < $halfColumns) { + // Cursor near start, take graphemes from the beginning that fit + $visibleGraphemes = self::takeGraphemesByWidth($graphemes, 0, $scrollColumns); + $cursorVisibleIndex = $cursorGraphemeIndex; + } elseif ($widthBeforeCursor > $totalWidth - $halfColumns) { + // Cursor near end, take graphemes from the end that fit + $visibleGraphemes = self::takeGraphemesFromEndByWidth($graphemes, $scrollColumns); + $startIndex = \count($graphemes) - \count($visibleGraphemes); + $cursorVisibleIndex = $cursorGraphemeIndex - $startIndex; + } else { + // Cursor in middle, center around cursor + [$visibleGraphemes, $startIndex] = self::takeGraphemesCenteredByWidth($graphemes, $cursorGraphemeIndex, $scrollColumns); + $cursorVisibleIndex = $cursorGraphemeIndex - $startIndex; + } + } + + // Build before/at/after cursor from visible graphemes + $beforeCursor = implode('', \array_slice($visibleGraphemes, 0, $cursorVisibleIndex)); + $atCursor = $visibleGraphemes[$cursorVisibleIndex] ?? ' '; + $afterCursor = implode('', \array_slice($visibleGraphemes, $cursorVisibleIndex + 1)); + + $cursorStyle = $this->resolveElement('cursor'); + $marker = $this->focused ? AnsiUtils::cursorMarker($cursorStyle->getCursorShape() ?? CursorShape::Block) : ''; + $textWithCursor = $beforeCursor.$marker.$atCursor.$afterCursor; + + // Pad to width + $visualLength = AnsiUtils::visibleWidth($textWithCursor); + $padding = str_repeat(' ', max(0, $availableColumns - $visualLength)); + + $line = $prompt.$textWithCursor.$padding; + + return [$line]; + } + + /** + * @return array + */ + protected static function getDefaultKeybindings(): array + { + return [ + 'cursor_left' => [Key::LEFT, 'ctrl+b'], + 'cursor_right' => [Key::RIGHT, 'ctrl+f'], + 'cursor_word_left' => ['alt+left', 'ctrl+left', 'alt+b'], + 'cursor_word_right' => ['alt+right', 'ctrl+right', 'alt+f'], + 'cursor_line_start' => [Key::HOME, 'ctrl+a'], + 'cursor_line_end' => [Key::END, 'ctrl+e'], + 'delete_char_backward' => [Key::BACKSPACE, 'shift+backspace'], + 'delete_char_forward' => [Key::DELETE, 'ctrl+d', 'shift+delete'], + 'delete_word_backward' => ['ctrl+w', 'alt+backspace'], + 'delete_to_line_start' => ['ctrl+u'], + 'delete_to_line_end' => ['ctrl+k'], + 'submit' => [Key::ENTER], + 'select_cancel' => [Key::ESCAPE, 'ctrl+c'], + ]; + } + + /** + * Take graphemes from $startIndex forward that fit within $maxWidth display columns. + * + * @param string[] $graphemes + * + * @return string[] + */ + private static function takeGraphemesByWidth(array $graphemes, int $startIndex, int $maxWidth): array + { + $result = []; + $width = 0; + for ($i = $startIndex; $i < \count($graphemes); ++$i) { + $gw = AnsiUtils::visibleWidth($graphemes[$i]); + if ($width + $gw > $maxWidth) { + break; + } + $result[] = $graphemes[$i]; + $width += $gw; + } + + return $result; + } + + /** + * Take graphemes from the end that fit within $maxWidth display columns. + * + * @param string[] $graphemes + * + * @return string[] + */ + private static function takeGraphemesFromEndByWidth(array $graphemes, int $maxWidth): array + { + $result = []; + $width = 0; + for ($i = \count($graphemes) - 1; $i >= 0; --$i) { + $gw = AnsiUtils::visibleWidth($graphemes[$i]); + if ($width + $gw > $maxWidth) { + break; + } + array_unshift($result, $graphemes[$i]); + $width += $gw; + } + + return $result; + } + + /** + * Take graphemes centered around $centerIndex that fit within $maxWidth display columns. + * + * @param string[] $graphemes + * + * @return array{string[], int} The visible graphemes and the start index in the original array + */ + private static function takeGraphemesCenteredByWidth(array $graphemes, int $centerIndex, int $maxWidth): array + { + $halfWidth = (int) floor($maxWidth / 2); + + // Expand left from center until we reach halfWidth + $startIndex = $centerIndex; + $leftWidth = 0; + while ($startIndex > 0) { + $gw = AnsiUtils::visibleWidth($graphemes[$startIndex - 1]); + if ($leftWidth + $gw > $halfWidth) { + break; + } + --$startIndex; + $leftWidth += $gw; + } + + // Take graphemes from startIndex that fit within maxWidth + return [self::takeGraphemesByWidth($graphemes, $startIndex, $maxWidth), $startIndex]; + } + + private function handlePaste(string $text): void + { + // Clean pasted text - remove newlines + $cleanText = str_replace(["\r\n", "\r", "\n"], '', $text); + + $this->line->insert($cleanText); + $this->notifyChange(); + } + + private function notifyChange(): void + { + $this->invalidate(); + $this->dispatch(new ChangeEvent($this, $this->line->getText())); + } +} diff --git a/src/Symfony/Component/Tui/Widget/KeybindingsTrait.php b/src/Symfony/Component/Tui/Widget/KeybindingsTrait.php new file mode 100644 index 0000000000000..726fdf5bcf511 --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/KeybindingsTrait.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Input\Keybindings; + +/** + * Default implementation of keybindings for focusable widgets. + * + * Resolution order (later overrides earlier): + * 1. Widget defaults (from getDefaultKeybindings()) + * 2. Global keybindings from the TUI (via WidgetContext) + * 3. Explicit keybindings set on this widget (via setKeybindings()) + * + * @experimental + * + * @author Fabien Potencier + */ +trait KeybindingsTrait +{ + private ?Keybindings $keybindings = null; + + /** @var (callable(string): bool)|null */ + private $onInput; + + /** + * Return the effective keybindings for this widget. + * + * Resolution order (later overrides earlier): + * 1. Widget defaults (from getDefaultKeybindings()) + * 2. Global keybindings from the TUI (via WidgetContext) + * 3. Explicit keybindings set on this widget (via setKeybindings()) + */ + public function getKeybindings(): Keybindings + { + $bindings = static::getDefaultKeybindings(); + + $context = $this->getContext()?->keybindings(); + if (null !== $context) { + $bindings = array_merge($bindings, $context->all()); + } + + if (null !== $this->keybindings) { + $bindings = array_merge($bindings, $this->keybindings->all()); + } + + return new Keybindings($bindings, $context?->getParser()); + } + + /** + * @return $this + */ + public function setKeybindings(?Keybindings $keybindings): static + { + $this->keybindings = $keybindings; + + return $this; + } + + /** + * @param (callable(string): bool)|null $callback + */ + public function onInput(?callable $callback): static + { + $this->onInput = $callback; + + return $this; + } + + /** + * Return the default keybindings for this widget. + * + * Override in widgets that define their own actions. + * + * @return array + */ + protected static function getDefaultKeybindings(): array + { + return []; + } +} diff --git a/src/Symfony/Component/Tui/Widget/LoaderWidget.php b/src/Symfony/Component/Tui/Widget/LoaderWidget.php new file mode 100644 index 0000000000000..fc64e87919f8f --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/LoaderWidget.php @@ -0,0 +1,260 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Loop\PeriodicStepper; +use Symfony\Component\Tui\Render\RenderContext; + +/** + * Animated loading spinner. + * + * @experimental + * + * @author Fabien Potencier + */ +class LoaderWidget extends AbstractWidget +{ + use ScheduledTickTrait; + + private const string DEFAULT_STYLE = 'dots'; + private const DEFAULT_INTERVAL_MS = 80; + + /** @var array */ + private static array $styles = [ + 'line' => ['-', '\\', '|', '/'], + 'dots' => ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], + 'bounce' => ['⠁', '⠂', '⠄', '⡀', '⢀', '⠠', '⠐', '⠈'], + 'pulse' => ['⠁', '⠉', '⠋', '⠛', '⠟', '⠿', '⡿', '⣿', '⡿', '⠿', '⠟', '⠛', '⠋', '⠉', '⠁'], + 'bar' => ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█', '▇', '▆', '▅', '▄', '▃', '▂', '▁'], + 'shade' => ['░', '▒', '▓', '█', '▓', '▒', '░'], + 'arc' => ['◜', '◝', '◞', '◟'], + 'circle' => ['◐', '◓', '◑', '◒'], + ]; + + /** @var string[] */ + private array $frames; + private string $finishedIndicator = ''; + private int $frame = 0; + private bool $running = false; + private bool $finished = false; + private PeriodicStepper $frameStepper; + + public function __construct( + private string $message = 'Loading...', + ) { + $this->frames = self::$styles[self::DEFAULT_STYLE]; + $this->frameStepper = PeriodicStepper::everyMs(self::DEFAULT_INTERVAL_MS, 8); + $this->start(); + } + + /** + * Start the loader animation. + */ + public function start(): void + { + if ($this->running) { + return; + } + + $this->running = true; + $this->finished = false; + $this->frame = 0; + $this->frameStepper->reset(); + $this->invalidate(); + $this->getContext()?->requestRender(); + $this->startScheduledTick($this->frameStepper->getIntervalSeconds()); + } + + /** + * Stop the loader animation. + */ + public function stop(): void + { + if (!$this->running) { + return; + } + + $this->running = false; + $this->finished = true; + + $this->clearScheduledTick(); + + $this->invalidate(); + $this->getContext()?->requestRender(); + } + + /** + * Check if the loader is running. + */ + public function isRunning(): bool + { + return $this->running; + } + + /** + * @return $this + */ + public function setMessage(string $message): self + { + if ($this->message !== $message) { + $this->message = $message; + $this->invalidate(); + $this->getContext()?->requestRender(); + } + + return $this; + } + + /** + * @param string[] $frames Frame characters (at least 2) + */ + public static function addSpinner(string $name, array $frames): void + { + $frames = array_values($frames); + + if (\count($frames) < 2) { + throw new \InvalidArgumentException('Must have at least 2 indicator frame characters.'); + } + + self::$styles[$name] = $frames; + } + + /** + * @return $this + */ + public function setSpinner(string $name): self + { + if (!isset(self::$styles[$name])) { + throw new \InvalidArgumentException(\sprintf('Unknown loader style "%s". Available styles: %s.', $name, implode(', ', array_keys(self::$styles)))); + } + + $this->frames = self::$styles[$name]; + $this->frame = 0; + $this->invalidate(); + + return $this; + } + + /** + * @return $this + */ + public function setIntervalMs(int $intervalMs): self + { + $this->frameStepper->setIntervalMs($intervalMs); + + if ($this->running) { + $this->startScheduledTick($this->frameStepper->getIntervalSeconds()); + } + + return $this; + } + + /** + * @return $this + */ + public function setFinishedIndicator(string $finishedIndicator): self + { + $this->finishedIndicator = $finishedIndicator; + $this->invalidate(); + + return $this; + } + + /** + * Get the current message. + */ + public function getMessage(): string + { + return $this->message; + } + + /** + * Get the current spinner frame character. + */ + public function getSpinnerFrame(): string + { + return $this->frames[$this->frame]; + } + + /** + * Advance the animation frame if enough time has passed. + * + * @return bool True if the frame was advanced + */ + public function tick(?float $deltaTime = null): bool + { + if (!$this->running) { + return false; + } + + $steps = $this->frameStepper->advance($deltaTime); + if (0 === $steps) { + return false; + } + + $this->frame = ($this->frame + $steps) % \count($this->frames); + $this->invalidate(); + + return true; + } + + /** + * @return string[] + */ + public function render(RenderContext $context): array + { + $columns = $context->getColumns(); + + if ($this->running) { + $indicator = $this->frames[$this->frame]; + } elseif ($this->finished && '' !== $this->finishedIndicator) { + $indicator = $this->finishedIndicator; + } else { + return []; + } + + $styledIndicator = $this->applyElement('spinner', $indicator); + $styledMessage = $this->applyElement('message', $this->message); + + $content = $styledIndicator.' '.$styledMessage; + $line = AnsiUtils::truncateToWidth($content, $columns); + + $visibleLen = AnsiUtils::visibleWidth($line); + $rightFill = str_repeat(' ', max(0, $columns - $visibleLen)); + + return ['', $line.$rightFill]; + } + + protected function onAttach(WidgetContext $context): void + { + if ($this->running) { + $this->frameStepper->reset(); + $this->resumeScheduledTick(); + } + } + + protected function onDetach(): void + { + $this->stopScheduledTick(); + } + + protected function onScheduledTick(): void + { + $this->tick(); + } + + protected function resolveScheduledTickContext(): ?WidgetContext + { + return $this->getContext(); + } +} diff --git a/src/Symfony/Component/Tui/Widget/Markdown/DarkTerminalTheme.php b/src/Symfony/Component/Tui/Widget/Markdown/DarkTerminalTheme.php new file mode 100644 index 0000000000000..19ef250e8e522 --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/Markdown/DarkTerminalTheme.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget\Markdown; + +use Tempest\Highlight\TerminalTheme; +use Tempest\Highlight\Themes\EscapesTerminalTheme; +use Tempest\Highlight\Tokens\TokenType; +use Tempest\Highlight\Tokens\TokenTypeEnum; + +/** + * Dark terminal theme for syntax highlighting. + * + * Colors: + * - syntaxComment: #6a6a7a + * - syntaxKeyword: #ff7ab2 + * - syntaxFunction: #4eb0ff + * - syntaxVariable: #78c7ff + * - syntaxString: #d9c97c + * - syntaxNumber: #d9c97c + * - syntaxType: #acf2e4 + * - syntaxOperator: #b281eb + * - syntaxPunctuation: #e5e5e7 + * + * @experimental + * + * @author Fabien Potencier + */ +final class DarkTerminalTheme implements TerminalTheme +{ + use EscapesTerminalTheme; + + public function before(TokenType $tokenType): string + { + $rgb = match ($tokenType) { + TokenTypeEnum::KEYWORD => [255, 122, 178], // #ff7ab2 + TokenTypeEnum::TYPE => [172, 242, 228], // #acf2e4 + TokenTypeEnum::PROPERTY => [120, 199, 255], // #78c7ff (variable) + TokenTypeEnum::VARIABLE => [120, 199, 255], // #78c7ff (variable) + TokenTypeEnum::GENERIC => [78, 176, 255], // #4eb0ff (function) + TokenTypeEnum::COMMENT => [106, 106, 122], // #6a6a7a + TokenTypeEnum::VALUE => [217, 201, 124], // #d9c97c (string/number) + TokenTypeEnum::ATTRIBUTE => [178, 129, 235], // #b281eb + TokenTypeEnum::OPERATOR => [178, 129, 235], // #b281eb + default => null, + }; + + if (null === $rgb) { + return ''; + } + + // Use 24-bit RGB escape sequence + return sprintf("\x1b[38;2;%d;%d;%dm", $rgb[0], $rgb[1], $rgb[2]); + } + + public function after(TokenType $tokenType): string + { + return "\x1b[39m"; // Reset foreground only + } +} diff --git a/src/Symfony/Component/Tui/Widget/MarkdownWidget.php b/src/Symfony/Component/Tui/Widget/MarkdownWidget.php new file mode 100644 index 0000000000000..a69ef9e645a09 --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/MarkdownWidget.php @@ -0,0 +1,563 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use League\CommonMark\Environment\Environment; +use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; +use League\CommonMark\Extension\CommonMark\Node\Block\BlockQuote; +use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode; +use League\CommonMark\Extension\CommonMark\Node\Block\Heading; +use League\CommonMark\Extension\CommonMark\Node\Block\IndentedCode; +use League\CommonMark\Extension\CommonMark\Node\Block\ListBlock; +use League\CommonMark\Extension\CommonMark\Node\Block\ListItem; +use League\CommonMark\Extension\CommonMark\Node\Block\ThematicBreak; +use League\CommonMark\Extension\CommonMark\Node\Inline\Code; +use League\CommonMark\Extension\CommonMark\Node\Inline\Emphasis; +use League\CommonMark\Extension\CommonMark\Node\Inline\Link; +use League\CommonMark\Extension\CommonMark\Node\Inline\Strong; +use League\CommonMark\Extension\GithubFlavoredMarkdownExtension; +use League\CommonMark\Extension\Strikethrough\Strikethrough; +use League\CommonMark\Extension\Table\Table; +use League\CommonMark\Extension\Table\TableCell; +use League\CommonMark\Extension\Table\TableRow; +use League\CommonMark\Extension\Table\TableSection; +use League\CommonMark\Node\Block\Document; +use League\CommonMark\Node\Block\Paragraph; +use League\CommonMark\Node\Inline\Newline; +use League\CommonMark\Node\Inline\Text; +use League\CommonMark\Node\Node; +use League\CommonMark\Parser\MarkdownParser; +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Ansi\TextWrapper; +use Symfony\Component\Tui\Render\RenderContext; +use Symfony\Component\Tui\Widget\Markdown\DarkTerminalTheme; +use Symfony\Component\Tui\Widget\Util\StringUtils; +use Tempest\Highlight\Highlighter; + +/** + * Renders markdown text with styling using league/commonmark and tempest/highlight. + * + * Supports headings, bold, italic, code, lists, links, blockquotes, tables, and horizontal rules. + * + * @experimental + * + * @author Fabien Potencier + */ +class MarkdownWidget extends AbstractWidget +{ + private MarkdownParser $parser; + private Highlighter $highlighter; + + /** + * ANSI codes to restore the context's style after inline style overrides. + * + * When the Markdown widget is rendered inside a styled context (e.g. gray italic + * for thinking blocks), inline styles (yellow for code, bold for strong, etc.) + * emit reset codes that cancel the context's attributes. This restore sequence + * re-applies the context's formatting after each inline style. + */ + private string $restoreContext = ''; + + public function __construct( + private string $text = '', + ?MarkdownParser $parser = null, + ?Highlighter $highlighter = null, + ) { + $this->text = StringUtils::sanitizeUtf8($text); + if (null === $parser) { + $environment = new Environment(); + $environment->addExtension(new CommonMarkCoreExtension()); + $environment->addExtension(new GithubFlavoredMarkdownExtension()); + $parser = new MarkdownParser($environment); + } + $this->parser = $parser; + $this->highlighter = $highlighter ?? new Highlighter(new DarkTerminalTheme()); + } + + /** + * @return $this + */ + public function setText(string $text): self + { + $this->text = StringUtils::sanitizeUtf8($text); + $this->invalidate(); + + return $this; + } + + /** + * Get the markdown text. + */ + public function getText(): string + { + return $this->text; + } + + /** + * @return string[] + */ + public function render(RenderContext $context): array + { + if ('' === trim($this->text)) { + return []; + } + + return $this->renderMarkdown($context); + } + + /** + * @return string[] + */ + private function renderMarkdown(RenderContext $context): array + { + // Context already has inner dimensions (chrome subtracted by the Renderer) + $contentColumns = $context->getColumns(); + + // Compute the restore sequence from the context's resolved style. + // When the context has styling (e.g. gray italic for thinking blocks), + // inline styles (yellow for code, bold for strong, etc.) emit reset codes + // that cancel the context's attributes. This restore sequence re-applies them. + $this->restoreContext = $context->getStyle()->getAnsiRestore(); + + // Parse markdown to AST + $document = $this->parser->parse($this->text); + + // Render AST to styled lines + $renderedLines = $this->renderDocument($document, $contentColumns); + + // Wrap all lines to ensure they fit within width + $wrappedLines = []; + foreach ($renderedLines as $line) { + array_push($wrappedLines, ...TextWrapper::wrapTextWithAnsi($line, $contentColumns)); + } + + return $wrappedLines; + } + + /** + * @return string[] + */ + private function renderDocument(Document $document, int $columns): array + { + $lines = []; + $isFirst = true; + + foreach ($document->children() as $child) { + // Add spacing between blocks + if (!$isFirst && !$child instanceof TableRow) { + $lines[] = ''; + } + $isFirst = false; + + $blockLines = $this->renderNode($child, $columns); + array_push($lines, ...$blockLines); + } + + return $lines; + } + + /** + * @return string[] + */ + private function renderNode(Node $node, int $columns): array + { + return match (true) { + $node instanceof Heading => $this->renderHeading($node, $columns), + $node instanceof Paragraph => $this->renderParagraph($node, $columns), + $node instanceof FencedCode => $this->renderFencedCode($node, $columns), + $node instanceof IndentedCode => $this->renderIndentedCode($node, $columns), + $node instanceof BlockQuote => $this->renderBlockQuote($node, $columns), + $node instanceof ListBlock => $this->renderList($node, $columns), + $node instanceof ThematicBreak => [$this->resolveElement('hr')->apply(str_repeat('─', $columns))], + $node instanceof Table => $this->renderTable($node, $columns), + default => $this->renderGenericBlock($node, $columns), + }; + } + + /** + * @return string[] + */ + private function renderHeading(Heading $heading, int $columns): array + { + $level = $heading->getLevel(); + $text = $this->renderInlineNodes($heading); + $prefix = str_repeat('#', $level).' '; + + $styledText = $this->resolveElement('heading')->apply($prefix.$text); + + return TextWrapper::wrapTextWithAnsi($styledText, $columns); + } + + /** + * @return string[] + */ + private function renderParagraph(Paragraph $paragraph, int $columns): array + { + $text = $this->renderInlineNodes($paragraph); + + return TextWrapper::wrapTextWithAnsi($text, $columns); + } + + /** + * @return string[] + */ + private function renderFencedCode(FencedCode $code, int $columns): array + { + $language = $code->getInfoWords()[0] ?? null; + $content = rtrim($code->getLiteral(), "\n"); + + return $this->renderCodeBlock($content, $language, $columns); + } + + /** + * @return string[] + */ + private function renderIndentedCode(IndentedCode $code, int $columns): array + { + $content = rtrim($code->getLiteral(), "\n"); + + return $this->renderCodeBlock($content, null, $columns); + } + + /** + * @return string[] + */ + private function renderCodeBlock(string $code, ?string $language, int $columns): array + { + $lines = []; + + $codeBlockBorderStyle = $this->resolveElement('code-block-border'); + + // Top border + $lines[] = $codeBlockBorderStyle->apply(str_repeat('─', $columns)); + + $indent = ' '; // Code block indent + $availableColumns = max(1, $columns - \strlen($indent)); + + // Try syntax highlighting with tempest/highlight + $highlighted = null; + if (null !== $language && '' !== $language) { + try { + $highlighted = $this->highlighter->parse($code, $language); + } catch (\Throwable) { + // Fall back to plain text + } + } + + if (null !== $highlighted) { + foreach (explode("\n", $highlighted) as $line) { + $padded = AnsiUtils::truncateToWidth($line, $availableColumns, '', true); + $lines[] = $indent.$padded; + } + } else { + foreach (explode("\n", $code) as $line) { + $padded = AnsiUtils::truncateToWidth($line, $availableColumns, '', true); + $lines[] = $indent.$padded; + } + } + + // Bottom border + $lines[] = $codeBlockBorderStyle->apply(str_repeat('─', $columns)); + + return $lines; + } + + /** + * @return string[] + */ + private function renderBlockQuote(BlockQuote $quote, int $columns): array + { + $lines = []; + $quoteColumns = max(1, $columns - 2); + $quoteStyle = $this->resolveElement('quote'); + $quoteBorderStyle = $this->resolveElement('quote-border'); + + foreach ($quote->children() as $child) { + $childLines = $this->renderNode($child, $quoteColumns); + foreach ($childLines as $line) { + $styledLine = $quoteStyle->apply($line); + $border = $quoteBorderStyle->apply('│ ').$this->restoreContext; + $lines[] = $border.$styledLine; + } + } + + return $lines; + } + + /** + * @return string[] + */ + private function renderList(ListBlock $list, int $columns): array + { + $lines = []; + $itemColumns = max(1, $columns - 2); + $isOrdered = 'ordered' === $list->getListData()->type; + $index = $list->getListData()->start ?? 1; + $listBulletStyle = $this->resolveElement('list-bullet'); + + foreach ($list->children() as $item) { + if (!$item instanceof ListItem) { + continue; + } + + $bullet = $isOrdered + ? $listBulletStyle->apply($index.'. ').$this->restoreContext + : $listBulletStyle->apply('• ').$this->restoreContext; + + $content = $this->renderListItemContent($item, $itemColumns); + foreach ($content as $i => $line) { + if (0 === $i) { + $lines[] = $bullet.$line; + } else { + $lines[] = ' '.$line; + } + } + + ++$index; + } + + return $lines; + } + + /** + * @return string[] + */ + private function renderListItemContent(ListItem $item, int $columns): array + { + $parts = []; + + foreach ($item->children() as $child) { + if ($child instanceof Paragraph) { + $text = $this->renderInlineNodes($child); + $wrapped = TextWrapper::wrapTextWithAnsi($text, $columns); + array_push($parts, ...$wrapped); + } else { + $childLines = $this->renderNode($child, $columns); + array_push($parts, ...$childLines); + } + } + + return $parts; + } + + /** + * @return string[] + */ + private function renderTable(Table $table, int $columns): array + { + $headers = []; + $rows = []; + + foreach ($table->children() as $section) { + if (!$section instanceof TableSection) { + continue; + } + + foreach ($section->children() as $row) { + if (!$row instanceof TableRow) { + continue; + } + + $cells = []; + foreach ($row->children() as $cell) { + if ($cell instanceof TableCell) { + $cells[] = $this->renderInlineNodes($cell); + } + } + + if ($section->isHead()) { + $headers = $cells; + } else { + $rows[] = $cells; + } + } + } + + if ([] === $headers && [] === $rows) { + return []; + } + + return $this->formatTable($headers, $rows, $columns); + } + + /** + * @param string[] $headers + * @param array $rows + * + * @return string[] + */ + private function formatTable(array $headers, array $rows, int $availableColumns): array + { + $columnCounts = array_map('count', $rows); + $columnCounts[] = \count($headers); + $numCols = max($columnCounts); + + if (0 === $numCols) { + return []; + } + + $borderOverhead = 3 * $numCols + 1; + $minTableWidth = $borderOverhead + $numCols; + + if ($availableColumns < $minTableWidth) { + // Fall back to simple text rendering + $lines = []; + if ([] !== $headers) { + $lines[] = implode(' | ', $headers); + } + foreach ($rows as $row) { + $lines[] = implode(' | ', $row); + } + + return $lines; + } + + // Calculate natural widths + $naturalWidths = []; + for ($i = 0; $i < $numCols; ++$i) { + $naturalWidths[$i] = AnsiUtils::visibleWidth($headers[$i] ?? ''); + } + + foreach ($rows as $row) { + for ($i = 0; $i < $numCols; ++$i) { + $naturalWidths[$i] = max($naturalWidths[$i] ?? 0, AnsiUtils::visibleWidth($row[$i] ?? '')); + } + } + + $totalNaturalWidth = array_sum($naturalWidths) + $borderOverhead; + $columnWidths = []; + + if ($totalNaturalWidth <= $availableColumns) { + $columnWidths = $naturalWidths; + } else { + $availableForCells = $availableColumns - $borderOverhead; + $totalNatural = array_sum($naturalWidths); + + foreach ($naturalWidths as $width) { + $proportion = $totalNatural > 0 ? $width / $totalNatural : 1 / $numCols; + $columnWidths[] = max(1, (int) floor($proportion * $availableForCells)); + } + + $allocated = array_sum($columnWidths); + $remaining = $availableForCells - $allocated; + for ($i = 0; $remaining > 0 && $i < $numCols; ++$i) { + ++$columnWidths[$i]; + --$remaining; + } + } + + $lines = []; + + // Top border + $topBorderCells = array_map(fn (int $w) => str_repeat('─', $w), $columnWidths); + $lines[] = '┌─'.implode('─┬─', $topBorderCells).'─┐'; + + // Header row + if ([] !== $headers) { + $headerCellLines = []; + for ($i = 0; $i < $numCols; ++$i) { + $text = $headers[$i] ?? ''; + $headerCellLines[] = $this->wrapCellText($text, $columnWidths[$i]); + } + + $headerLineCount = max(array_map('count', $headerCellLines)); + + for ($lineIdx = 0; $lineIdx < $headerLineCount; ++$lineIdx) { + $rowParts = []; + for ($colIdx = 0; $colIdx < $numCols; ++$colIdx) { + $text = $headerCellLines[$colIdx][$lineIdx] ?? ''; + $padded = $text.str_repeat(' ', max(0, $columnWidths[$colIdx] - AnsiUtils::visibleWidth($text))); + $rowParts[] = $this->resolveElement('bold')->apply($padded); + } + $lines[] = '│ '.implode(' │ ', $rowParts).' │'; + } + + // Separator + $separatorCells = array_map(fn (int $w) => str_repeat('─', $w), $columnWidths); + $lines[] = '├─'.implode('─┼─', $separatorCells).'─┤'; + } + + // Data rows + foreach ($rows as $row) { + $rowCellLines = []; + for ($i = 0; $i < $numCols; ++$i) { + $text = $row[$i] ?? ''; + $rowCellLines[] = $this->wrapCellText($text, $columnWidths[$i]); + } + + $rowLineCount = max(array_map('count', $rowCellLines)); + for ($lineIdx = 0; $lineIdx < $rowLineCount; ++$lineIdx) { + $rowParts = []; + for ($colIdx = 0; $colIdx < $numCols; ++$colIdx) { + $text = $rowCellLines[$colIdx][$lineIdx] ?? ''; + $rowParts[] = $text.str_repeat(' ', max(0, $columnWidths[$colIdx] - AnsiUtils::visibleWidth($text))); + } + $lines[] = '│ '.implode(' │ ', $rowParts).' │'; + } + } + + // Bottom border + $bottomBorderCells = array_map(fn (int $w) => str_repeat('─', $w), $columnWidths); + $lines[] = '└─'.implode('─┴─', $bottomBorderCells).'─┘'; + + return $lines; + } + + /** + * @return string[] + */ + private function wrapCellText(string $text, int $maxWidth): array + { + return TextWrapper::wrapTextWithAnsi($text, max(1, $maxWidth)); + } + + /** + * @return string[] + */ + private function renderGenericBlock(Node $node, int $columns): array + { + $text = $this->renderInlineNodes($node); + if ('' === $text) { + return []; + } + + return TextWrapper::wrapTextWithAnsi($text, $columns); + } + + /** + * Render all inline nodes within a container to a single styled string. + */ + private function renderInlineNodes(Node $container): string + { + $result = ''; + + foreach ($container->children() as $child) { + $result .= $this->renderInlineNode($child); + } + + return $result; + } + + private function renderInlineNode(Node $node): string + { + return match (true) { + $node instanceof Text => $node->getLiteral(), + $node instanceof Strong => $this->resolveElement('bold')->apply($this->renderInlineNodes($node)).$this->restoreContext, + $node instanceof Emphasis => $this->resolveElement('italic')->apply($this->renderInlineNodes($node)).$this->restoreContext, + $node instanceof Strikethrough => $this->resolveElement('strikethrough')->apply($this->renderInlineNodes($node)).$this->restoreContext, + $node instanceof Code => $this->resolveElement('code')->apply($node->getLiteral()).$this->restoreContext, + $node instanceof Link => $this->resolveElement('link')->apply($this->renderInlineNodes($node)).$this->restoreContext.' '.$this->resolveElement('link-url')->apply('('.$node->getUrl().')').$this->restoreContext, + $node instanceof Newline => "\n", + default => $this->renderInlineNodes($node), // For nested structures + }; + } +} diff --git a/src/Symfony/Component/Tui/Widget/ParentInterface.php b/src/Symfony/Component/Tui/Widget/ParentInterface.php new file mode 100644 index 0000000000000..9196b525a2cbf --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/ParentInterface.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +/** + * Interface for widgets that have child widgets. + * + * This is a read-only interface for tree traversal. Use ContainerInterface + * when you need to add or remove children. + * + * @experimental + * + * @author Fabien Potencier + */ +interface ParentInterface +{ + /** + * Get all child widgets. + * + * @return AbstractWidget[] + */ + public function all(): array; +} diff --git a/src/Symfony/Component/Tui/Widget/ProgressBarWidget.php b/src/Symfony/Component/Tui/Widget/ProgressBarWidget.php new file mode 100644 index 0000000000000..54c124046f3ed --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/ProgressBarWidget.php @@ -0,0 +1,598 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Render\RenderContext; + +/** + * Animated progress bar widget. + * + * Supports both determinate (with max steps) and indeterminate (no max) modes. + * Uses a format string with placeholders to render the bar. + * + * Built-in placeholders: %current%, %max%, %bar%, %percent%, %elapsed%, + * %remaining%, %estimated%, %memory%, %message%. + * + * The bar animates via the event loop, like the LoaderWidget spinner. + * + * @experimental + * + * @author Fabien Potencier + */ +class ProgressBarWidget extends AbstractWidget +{ + use ScheduledTickTrait; + + private const TICK_INTERVAL_MS = 100; + + private int $step = 0; + private int $startingStep = 0; + private ?int $max; + private int $stepWidth; + private float $percent = 0.0; + private int $startTime; + private bool $running = false; + + private int $barWidth = 28; + private string $barChar = '━'; + private string $emptyBarChar = '━'; + private string $progressChar = ''; + private string $format; + + /** @var array */ + private array $messages = []; + + /** @var array */ + private array $placeholderFormatters = []; + + /** @var array */ + private static array $defaultPlaceholderFormatters = []; + + public function __construct( + int $max = 0, + ?string $format = null, + ) { + $this->setMaxSteps($max); + $this->format = $format ?? ($max > 0 ? self::FORMAT_NORMAL : self::FORMAT_INDETERMINATE); + $this->startTime = time(); + } + + /** + * Normal format: ` 3/10 [━━━━━━━━━━━━━━━━━━━━━━━━━━━━] 30%`. + */ + public const FORMAT_NORMAL = ' %current%/%max% [%bar%] %percent:3s%%'; + + /** + * Indeterminate format (no max): ` 42 [━━━━━━━━━━━━━━━━━━━━━━━━━━━━]`. + */ + public const FORMAT_INDETERMINATE = ' %current% [%bar%]'; + + /** + * Verbose format: ` 3/10 [━━━━━━━━━━━━━━━━━━━━━━━━━━━━] 30% 0:05`. + */ + public const FORMAT_VERBOSE = ' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%'; + + /** + * Very verbose format: ` 3/10 [━━━━━━━━━━━━━━━━━━━━━━━━━━━━] 30% 0:05/0:15`. + */ + public const FORMAT_VERY_VERBOSE = ' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s%'; + + /** + * Debug format with memory: ` 3/10 [━━━━━━━━━━━━━━━━━━━━━━━━━━━━] 30% 0:05/0:15 12.0 MiB`. + */ + public const FORMAT_DEBUG = ' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %memory:6s%'; + + /** + * Verbose indeterminate format: ` 42 [━━━━━━━━━━━━━━━━━━━━━━━━━━━━] 0:05`. + */ + public const FORMAT_VERBOSE_INDETERMINATE = ' %current% [%bar%] %elapsed:6s%'; + + /** + * Debug indeterminate format: ` 42 [━━━━━━━━━━━━━━━━━━━━━━━━━━━━] 0:05 12.0 MiB`. + */ + public const FORMAT_DEBUG_INDETERMINATE = ' %current% [%bar%] %elapsed:6s% %memory:6s%'; + + /** + * Start (or restart) the progress bar. + * + * @param int|null $max Maximum steps (0 for indeterminate), null to keep current + * @param int $startAt Starting step value + */ + public function start(?int $max = null, int $startAt = 0): void + { + $this->startTime = time(); + $this->step = $startAt; + $this->startingStep = $startAt; + $this->percent = 0.0; + $this->running = true; + + if (null !== $max) { + $this->setMaxSteps($max); + } + + if ($startAt > 0) { + $this->setProgress($startAt); + } + + $this->invalidate(); + $this->getContext()?->requestRender(); + $this->startScheduledTick(self::TICK_INTERVAL_MS / 1000); + } + + /** + * Advance the progress bar by a number of steps. + */ + public function advance(int $step = 1): void + { + $this->setProgress($this->step + $step); + } + + /** + * Set the current progress. + */ + public function setProgress(int $step): void + { + if (null !== $this->max && $step > $this->max) { + $this->max = $step; + $this->stepWidth = \strlen((string) $this->max); + } elseif ($step < 0) { + $step = 0; + } + + $this->step = $step; + + if (null === $this->max) { + $this->percent = 0.0; + } elseif (0 === $this->max) { + $this->percent = 1.0; + } else { + $this->percent = (float) $this->step / $this->max; + } + + $this->invalidate(); + $this->getContext()?->requestRender(); + } + + /** + * Finish the progress bar (jump to max). + */ + public function finish(): void + { + if (null === $this->max) { + $this->max = $this->step; + $this->stepWidth = \strlen((string) $this->max); + } + + $this->setProgress($this->max); + $this->running = false; + $this->clearScheduledTick(); + } + + /** + * Check if the progress bar is running. + */ + public function isRunning(): bool + { + return $this->running; + } + + /** + * Get the current step. + */ + public function getProgress(): int + { + return $this->step; + } + + /** + * Get the maximum number of steps (0 if indeterminate). + */ + public function getMaxSteps(): int + { + return $this->max ?? 0; + } + + /** + * Get the progress as a percentage (0.0 to 1.0). + */ + public function getProgressPercent(): float + { + return $this->percent; + } + + /** + * Get the start time as a Unix timestamp. + */ + public function getStartTime(): int + { + return $this->startTime; + } + + /** + * Get the estimated total time in seconds. + */ + public function getEstimated(): float + { + if (0 === $this->step || $this->step === $this->startingStep) { + return 0; + } + + return round((time() - $this->startTime) / ($this->step - $this->startingStep) * ($this->max ?? $this->step)); + } + + /** + * Get the estimated remaining time in seconds. + */ + public function getRemaining(): float + { + if (null === $this->max || 0 === $this->step || $this->step === $this->startingStep) { + return 0; + } + + return round((time() - $this->startTime) / ($this->step - $this->startingStep) * ($this->max - $this->step)); + } + + /** + * Get the bar offset (number of filled characters). + */ + public function getBarOffset(): int + { + if (null !== $this->max) { + return (int) floor($this->percent * $this->barWidth); + } + + return $this->step % $this->barWidth; + } + + /** + * Get the width reserved for the step number display. + */ + public function getStepWidth(): int + { + return $this->stepWidth; + } + + /** + * @return $this + */ + public function setFormat(string $format): self + { + $this->format = $format; + $this->invalidate(); + + return $this; + } + + /** + * Get the current format string. + */ + public function getFormat(): string + { + return $this->format; + } + + /** + * @return $this + */ + public function setBarWidth(int $width): self + { + $this->barWidth = max(1, $width); + $this->invalidate(); + + return $this; + } + + /** + * Get the bar width in characters. + */ + public function getBarWidth(): int + { + return $this->barWidth; + } + + /** + * Set the character used for the filled part of the bar. + * + * @return $this + */ + public function setBarCharacter(string $char): self + { + $this->barChar = $char; + $this->invalidate(); + + return $this; + } + + /** + * Get the character used for the filled part of the bar. + */ + public function getBarCharacter(): string + { + return $this->barChar; + } + + /** + * Set the character used for the empty part of the bar. + * + * @return $this + */ + public function setEmptyBarCharacter(string $char): self + { + $this->emptyBarChar = $char; + $this->invalidate(); + + return $this; + } + + /** + * Get the character used for the empty part of the bar. + */ + public function getEmptyBarCharacter(): string + { + return $this->emptyBarChar; + } + + /** + * Set the character displayed at the progress position. + * + * @return $this + */ + public function setProgressCharacter(string $char): self + { + $this->progressChar = $char; + $this->invalidate(); + + return $this; + } + + /** + * Get the character displayed at the progress position. + */ + public function getProgressCharacter(): string + { + return $this->progressChar; + } + + /** + * Set the maximum number of steps. + */ + public function setMaxSteps(int $max): void + { + if (0 === $max) { + $this->max = null; + $this->stepWidth = 4; + } else { + $this->max = max(0, $max); + $this->stepWidth = \strlen((string) $this->max); + } + + $this->invalidate(); + } + + /** + * Associate a named message for use in the format string via %message_name%. + * + * @return $this + */ + public function setMessage(string $message, string $name = 'message'): self + { + $this->messages[$name] = $message; + $this->invalidate(); + $this->getContext()?->requestRender(); + + return $this; + } + + /** + * Get a named message. + */ + public function getMessage(string $name = 'message'): ?string + { + return $this->messages[$name] ?? null; + } + + /** + * Set a placeholder formatter for this instance. + * + * @param \Closure(self): string $formatter + * + * @return $this + */ + public function setPlaceholderFormatter(string $name, \Closure $formatter): self + { + $this->placeholderFormatters[$name] = $formatter; + $this->invalidate(); + + return $this; + } + + /** + * Get a placeholder formatter (instance-level, then global). + * + * @return (\Closure(self): string)|null + */ + public function getPlaceholderFormatter(string $name): ?\Closure + { + return $this->placeholderFormatters[$name] ?? self::$defaultPlaceholderFormatters[$name] ?? null; + } + + /** + * Set a global placeholder formatter for all ProgressBarWidget instances. + * + * @param \Closure(self): string $formatter + */ + public static function setDefaultPlaceholderFormatter(string $name, \Closure $formatter): void + { + self::$defaultPlaceholderFormatters[$name] = $formatter; + } + + /** + * Tick the animation (for indeterminate mode bouncing). + */ + public function tick(): bool + { + if (!$this->running) { + return false; + } + + $this->invalidate(); + + return true; + } + + /** + * @return string[] + */ + public function render(RenderContext $context): array + { + $columns = $context->getColumns(); + $line = $this->buildLine($columns); + + $styledLine = $this->applyElement('text', $line); + $visibleLen = AnsiUtils::visibleWidth($styledLine); + $rightFill = str_repeat(' ', max(0, $columns - $visibleLen)); + + return [$styledLine.$rightFill]; + } + + protected function onAttach(WidgetContext $context): void + { + if ($this->running) { + $this->resumeScheduledTick(); + } + } + + protected function onDetach(): void + { + $this->stopScheduledTick(); + } + + protected function onScheduledTick(): void + { + $this->tick(); + } + + protected function resolveScheduledTickContext(): ?WidgetContext + { + return $this->getContext(); + } + + private function buildLine(int $availableWidth): string + { + $format = $this->format; + + // First pass: resolve all placeholders to measure total width + $line = $this->replacePlaceholders($format); + + // If the line is too wide, shrink the bar to fit + $lineWidth = AnsiUtils::visibleWidth($line); + if ($lineWidth > $availableWidth) { + $newBarWidth = $this->barWidth - ($lineWidth - $availableWidth); + if ($newBarWidth >= 1) { + $savedBarWidth = $this->barWidth; + $this->barWidth = $newBarWidth; + $line = $this->replacePlaceholders($format); + $this->barWidth = $savedBarWidth; + } + } + + return $line; + } + + private function replacePlaceholders(string $format): string + { + return preg_replace_callback('{%([a-z\-_]+)(?::([^%]+))?%}i', function (array $matches): string { + $name = $matches[1]; + + $text = match ($name) { + 'bar' => $this->renderBar(), + 'elapsed' => self::formatTime(time() - $this->startTime), + 'remaining' => self::formatTime((int) $this->getRemaining()), + 'estimated' => self::formatTime((int) $this->getEstimated()), + 'memory' => self::formatMemory(memory_get_usage(true)), + 'current' => str_pad((string) $this->step, $this->stepWidth, ' ', \STR_PAD_LEFT), + 'max' => (string) ($this->max ?? 0), + 'percent' => (string) (int) floor($this->percent * 100), + default => null, + }; + + if (null === $text) { + $formatter = $this->getPlaceholderFormatter($name); + if (null !== $formatter) { + $text = $formatter($this); + } elseif (isset($this->messages[$name])) { + $text = $this->messages[$name]; + } else { + return $matches[0]; + } + } + + if (isset($matches[2])) { + $text = \sprintf('%'.$matches[2], $text); + } + + return $text; + }, $format) ?? $format; + } + + private function renderBar(): string + { + $completeBars = $this->getBarOffset(); + $styledComplete = $this->applyElement('bar-fill', str_repeat($this->barChar, $completeBars)); + + if ($completeBars < $this->barWidth) { + $progressCharLen = mb_strlen($this->progressChar); + $emptyBars = $this->barWidth - $completeBars - $progressCharLen; + $styledProgress = '' !== $this->progressChar ? $this->applyElement('bar-progress', $this->progressChar) : ''; + $styledEmpty = $this->applyElement('bar-empty', str_repeat($this->emptyBarChar, max(0, $emptyBars))); + + return $styledComplete.$styledProgress.$styledEmpty; + } + + return $styledComplete; + } + + private static function formatTime(int $secs): string + { + if ($secs < 0) { + $secs = 0; + } + + $hours = (int) ($secs / 3600); + $minutes = (int) (($secs % 3600) / 60); + $seconds = $secs % 60; + + if ($hours > 0) { + return \sprintf('%d:%02d:%02d', $hours, $minutes, $seconds); + } + + return \sprintf('%d:%02d', $minutes, $seconds); + } + + private static function formatMemory(int $memory): string + { + if ($memory >= 1024 * 1024 * 1024) { + return \sprintf('%.1f GiB', $memory / 1024 / 1024 / 1024); + } + + if ($memory >= 1024 * 1024) { + return \sprintf('%.1f MiB', $memory / 1024 / 1024); + } + + if ($memory >= 1024) { + return \sprintf('%d KiB', $memory / 1024); + } + + return \sprintf('%d B', $memory); + } +} diff --git a/src/Symfony/Component/Tui/Widget/QuitableTrait.php b/src/Symfony/Component/Tui/Widget/QuitableTrait.php new file mode 100644 index 0000000000000..2dd8bd0e1e6c7 --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/QuitableTrait.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Event\QuitEvent; + +/** + * Trait for widgets that support a quit action. + * + * Dispatches a {@see QuitEvent} when the quit key is pressed. + * If no listener is registered for QuitEvent (neither globally on the + * Tui nor locally on the widget), the default behavior is to stop the TUI. + * + * @experimental + * + * @author Fabien Potencier + */ +trait QuitableTrait +{ + /** + * Register a listener for the quit event on this widget. + * + * @param callable(QuitEvent): void $callback + * + * @return $this + */ + public function onQuit(callable $callback): static + { + return $this->on(QuitEvent::class, $callback); + } + + /** + * Dispatch the quit event. + * + * Call this from handleInput() when quit key is pressed. + * If no listener is registered for QuitEvent, stops the TUI. + */ + protected function dispatchQuit(): void + { + $context = $this->getContext(); + if (null === $context) { + return; + } + + $hasListeners = $context->getEventDispatcher()->hasListeners(QuitEvent::class) + || $this->hasListeners(QuitEvent::class); + + if ($hasListeners) { + $this->dispatch(new QuitEvent($this)); + } else { + // Default behavior: stop the TUI + $context->stop(); + } + } +} diff --git a/src/Symfony/Component/Tui/Widget/ScheduledTickTrait.php b/src/Symfony/Component/Tui/Widget/ScheduledTickTrait.php new file mode 100644 index 0000000000000..05a1950c59bc2 --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/ScheduledTickTrait.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Exception\InvalidArgumentException; + +/** + * Shared scheduling lifecycle for runtime objects driven by WidgetContext ticks. + * + * @experimental + * + * @author Fabien Potencier + */ +trait ScheduledTickTrait +{ + private ?string $scheduledTickId = null; + private ?float $scheduledTickInterval = null; + + abstract protected function resolveScheduledTickContext(): ?WidgetContext; + + abstract protected function onScheduledTick(): void; + + protected function startScheduledTick(float $intervalSeconds): void + { + if ($intervalSeconds <= 0.0) { + throw new InvalidArgumentException(\sprintf('Interval must be greater than 0, got %s.', $intervalSeconds)); + } + + if (null !== $this->scheduledTickId && null !== $this->scheduledTickInterval && abs($this->scheduledTickInterval - $intervalSeconds) < 0.000001) { + return; + } + + $this->stopScheduledTick(); + $this->scheduledTickInterval = $intervalSeconds; + + $context = $this->resolveScheduledTickContext(); + if (null === $context) { + return; + } + + $this->scheduledTickId = $context->scheduleTick( + function (): void { + $this->onScheduledTick(); + }, + $intervalSeconds, + ); + } + + protected function resumeScheduledTick(): void + { + if (null === $this->scheduledTickInterval) { + return; + } + + $this->startScheduledTick($this->scheduledTickInterval); + } + + protected function stopScheduledTick(): void + { + if (null === $this->scheduledTickId) { + return; + } + + $this->resolveScheduledTickContext()?->cancelTick($this->scheduledTickId); + $this->scheduledTickId = null; + } + + protected function clearScheduledTick(): void + { + $this->stopScheduledTick(); + $this->scheduledTickInterval = null; + } +} diff --git a/src/Symfony/Component/Tui/Widget/SelectListWidget.php b/src/Symfony/Component/Tui/Widget/SelectListWidget.php new file mode 100644 index 0000000000000..36c3ae7ee3c63 --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/SelectListWidget.php @@ -0,0 +1,355 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Event\CancelEvent; +use Symfony\Component\Tui\Event\SelectEvent; +use Symfony\Component\Tui\Event\SelectionChangeEvent; +use Symfony\Component\Tui\Input\Key; +use Symfony\Component\Tui\Input\Keybindings; +use Symfony\Component\Tui\Render\RenderContext; + +/** + * Interactive selection list with keyboard navigation. + * + * @experimental + * + * @author Fabien Potencier + */ +class SelectListWidget extends AbstractWidget implements FocusableInterface +{ + use FocusableTrait; + use KeybindingsTrait; + + /** @var array */ + private array $filteredItems; + + private int $selectedIndex = 0; + private bool $selected = false; + + /** + * @param array $items + */ + public function __construct( + private array $items, + private int $maxVisible = 5, + ?Keybindings $keybindings = null, + ) { + $this->filteredItems = $items; + if (null !== $keybindings) { + $this->setKeybindings($keybindings); + } + } + + /** + * @param array $items + * + * @return $this + */ + public function setItems(array $items): self + { + $this->items = $items; + $this->filteredItems = $items; + $this->selectedIndex = 0; + $this->invalidate(); + + return $this; + } + + /** + * @return $this + */ + public function setFilter(string $filter): self + { + $filter = strtolower($filter); + + $filteredItems = array_values(array_filter( + $this->items, + fn ($item) => str_starts_with(strtolower($item['value']), $filter), + )); + + if ($filteredItems !== $this->filteredItems) { + $this->filteredItems = $filteredItems; + $this->selectedIndex = 0; + $this->invalidate(); + } + + return $this; + } + + /** + * @return $this + */ + public function setSelectedIndex(int $index): self + { + $index = max(0, min($index, \count($this->filteredItems) - 1)); + if ($this->selectedIndex !== $index) { + $this->selectedIndex = $index; + $this->invalidate(); + } + + return $this; + } + + /** + * Get the currently selected item. + * + * @return array{value: string, label: string, description?: string}|null + */ + public function getSelectedItem(): ?array + { + return $this->filteredItems[$this->selectedIndex] ?? null; + } + + /** + * Check if an item was selected (Enter pressed) vs cancelled (Escape pressed). + */ + public function wasSelected(): bool + { + return $this->selected; + } + + /** + * @param callable(SelectEvent): void $callback + * + * @return $this + */ + public function onSelect(callable $callback): self + { + return $this->on(SelectEvent::class, $callback); + } + + /** + * @param callable(CancelEvent): void $callback + * + * @return $this + */ + public function onCancel(callable $callback): self + { + return $this->on(CancelEvent::class, $callback); + } + + /** + * @param callable(SelectionChangeEvent): void $callback + * + * @return $this + */ + public function onSelectionChange(callable $callback): self + { + return $this->on(SelectionChangeEvent::class, $callback); + } + + public function handleInput(string $data): void + { + if (null !== $this->onInput && ($this->onInput)($data)) { + return; + } + + $kb = $this->getKeybindings(); + + if ([] !== $this->filteredItems) { + // Up - wrap to bottom when at top + if ($kb->matches($data, 'select_up')) { + $this->selectedIndex = 0 === $this->selectedIndex ? \count($this->filteredItems) - 1 : $this->selectedIndex - 1; + $this->notifySelectionChange(); + + return; + } + + // Down - wrap to top when at bottom + if ($kb->matches($data, 'select_down')) { + $this->selectedIndex = $this->selectedIndex === \count($this->filteredItems) - 1 ? 0 : $this->selectedIndex + 1; + $this->notifySelectionChange(); + + return; + } + + if ($kb->matches($data, 'select_page_up') || $kb->matches($data, 'cursor_left')) { + $this->selectedIndex = max(0, $this->selectedIndex - $this->maxVisible); + $this->notifySelectionChange(); + + return; + } + + if ($kb->matches($data, 'select_page_down') || $kb->matches($data, 'cursor_right')) { + $this->selectedIndex = min(\count($this->filteredItems) - 1, $this->selectedIndex + $this->maxVisible); + $this->notifySelectionChange(); + + return; + } + + // Confirm selection + if ($kb->matches($data, 'select_confirm')) { + $this->confirmSelection(); + + return; + } + } + + // Cancel + if ($kb->matches($data, 'select_cancel')) { + $this->selected = false; + $this->dispatch(new CancelEvent($this)); + } + } + + /** + * @return string[] + */ + public function render(RenderContext $context): array + { + $columns = $context->getColumns(); + $lines = []; + + // No items match filter + if ([] === $this->filteredItems) { + $line = $this->applyElement('no-match', ' No matching items'); + $lines[] = $line; + + return $lines; + } + + // Calculate visible range with scrolling + $startIndex = max( + 0, + min( + $this->selectedIndex - (int) floor($this->maxVisible / 2), + \count($this->filteredItems) - $this->maxVisible, + ), + ); + $endIndex = min($startIndex + $this->maxVisible, \count($this->filteredItems)); + + // Compute max label width from visible items for alignment + $maxLabelWidth = 0; + for ($i = $startIndex; $i < $endIndex; ++$i) { + $maxLabelWidth = max($maxLabelWidth, AnsiUtils::visibleWidth($this->filteredItems[$i]['label'])); + } + $labelColumnWidth = min(30, $maxLabelWidth); + + // Render visible items + for ($i = $startIndex; $i < $endIndex; ++$i) { + $item = $this->filteredItems[$i]; + $isSelected = $i === $this->selectedIndex; + $description = isset($item['description']) ? $this->normalizeDescription($item['description']) : null; + $line = $this->renderItem($item, $isSelected, $description, $columns, $labelColumnWidth); + $lines[] = $line; + } + + // Add scroll indicator if needed + if ($startIndex > 0 || $endIndex < \count($this->filteredItems)) { + $scrollText = \sprintf(' (%d/%d)', $this->selectedIndex + 1, \count($this->filteredItems)); + $line = $this->applyElement('scroll-info', AnsiUtils::truncateToWidth($scrollText, $columns - 2, '')); + $lines[] = $line; + } + + return $lines; + } + + /** + * @return array + */ + protected static function getDefaultKeybindings(): array + { + return [ + 'select_up' => [Key::UP], + 'select_down' => [Key::DOWN], + 'select_page_up' => [Key::PAGE_UP], + 'select_page_down' => [Key::PAGE_DOWN], + 'select_confirm' => [Key::ENTER], + 'select_cancel' => [Key::ESCAPE, 'ctrl+c'], + 'cursor_left' => [Key::LEFT, 'ctrl+b'], + 'cursor_right' => [Key::RIGHT, 'ctrl+f'], + ]; + } + + /** + * @param array{value: string, label: string, description?: string} $item + */ + private function renderItem(array $item, bool $isSelected, ?string $description, int $columns, int $labelColumnWidth): string + { + $displayValue = $item['label']; + $alignedWidth = $labelColumnWidth + 2; + + if ($isSelected) { + $prefix = '→ '; + $selectedStyle = $this->resolveElement('selected'); + + if (null !== $description && $columns > 40) { + $maxValueColumns = min($labelColumnWidth, $columns - \strlen($prefix) - 4); + $truncatedValue = AnsiUtils::truncateToWidth($displayValue, $maxValueColumns, ''); + $spacing = str_repeat(' ', max(1, $alignedWidth - AnsiUtils::visibleWidth($truncatedValue))); + + $descriptionStart = \strlen($prefix) + AnsiUtils::visibleWidth($truncatedValue) + \strlen($spacing); + $remainingColumns = $columns - $descriptionStart - 2; + + if ($remainingColumns > 10) { + $truncatedDesc = AnsiUtils::truncateToWidth($description, $remainingColumns, ''); + + return $selectedStyle->apply("→ {$truncatedValue}{$spacing}{$truncatedDesc}"); + } + } + + $maxColumns = $columns - \strlen($prefix) - 2; + + return $selectedStyle->apply($prefix.AnsiUtils::truncateToWidth($displayValue, $maxColumns, '')); + } + + // Non-selected item + $prefix = ' '; + + if (null !== $description && $columns > 40) { + $maxValueColumns = min($labelColumnWidth, $columns - \strlen($prefix) - 4); + $truncatedValue = AnsiUtils::truncateToWidth($displayValue, $maxValueColumns, ''); + $spacing = str_repeat(' ', max(1, $alignedWidth - AnsiUtils::visibleWidth($truncatedValue))); + + $descriptionStart = \strlen($prefix) + AnsiUtils::visibleWidth($truncatedValue) + \strlen($spacing); + $remainingColumns = $columns - $descriptionStart - 2; + + if ($remainingColumns > 10) { + $truncatedDesc = AnsiUtils::truncateToWidth($description, $remainingColumns, ''); + $labelText = $this->applyElement('label', $truncatedValue); + $descText = $this->applyElement('description', $spacing.$truncatedDesc); + + return $prefix.$labelText.$descText; + } + } + + $maxColumns = $columns - \strlen($prefix) - 2; + + return $prefix.AnsiUtils::truncateToWidth($displayValue, $maxColumns, ''); + } + + private function normalizeDescription(string $description): string + { + // Convert multiline to single line + return trim(preg_replace('/[\r\n]+/', ' ', $description)); + } + + private function confirmSelection(): void + { + $this->selected = true; + $selectedItem = $this->filteredItems[$this->selectedIndex] ?? null; + if (null !== $selectedItem) { + $this->dispatch(new SelectEvent($this, $selectedItem)); + } + } + + private function notifySelectionChange(): void + { + $this->invalidate(); + $selectedItem = $this->filteredItems[$this->selectedIndex] ?? null; + if (null !== $selectedItem) { + $this->dispatch(new SelectionChangeEvent($this, $selectedItem)); + } + } +} diff --git a/src/Symfony/Component/Tui/Widget/SettingItem.php b/src/Symfony/Component/Tui/Widget/SettingItem.php new file mode 100644 index 0000000000000..95909934276bf --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/SettingItem.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Exception\LogicException; + +/** + * Represents a single item in a SettingsListWidget. + * + * @experimental + * + * @author Fabien Potencier + * + * @phpstan-type SubmenuFactory callable(string, callable(?string): void): (FocusableInterface&AbstractWidget) + */ +final class SettingItem +{ + private string $currentValue; + + /** + * @param list $values Predefined values for cycling (empty = no cycling) + * @param SubmenuFactory|null $submenu Factory for submenu widget + */ + public function __construct( + private readonly string $id, + private readonly string $label, + string $currentValue, + private readonly ?string $description = null, + /** @var list */ + private readonly array $values = [], + /** @var SubmenuFactory|null */ + private $submenu = null, + ) { + $this->currentValue = $currentValue; + } + + public function getId(): string + { + return $this->id; + } + + public function getLabel(): string + { + return $this->label; + } + + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @return list + */ + public function getValues(): array + { + return $this->values; + } + + public function getCurrentValue(): string + { + return $this->currentValue; + } + + public function setCurrentValue(string $value): void + { + $this->currentValue = $value; + } + + public function hasValues(): bool + { + return [] !== $this->values; + } + + public function hasSubmenu(): bool + { + return null !== $this->submenu; + } + + /** + * @return SubmenuFactory + */ + public function getSubmenu(): callable + { + if (null === $this->submenu) { + throw new LogicException('This setting item does not have a submenu.'); + } + + return $this->submenu; + } +} diff --git a/src/Symfony/Component/Tui/Widget/SettingsListWidget.php b/src/Symfony/Component/Tui/Widget/SettingsListWidget.php new file mode 100644 index 0000000000000..483a80c3accd6 --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/SettingsListWidget.php @@ -0,0 +1,417 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Event\CancelEvent; +use Symfony\Component\Tui\Event\SelectEvent; +use Symfony\Component\Tui\Event\SettingChangeEvent; +use Symfony\Component\Tui\Input\Key; +use Symfony\Component\Tui\Input\Keybindings; +use Symfony\Component\Tui\Render\RenderContext; + +/** + * Settings panel with value cycling and submenus. + * + * @experimental + * + * @author Fabien Potencier + */ +class SettingsListWidget extends AbstractWidget implements FocusableInterface, ParentInterface +{ + use FocusableTrait; + use KeybindingsTrait; + + private int $selectedIndex = 0; + + // Submenu state + private (FocusableInterface&AbstractWidget)|null $activeSubmenu = null; + + /** @var list Listeners to remove from the global dispatcher on cleanup */ + private array $submenuListeners = []; + + /** + * @param list $items + */ + public function __construct( + private array $items, + private int $maxVisible = 10, + ?Keybindings $keybindings = null, + ) { + if (null !== $keybindings) { + $this->setKeybindings($keybindings); + } + } + + /** + * Update the value for a setting. + */ + public function updateValue(string $id, string $value): void + { + foreach ($this->items as $item) { + if ($item->getId() === $id) { + if ($item->getCurrentValue() !== $value) { + $item->setCurrentValue($value); + $this->invalidate(); + } + break; + } + } + } + + /** + * Get the current value for a setting. + */ + public function getValue(string $id): ?string + { + foreach ($this->items as $item) { + if ($item->getId() === $id) { + return $item->getCurrentValue(); + } + } + + return null; + } + + /** + * @param callable(SettingChangeEvent): void $callback + * + * @return $this + */ + public function onChange(callable $callback): self + { + return $this->on(SettingChangeEvent::class, $callback); + } + + /** + * @param callable(CancelEvent): void $callback + * + * @return $this + */ + public function onCancel(callable $callback): self + { + return $this->on(CancelEvent::class, $callback); + } + + public function all(): array + { + if (null !== $this->activeSubmenu) { + return [$this->activeSubmenu]; + } + + return []; + } + + public function handleInput(string $data): void + { + if (null !== $this->onInput && ($this->onInput)($data)) { + return; + } + + // If submenu is active, forward input to it + if (null !== $this->activeSubmenu) { + $this->activeSubmenu->handleInput($data); + $this->invalidate(); + + return; + } + + if ([] === $this->items) { + return; + } + + $kb = $this->getKeybindings(); + + // Navigation + if ($kb->matches($data, 'select_up')) { + $nextIndex = max(0, $this->selectedIndex - 1); + if ($this->selectedIndex !== $nextIndex) { + $this->selectedIndex = $nextIndex; + $this->invalidate(); + } + + return; + } + + if ($kb->matches($data, 'select_down')) { + $nextIndex = min(\count($this->items) - 1, $this->selectedIndex + 1); + if ($this->selectedIndex !== $nextIndex) { + $this->selectedIndex = $nextIndex; + $this->invalidate(); + } + + return; + } + + if ($kb->matches($data, 'select_page_up')) { + $nextIndex = max(0, $this->selectedIndex - $this->maxVisible); + if ($this->selectedIndex !== $nextIndex) { + $this->selectedIndex = $nextIndex; + $this->invalidate(); + } + + return; + } + + if ($kb->matches($data, 'select_page_down')) { + $nextIndex = min(\count($this->items) - 1, $this->selectedIndex + $this->maxVisible); + if ($this->selectedIndex !== $nextIndex) { + $this->selectedIndex = $nextIndex; + $this->invalidate(); + } + + return; + } + + // Activate (cycle value or open submenu) + if ($kb->matches($data, 'select_confirm') || ' ' === $data) { + $this->activateCurrentItem(); + + return; + } + + // Cycle value forward (Right arrow) + if ($kb->matches($data, 'cursor_right')) { + $this->cycleValue(1); + + return; + } + + // Cycle value backward (Left arrow) + if ($kb->matches($data, 'cursor_left')) { + $this->cycleValue(-1); + + return; + } + + // Cancel + if ($kb->matches($data, 'select_cancel')) { + $this->dispatch(new CancelEvent($this)); + } + } + + /** + * @return string[] + */ + public function render(RenderContext $context): array + { + $columns = $context->getColumns(); + + // If submenu is active, render it through the Renderer pipeline + // so its style (padding, border, background) is properly applied + if (null !== $this->activeSubmenu && null !== ($widgetContext = $this->getContext())) { + return $widgetContext->renderWidget($this->activeSubmenu, $context); + } + + $lines = []; + + // Calculate visible range + $startIndex = max( + 0, + min( + $this->selectedIndex - (int) floor($this->maxVisible / 2), + \count($this->items) - $this->maxVisible, + ), + ); + $endIndex = min($startIndex + $this->maxVisible, \count($this->items)); + + // Render items + for ($i = $startIndex; $i < $endIndex; ++$i) { + $item = $this->items[$i]; + $isSelected = $i === $this->selectedIndex; + + $line = $this->renderItem($item, $isSelected, $columns); + $lines[] = $line; + + // Add description for selected item + if ($isSelected && null !== $item->getDescription()) { + $descLine = ' '.$this->applyElement('description', $item->getDescription()); + $descLine = AnsiUtils::truncateToWidth($descLine, $columns); + $lines[] = $descLine; + } + } + + // Add hint + $hint = $this->applyElement('hint', ' ↑↓ Navigate Enter/Space Activate Esc Cancel'); + $hint = AnsiUtils::truncateToWidth($hint, $columns); + $lines[] = $hint; + + return $lines; + } + + protected function onDetach(): void + { + $this->removeSubmenuListeners(); + $this->activeSubmenu = null; + } + + /** + * @return array + */ + protected static function getDefaultKeybindings(): array + { + return [ + 'select_up' => [Key::UP], + 'select_down' => [Key::DOWN], + 'select_page_up' => [Key::PAGE_UP], + 'select_page_down' => [Key::PAGE_DOWN], + 'select_confirm' => [Key::ENTER], + 'select_cancel' => [Key::ESCAPE, 'ctrl+c'], + 'cursor_left' => [Key::LEFT, 'ctrl+b'], + 'cursor_right' => [Key::RIGHT, 'ctrl+f'], + ]; + } + + /** + * Cycle the current item's value forward or backward. + */ + private function cycleValue(int $direction): void + { + if (!isset($this->items[$this->selectedIndex])) { + return; + } + + $item = $this->items[$this->selectedIndex]; + + // Only cycle if item has predefined values + if (!$item->hasValues()) { + return; + } + + $values = $item->getValues(); + $valueCount = \count($values); + $currentIndex = array_search($item->getCurrentValue(), $values, true); + $currentIndex = false === $currentIndex ? 0 : (int) $currentIndex; + + // Calculate next index with wrapping + $nextIndex = ($currentIndex + $direction + $valueCount) % $valueCount; + $newValue = $values[$nextIndex]; + + $item->setCurrentValue($newValue); + $this->invalidate(); + $this->dispatch(new SettingChangeEvent($this, $item->getId(), $newValue)); + } + + private function renderItem(SettingItem $item, bool $isSelected, int $columns): string + { + $cursor = $isSelected ? '→ ' : ' '; + $label = $isSelected + ? $this->applyElement('label-selected', $item->getLabel()) + : $item->getLabel(); + $value = $isSelected + ? $this->applyElement('value-selected', $item->getCurrentValue()) + : $this->applyElement('value', $item->getCurrentValue()); + + // Calculate spacing + $labelWidth = AnsiUtils::visibleWidth($cursor.$label); + $valueWidth = AnsiUtils::visibleWidth($value); + $spacing = max(1, $columns - $labelWidth - $valueWidth - 2); + + $line = $cursor.$label.str_repeat(' ', $spacing).$value; + + return AnsiUtils::truncateToWidth($line, $columns); + } + + private function activateCurrentItem(): void + { + if (!isset($this->items[$this->selectedIndex])) { + return; + } + + $item = $this->items[$this->selectedIndex]; + + // If item has predefined values, cycle through them + if ($item->hasValues()) { + $values = $item->getValues(); + $currentIndex = array_search($item->getCurrentValue(), $values, true); + $nextIndex = (false === $currentIndex ? 0 : (int) $currentIndex + 1) % \count($values); + $newValue = $values[$nextIndex]; + + $item->setCurrentValue($newValue); + $this->invalidate(); + $this->dispatch(new SettingChangeEvent($this, $item->getId(), $newValue)); + + return; + } + + // If item has a submenu, open it + if ($item->hasSubmenu()) { + $onDone = function (?string $selectedValue) use ($item): void { + $this->removeSubmenuListeners(); + + if (null !== $this->activeSubmenu) { + $context = $this->getContext(); + if (null !== $context) { + $context->detachChild($this->activeSubmenu); + } + } + $this->activeSubmenu = null; + + if (null !== $selectedValue) { + $item->setCurrentValue($selectedValue); + $this->invalidate(); + $this->dispatch(new SettingChangeEvent($this, $item->getId(), $selectedValue)); + } else { + $this->invalidate(); + } + }; + + $this->activeSubmenu = ($item->getSubmenu())( + $item->getCurrentValue(), + $onDone, + ); + + $submenu = $this->activeSubmenu; + $context = $this->getContext(); + if (null !== $context) { + $context->attachChild($this, $submenu); + + // Wire submenu events: when the inner widget dispatches + // SelectEvent or CancelEvent, route to the onDone callback + $dispatcher = $context->getEventDispatcher(); + $selectListener = function (SelectEvent $e) use ($submenu, $onDone): void { + if ($e->getTarget() === $submenu) { + $onDone($e->getValue()); + } + }; + $cancelListener = function (CancelEvent $e) use ($submenu, $onDone): void { + if ($e->getTarget() === $submenu) { + $onDone(null); + } + }; + $dispatcher->addListener(SelectEvent::class, $selectListener); + $dispatcher->addListener(CancelEvent::class, $cancelListener); + $this->submenuListeners = [ + [SelectEvent::class, $selectListener], + [CancelEvent::class, $cancelListener], + ]; + } + $this->invalidate(); + } + } + + private function removeSubmenuListeners(): void + { + if ([] === $this->submenuListeners) { + return; + } + + $context = $this->getContext(); + if (null !== $context) { + $dispatcher = $context->getEventDispatcher(); + foreach ($this->submenuListeners as [$eventClass, $listener]) { + $dispatcher->removeListener($eventClass, $listener); + } + } + $this->submenuListeners = []; + } +} diff --git a/src/Symfony/Component/Tui/Widget/TextWidget.php b/src/Symfony/Component/Tui/Widget/TextWidget.php new file mode 100644 index 0000000000000..a12cbb61843ef --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/TextWidget.php @@ -0,0 +1,140 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Ansi\TextWrapper; +use Symfony\Component\Tui\Render\RenderContext; +use Symfony\Component\Tui\Widget\Figlet\FigletRenderer; + +/** + * Text component - displays text with word wrapping or truncation. + * + * When truncate is false (default), text wraps to multiple lines. + * When truncate is true, each line is truncated to fit the width with an ellipsis. + * + * When a FIGlet font is set via the Style system, the text is rendered as large + * ASCII art instead. Bundled fonts: big, small, slant, standard, mini. + * Custom fonts can be registered via the FontRegistry. + * + * Font can be set via stylesheet rules, CSS classes, or Tailwind utility classes: + * + * // Stylesheet rule + * $stylesheet->addRule('.title', new Style(font: 'big')); + * + * // Tailwind utility class + * $widget->addStyleClass('font-big'); + * + * // Template + * Hello + * + * @experimental + * + * @author Fabien Potencier + */ +class TextWidget extends AbstractWidget +{ + /** + * @param string $text Text content to display + * @param bool $truncate When true, truncate lines to fit width instead of wrapping + */ + public function __construct( + private string $text = '', + private bool $truncate = false, + ) { + } + + /** + * @return $this + */ + public function setText(string $text): self + { + $this->text = $text; + $this->invalidate(); + + return $this; + } + + /** + * Get the text content. + */ + public function getText(): string + { + return $this->text; + } + + /** + * @return string[] + */ + public function render(RenderContext $context): array + { + // Don't render anything if there's no actual text + if ('' === $this->text || '' === trim($this->text)) { + return []; + } + + $font = $context->getStyle()->getFont(); + + if (null !== $font) { + return $this->renderFiglet($context, $font); + } + + return $this->renderText($context); + } + + /** + * @return string[] + */ + private function renderText(RenderContext $context): array + { + // Replace tabs with 3 spaces + $normalizedText = str_replace("\t", ' ', $this->text); + + // Context already has inner dimensions (chrome subtracted by the Renderer) + $contentColumns = $context->getColumns(); + + // Either truncate or wrap based on mode + if ($this->truncate) { + $lines = explode("\n", $normalizedText); + $processedLines = []; + foreach ($lines as $line) { + $processedLines[] = AnsiUtils::truncateToWidth($line, $contentColumns); + } + } else { + $processedLines = TextWrapper::wrapTextWithAnsi($normalizedText, $contentColumns); + } + + return [] !== $processedLines ? $processedLines : ['']; + } + + /** + * @return string[] + */ + private function renderFiglet(RenderContext $context, string $fontName): array + { + $font = $context->getFontRegistry()->get($fontName); + $renderer = new FigletRenderer($font); + $lines = $renderer->render($this->text); + + // Truncate lines that exceed available width (ANSI-aware) + $truncated = []; + foreach ($lines as $line) { + if (AnsiUtils::visibleWidth($line) > $context->getColumns()) { + $truncated[] = AnsiUtils::truncateToWidth($line, $context->getColumns(), ''); + } else { + $truncated[] = $line; + } + } + + return $truncated; + } +} diff --git a/src/Symfony/Component/Tui/Widget/Util/KillRing.php b/src/Symfony/Component/Tui/Widget/Util/KillRing.php new file mode 100644 index 0000000000000..bb29f9a7228c6 --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/Util/KillRing.php @@ -0,0 +1,141 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget\Util; + +/** + * Emacs-style kill ring for cut/paste operations. + * + * Consecutive kill operations are appended to the same entry. + * Yank/yank-pop cycles through the ring. + * + * @experimental + * + * @author Fabien Potencier + */ +class KillRing +{ + /** @var string[] */ + private array $entries = []; + private ?string $lastAction = null; + + /** + * @var array{startLine: int, startCol: int, endLine: int, endCol: int}|null + */ + private ?array $lastYank = null; + + public function __construct( + private int $maxEntries = 50, + ) { + } + + /** + * Add text to the kill ring. + * + * If the last action was also a kill, the text is appended/prepended + * to the most recent entry (consecutive kills accumulate). + */ + public function add(string $text, bool $prepend): void + { + if ('' === $text) { + return; + } + + if ('kill' === $this->lastAction && [] !== $this->entries) { + $lastIndex = \count($this->entries) - 1; + $current = $this->entries[$lastIndex]; + $this->entries[$lastIndex] = $prepend ? $text.$current : $current.$text; + } else { + $this->entries[] = $text; + if (\count($this->entries) > $this->maxEntries) { + array_shift($this->entries); + } + } + + $this->lastAction = 'kill'; + $this->lastYank = null; + } + + /** + * Get the most recent kill ring entry for yanking. + */ + public function peek(): ?string + { + if ([] === $this->entries) { + return null; + } + + return $this->entries[\count($this->entries) - 1]; + } + + /** + * Whether yank-pop is available (last action was yank and ring has > 1 entry). + */ + public function canYankPop(): bool + { + return 'yank' === $this->lastAction && \count($this->entries) > 1 && null !== $this->lastYank; + } + + /** + * Rotate the ring for yank-pop and return the new top entry. + * + * Moves the last entry to the front and returns the new last entry. + */ + public function rotate(): ?string + { + if (\count($this->entries) <= 1) { + return null; + } + + $lastEntry = array_pop($this->entries); + array_unshift($this->entries, $lastEntry); + + return $this->entries[\count($this->entries) - 1]; + } + + /** + * Record that a yank happened (for yank-pop tracking). + * + * @param array{startLine: int, startCol: int, endLine: int, endCol: int} $range + */ + public function recordYank(array $range): void + { + $this->lastYank = $range; + $this->lastAction = 'yank'; + } + + /** + * Get the range of the last yank (for deletion before yank-pop). + * + * @return array{startLine: int, startCol: int, endLine: int, endCol: int}|null + */ + public function getLastYankRange(): ?array + { + return $this->lastYank; + } + + /** + * Reset the action tracking (called after non-kill/yank operations). + */ + public function resetAction(): void + { + $this->lastAction = null; + } + + /** + * Reset both action and yank tracking (called after undo). + */ + public function resetAll(): void + { + $this->lastAction = null; + $this->lastYank = null; + } +} diff --git a/src/Symfony/Component/Tui/Widget/Util/Line.php b/src/Symfony/Component/Tui/Widget/Util/Line.php new file mode 100644 index 0000000000000..f26c6e348f377 --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/Util/Line.php @@ -0,0 +1,289 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget\Util; + +/** + * Grapheme-aware single-line text buffer with cursor position. + * + * Encapsulates text content and a byte-offset cursor. Every editing method + * mutates internal state and returns whether the operation had an effect, + * letting the caller decide whether to invalidate, push undo snapshots, etc. + * + * Both InputWidget (single-line) and EditorWidget (multi-line, per-line) + * delegate grapheme-level operations here. + * + * @experimental + * + * @author Fabien Potencier + */ +final class Line +{ + private string $text; + private int $cursor; + + public function __construct(string $text = '', int $cursor = 0) + { + $this->text = $text; + $this->cursor = max(0, min($cursor, \strlen($text))); + } + + public function getText(): string + { + return $this->text; + } + + public function getCursor(): int + { + return $this->cursor; + } + + public function setText(string $text): void + { + $this->text = $text; + $this->cursor = min($this->cursor, \strlen($text)); + } + + public function setCursor(int $cursor): void + { + $this->cursor = max(0, min($cursor, \strlen($this->text))); + } + + /** + * Insert text at the cursor position. + */ + public function insert(string $insertion): void + { + $this->text = substr($this->text, 0, $this->cursor).$insertion.substr($this->text, $this->cursor); + $this->cursor += \strlen($insertion); + } + + /** + * Delete one grapheme backward (backspace). + */ + public function deleteCharBackward(): bool + { + if (0 === $this->cursor) { + return false; + } + + $beforeCursor = substr($this->text, 0, $this->cursor); + $graphemes = grapheme_str_split($beforeCursor); + + if (false === $graphemes || [] === $graphemes) { + return false; + } + + $lastGrapheme = array_pop($graphemes); + $this->text = implode('', $graphemes).substr($this->text, $this->cursor); + $this->cursor -= \strlen($lastGrapheme); + + return true; + } + + /** + * Delete one grapheme forward (delete key). + */ + public function deleteCharForward(): bool + { + if ($this->cursor >= \strlen($this->text)) { + return false; + } + + $afterCursor = substr($this->text, $this->cursor); + $graphemes = grapheme_str_split($afterCursor); + + if (false === $graphemes || [] === $graphemes) { + return false; + } + + $this->text = substr($this->text, 0, $this->cursor).substr($this->text, $this->cursor + \strlen($graphemes[0])); + + return true; + } + + /** + * Move one grapheme to the left. + */ + public function moveCursorLeft(): bool + { + if (0 === $this->cursor) { + return false; + } + + $beforeCursor = substr($this->text, 0, $this->cursor); + $graphemes = grapheme_str_split($beforeCursor); + + if (false === $graphemes || [] === $graphemes) { + return false; + } + + /** @var string $lastGrapheme */ + $lastGrapheme = array_pop($graphemes); + $this->cursor -= \strlen($lastGrapheme); + + return true; + } + + /** + * Move one grapheme to the right. + */ + public function moveCursorRight(): bool + { + if ($this->cursor >= \strlen($this->text)) { + return false; + } + + $afterCursor = substr($this->text, $this->cursor); + $graphemes = grapheme_str_split($afterCursor); + + if (false === $graphemes || [] === $graphemes) { + return false; + } + + $this->cursor += \strlen($graphemes[0]); + + return true; + } + + /** + * Move cursor to the beginning of the line. + */ + public function moveCursorToStart(): bool + { + if (0 === $this->cursor) { + return false; + } + + $this->cursor = 0; + + return true; + } + + /** + * Move cursor to the end of the line. + */ + public function moveCursorToEnd(): bool + { + $end = \strlen($this->text); + if ($this->cursor === $end) { + return false; + } + + $this->cursor = $end; + + return true; + } + + /** + * Move cursor one word backward. + */ + public function moveWordBackward(): bool + { + if (0 === $this->cursor) { + return false; + } + + $newCursor = WordNavigator::skipWordBackward($this->text, $this->cursor); + if ($newCursor === $this->cursor) { + return false; + } + + $this->cursor = $newCursor; + + return true; + } + + /** + * Move cursor one word forward. + */ + public function moveWordForward(): bool + { + if ($this->cursor >= \strlen($this->text)) { + return false; + } + + $newCursor = WordNavigator::skipWordForward($this->text, $this->cursor); + if ($newCursor === $this->cursor) { + return false; + } + + $this->cursor = $newCursor; + + return true; + } + + /** + * Delete one word backward and return the deleted text. + */ + public function deleteWordBackward(): string + { + if (0 === $this->cursor) { + return ''; + } + + $deleteFrom = WordNavigator::skipWordBackward($this->text, $this->cursor); + $deletedText = substr($this->text, $deleteFrom, $this->cursor - $deleteFrom); + + $this->text = substr($this->text, 0, $deleteFrom).substr($this->text, $this->cursor); + $this->cursor = $deleteFrom; + + return $deletedText; + } + + /** + * Delete one word forward and return the deleted text. + */ + public function deleteWordForward(): string + { + if ($this->cursor >= \strlen($this->text)) { + return ''; + } + + $deleteTo = WordNavigator::skipWordForward($this->text, $this->cursor); + $deletedText = substr($this->text, $this->cursor, $deleteTo - $this->cursor); + + $this->text = substr($this->text, 0, $this->cursor).substr($this->text, $deleteTo); + + return $deletedText; + } + + /** + * Delete from cursor to end of line and return the deleted text. + */ + public function deleteToEnd(): string + { + $deletedText = substr($this->text, $this->cursor); + if ('' === $deletedText) { + return ''; + } + + $this->text = substr($this->text, 0, $this->cursor); + + return $deletedText; + } + + /** + * Delete from start of line to cursor and return the deleted text. + */ + public function deleteToStart(): string + { + $deletedText = substr($this->text, 0, $this->cursor); + if ('' === $deletedText) { + return ''; + } + + $this->text = substr($this->text, $this->cursor); + $this->cursor = 0; + + return $deletedText; + } +} diff --git a/src/Symfony/Component/Tui/Widget/Util/StringUtils.php b/src/Symfony/Component/Tui/Widget/Util/StringUtils.php new file mode 100644 index 0000000000000..17d4ccfb0e904 --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/Util/StringUtils.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget\Util; + +/** + * General-purpose string utilities for terminal input handling. + * + * @experimental + * + * @author Fabien Potencier + */ +final class StringUtils +{ + /** + * Check if the input data contains control characters (C0 controls + DEL). + * + * Only checks for ASCII control characters (0x00-0x1F and 0x7F). + * Does NOT check for C1 control characters (U+0080-U+009F) at the byte + * level, because bytes 0x80-0x9F are valid UTF-8 continuation bytes used + * in multi-byte characters like emojis (e.g. 😀 = \xF0\x9F\x98\x80). + */ + public static function hasControlChars(string $data): bool + { + for ($i = 0; $i < \strlen($data); ++$i) { + $code = \ord($data[$i]); + if ($code < 32 || 0x7F === $code) { + return true; + } + } + + return false; + } + + /** + * Sanitize a string by removing invalid UTF-8 byte sequences. + */ + public static function sanitizeUtf8(string $value): string + { + if ('' === $value || false !== preg_match('//u', $value)) { + return $value; + } + + $sanitized = @iconv('UTF-8', 'UTF-8//IGNORE', $value); + + return false === $sanitized ? '' : $sanitized; + } +} diff --git a/src/Symfony/Component/Tui/Widget/Util/WordNavigator.php b/src/Symfony/Component/Tui/Widget/Util/WordNavigator.php new file mode 100644 index 0000000000000..8f98bbac63518 --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/Util/WordNavigator.php @@ -0,0 +1,126 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget\Util; + +use Symfony\Component\Tui\Ansi\AnsiUtils; + +/** + * Grapheme-aware word navigation for text editing. + * + * Provides cursor movement logic: skip whitespace, then skip a punctuation run + * or a word run. Both InputWidget and EditorWidget delegate to this class for + * within-line word navigation. + * + * @experimental + * + * @author Fabien Potencier + */ +final class WordNavigator +{ + /** + * Returns the new cursor position after moving one word backward within + * the given text. The cursor is a byte offset. + * + * Algorithm: skip trailing whitespace, then skip a punctuation run or a + * word-character run. + */ + public static function skipWordBackward(string $text, int $cursor): int + { + if (0 === $cursor) { + return 0; + } + + $graphemes = grapheme_str_split(substr($text, 0, $cursor)); + if (false === $graphemes) { + return $cursor; + } + + $newCursor = $cursor; + + // Skip trailing whitespace + while ([] !== $graphemes && AnsiUtils::isWhitespace(end($graphemes))) { + $newCursor -= \strlen(array_pop($graphemes)); + } + + if ([] !== $graphemes) { + /** @var string $lastGrapheme */ + $lastGrapheme = end($graphemes); + if (AnsiUtils::isPunctuation($lastGrapheme)) { + // Skip punctuation run + while ([] !== $graphemes && AnsiUtils::isPunctuation(end($graphemes))) { + $newCursor -= \strlen(array_pop($graphemes)); + } + } else { + // Skip word run + while ([] !== $graphemes + && !AnsiUtils::isWhitespace(end($graphemes)) + && !AnsiUtils::isPunctuation(end($graphemes))) { + $newCursor -= \strlen(array_pop($graphemes)); + } + } + } + + return max(0, $newCursor); + } + + /** + * Returns the new cursor position after moving one word forward within + * the given text. The cursor is a byte offset. + * + * Algorithm: skip leading whitespace, then skip a punctuation run or a + * word-character run. + */ + public static function skipWordForward(string $text, int $cursor): int + { + $textLength = \strlen($text); + if ($cursor >= $textLength) { + return $cursor; + } + + $graphemes = grapheme_str_split(substr($text, $cursor)); + if (false === $graphemes) { + return $cursor; + } + + $newCursor = $cursor; + $index = 0; + $count = \count($graphemes); + + // Skip leading whitespace + while ($index < $count && AnsiUtils::isWhitespace($graphemes[$index])) { + $newCursor += \strlen($graphemes[$index]); + ++$index; + } + + if ($index < $count) { + if (AnsiUtils::isPunctuation($graphemes[$index])) { + // Skip punctuation run + while ($index < $count && AnsiUtils::isPunctuation($graphemes[$index])) { + $newCursor += \strlen($graphemes[$index]); + ++$index; + } + } else { + // Skip word run + while ($index < $count) { + $segment = $graphemes[$index]; + if (AnsiUtils::isWhitespace($segment) || AnsiUtils::isPunctuation($segment)) { + break; + } + $newCursor += \strlen($segment); + ++$index; + } + } + } + + return $newCursor; + } +} diff --git a/src/Symfony/Component/Tui/Widget/VerticallyExpandableInterface.php b/src/Symfony/Component/Tui/Widget/VerticallyExpandableInterface.php new file mode 100644 index 0000000000000..4652d05b3d750 --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/VerticallyExpandableInterface.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +/** + * Interface for widgets that can expand to fill available vertical space. + * + * When a widget implements this interface and vertical expansion is enabled, + * it will expand to use available vertical space in its parent container. + * In vertical layouts, multiple expanded siblings share the space equally. + * In horizontal layouts, all children receive the full available height. + * + * @experimental + * + * @author Fabien Potencier + */ +interface VerticallyExpandableInterface +{ + /** + * Set whether the widget should expand to fill available height. + * + * @return $this + */ + public function expandVertically(bool $expand): self; + + /** + * Check if the widget should expand to fill available height. + */ + public function isVerticallyExpanded(): bool; +} diff --git a/src/Symfony/Component/Tui/Widget/WidgetContext.php b/src/Symfony/Component/Tui/Widget/WidgetContext.php new file mode 100644 index 0000000000000..c9c4652e20782 --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/WidgetContext.php @@ -0,0 +1,137 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Tui\Event\AbstractEvent; +use Symfony\Component\Tui\Focus\FocusManager; +use Symfony\Component\Tui\Input\Keybindings; +use Symfony\Component\Tui\Render\RenderContext; +use Symfony\Component\Tui\Render\Renderer; +use Symfony\Component\Tui\Style\Style; +use Symfony\Component\Tui\Terminal\TerminalInterface; +use Symfony\Component\Tui\Tui; + +/** + * Runtime context provided to widgets when attached to the tree. + * + * @experimental + * + * @author Fabien Potencier + */ +final class WidgetContext +{ + /** @var array */ + private array $tickIds = []; + + public function __construct( + private readonly Tui $tui, + private readonly Keybindings $keybindings, + private readonly TerminalInterface $terminal, + private readonly FocusManager $focusManager, + private readonly Renderer $renderer, + private readonly WidgetTree $widgetTree, + private readonly EventDispatcherInterface $eventDispatcher, + ) { + } + + public function keybindings(): Keybindings + { + return $this->keybindings; + } + + public function stop(): void + { + $this->tui->stop(); + } + + public function requestRender(bool $force = false): void + { + $this->tui->requestRender($force); + } + + public function dispatch(AbstractEvent $event): void + { + $this->eventDispatcher->dispatch($event); + $this->tui->requestRender(); + } + + public function getEventDispatcher(): EventDispatcherInterface + { + return $this->eventDispatcher; + } + + public function resolveElement(AbstractWidget $widget, string $element): Style + { + return $this->renderer->getStyleSheet()->resolveElement($widget, $element); + } + + public function getTerminalColumns(): int + { + return $this->terminal->getColumns(); + } + + public function getTerminalRows(): int + { + return $this->terminal->getRows(); + } + + /** + * @internal + */ + public function getFocusManager(): FocusManager + { + return $this->focusManager; + } + + /** + * @return string[] + */ + public function renderWidget(AbstractWidget $widget, RenderContext $context): array + { + return $this->renderer->renderWidget($widget, $context); + } + + public function scheduleTick(callable $callback, float $intervalSeconds): string + { + $id = $this->tui->scheduleInterval($callback, $intervalSeconds); + $this->tickIds[$id] = $id; + + return $id; + } + + public function cancelTick(string $id): void + { + if (!isset($this->tickIds[$id])) { + return; + } + + $this->tui->cancelInterval($id); + unset($this->tickIds[$id]); + } + + /** + * @internal + */ + public function attachChild(AbstractWidget $parent, AbstractWidget $child): void + { + $this->widgetTree->attach($child, $parent); + } + + /** + * @internal + */ + public function detachChild(AbstractWidget $child): void + { + $this->widgetTree->detach($child); + } +} diff --git a/src/Symfony/Component/Tui/Widget/WidgetTree.php b/src/Symfony/Component/Tui/Widget/WidgetTree.php new file mode 100644 index 0000000000000..4df6e1c91b1dd --- /dev/null +++ b/src/Symfony/Component/Tui/Widget/WidgetTree.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Tui\Focus\FocusManager; +use Symfony\Component\Tui\Input\Keybindings; +use Symfony\Component\Tui\Render\Renderer; +use Symfony\Component\Tui\Terminal\TerminalInterface; +use Symfony\Component\Tui\Tui; + +/** + * Internal widget tree manager. + * + * @experimental + * + * @author Fabien Potencier + */ +final class WidgetTree +{ + private WidgetContext $context; + private readonly TerminalInterface $terminal; + private ?AbstractWidget $root = null; + + public function __construct( + Tui $tui, + Keybindings $keybindings, + FocusManager $focusManager, + Renderer $renderer, + TerminalInterface $terminal, + EventDispatcherInterface $eventDispatcher, + ) { + $this->terminal = $terminal; + $this->context = new WidgetContext( + $tui, + $keybindings, + $this->terminal, + $focusManager, + $renderer, + $this, + $eventDispatcher, + ); + } + + public function setRoot(AbstractWidget $root): void + { + if ($this->root === $root) { + return; + } + + if (null !== $this->root) { + $this->detach($this->root); + } + + $this->root = $root; + $this->attach($root, null); + } + + public function attach(AbstractWidget $widget, ?AbstractWidget $parent): void + { + $widget->attach($parent, $this->context); + + if ($widget instanceof ParentInterface) { + foreach ($widget->all() as $child) { + $this->attach($child, $widget); + } + } + } + + public function detach(AbstractWidget $widget): void + { + if ($widget instanceof ParentInterface) { + foreach ($widget->all() as $child) { + $this->detach($child); + } + } + + $cleanup = $widget->collectTerminalCleanupSequence(); + $widget->detach(); + + if ('' !== $cleanup) { + $this->terminal->write($cleanup); + } + } +} diff --git a/src/Symfony/Component/Tui/composer.json b/src/Symfony/Component/Tui/composer.json new file mode 100644 index 0000000000000..a0a9157ad5523 --- /dev/null +++ b/src/Symfony/Component/Tui/composer.json @@ -0,0 +1,41 @@ +{ + "name": "symfony/tui", + "type": "library", + "description": "Provides a terminal UI framework for building rich, interactive CLI applications in PHP", + "keywords": ["tui", "terminal", "console", "cli"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.4", + "revolt/event-loop": "^1.0", + "symfony/console": "^8.0", + "symfony/event-dispatcher": "^8.0", + "symfony/mime": "^8.0", + "symfony/string": "^8.0", + "twig/twig": "^3.0" + }, + "require-dev": { + "league/commonmark": "^2.9", + "symfony/process": "^8.0", + "tempest/highlight": "^2.16" + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Tui\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/Tui/phpstan.neon.dist b/src/Symfony/Component/Tui/phpstan.neon.dist new file mode 100644 index 0000000000000..1d5823a9bc726 --- /dev/null +++ b/src/Symfony/Component/Tui/phpstan.neon.dist @@ -0,0 +1,4 @@ +parameters: + level: 7 + paths: + - src diff --git a/src/Symfony/Component/Tui/phpunit.xml.dist b/src/Symfony/Component/Tui/phpunit.xml.dist new file mode 100644 index 0000000000000..d86a7461444a9 --- /dev/null +++ b/src/Symfony/Component/Tui/phpunit.xml.dist @@ -0,0 +1,34 @@ + + + + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Resources + ./Tests + ./vendor + + + From a037eb84bdf4fe412b695ed6b0a0bb505de34253 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Thu, 26 Mar 2026 11:50:34 +0100 Subject: [PATCH 02/22] Replace .root class selector with :root pseudo-class for root widget Instead of adding a 'root' style class to the root widget (which could conflict with other widgets using the same class name), the root widget is now identified by the :root pseudo-class, matching CSS semantics. Changes: - AbstractWidget::getStateFlags() returns 'root' when the widget has no parent - StyleSheet::resolve() supports standalone :state selectors (e.g. :root) - Tui no longer adds the 'root' style class to the root container - Users should use ':root' instead of '.root' in their stylesheets --- .../Component/Tui/Style/StyleSheet.php | 14 +++++++-- .../Tui/Tests/Style/StyleSheetTest.php | 29 +++++++++++++++++++ src/Symfony/Component/Tui/Tests/TuiTest.php | 3 +- src/Symfony/Component/Tui/Tui.php | 5 ++-- .../Component/Tui/Widget/AbstractWidget.php | 4 +++ 5 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/Symfony/Component/Tui/Style/StyleSheet.php b/src/Symfony/Component/Tui/Style/StyleSheet.php index a05e38edc131a..eb9b84ca54748 100644 --- a/src/Symfony/Component/Tui/Style/StyleSheet.php +++ b/src/Symfony/Component/Tui/Style/StyleSheet.php @@ -21,6 +21,7 @@ * - FQCN with state: 'Symfony\Component\Tui\Widget\Input:focused' * - CSS class: '.sidebar' * - CSS class with state: '.sidebar:focused' + * - Standalone pseudo-class: ':root' (matches the root widget) * - Universal: '*' (matches all widgets) * - Sub-element (pseudo-element): SelectList::class.'::selected' * - Sub-element with state: SelectList::class.'::selected:focused' @@ -33,7 +34,7 @@ * 1. Universal selector ('*') * 2. Widget FQCN selector (e.g., Text::class) * 3. CSS class selectors (e.g., '.header') - * 4. State selectors (e.g., Input::class.':focused') + * 4. State selectors (e.g., ':root', Input::class.':focused') * 5. Instance style (widget's own setStyle()) * * All style properties use `null` to mean "inherit from earlier rules": @@ -229,8 +230,13 @@ public function resolve(AbstractWidget $widget, ?int $columns = null): Style } } - // 4. State selectors (applied to FQCN hierarchy and CSS classes) + // 4. State selectors (applied standalone, to FQCN hierarchy and CSS classes) foreach ($widget->getStateFlags() as $state) { + // :state (standalone pseudo-class, e.g. :root) + if (isset($this->rules[':'.$state])) { + $applicableStyles[] = $this->rules[':'.$state]; + } + // FQCN:state (walk class hierarchy, parent classes first) foreach ($classHierarchy as $class) { $classStateSelector = $class.':'.$state; @@ -401,6 +407,10 @@ protected function resolveBreakpoints(AbstractWidget $widget, int $columns, arra } foreach ($widget->getStateFlags() as $state) { + if (isset($rules[':'.$state])) { + $applicableStyles[] = $rules[':'.$state]; + } + foreach ($classHierarchy as $class) { $classStateSelector = $class.':'.$state; if (isset($rules[$classStateSelector])) { diff --git a/src/Symfony/Component/Tui/Tests/Style/StyleSheetTest.php b/src/Symfony/Component/Tui/Tests/Style/StyleSheetTest.php index 99e2dfdf011cd..21484288731ec 100644 --- a/src/Symfony/Component/Tui/Tests/Style/StyleSheetTest.php +++ b/src/Symfony/Component/Tui/Tests/Style/StyleSheetTest.php @@ -246,6 +246,35 @@ public static function boolPropertyInheritanceProvider(): iterable yield 'dim' => ['withDim', static fn (Style $s) => $s->getDim()]; } + // --- Standalone pseudo-class selector tests --- + + public function testRootPseudoClassMatchesRootWidget() + { + $stylesheet = new StyleSheet() + ->addRule(':root', new Style()->withBold()); + + // A widget without parent is the root + $widget = new TextWidget('Hello'); + + $resolved = $stylesheet->resolve($widget); + + $this->assertTrue($resolved->getBold()); + } + + public function testRootPseudoClassDoesNotMatchChildWidget() + { + $stylesheet = new StyleSheet() + ->addRule(':root', new Style()->withBold()); + + $parent = new ContainerWidget(); + $child = new TextWidget('Hello'); + $parent->add($child); + + $resolved = $stylesheet->resolve($child); + + $this->assertNull($resolved->getBold()); + } + // --- Cascading Stylesheet Tests (via merge) --- public function testMergeSheetsRulesOverride() diff --git a/src/Symfony/Component/Tui/Tests/TuiTest.php b/src/Symfony/Component/Tui/Tests/TuiTest.php index 2c8606c1a7222..aac49ffbc8f73 100644 --- a/src/Symfony/Component/Tui/Tests/TuiTest.php +++ b/src/Symfony/Component/Tui/Tests/TuiTest.php @@ -204,10 +204,9 @@ public function testOnTickReceivesDeltaTime() public function testRenderAllLinesRespectWidth() { - $renderer = new Renderer(new StyleSheet(['.root' => new Style(gap: 1)])); + $renderer = new Renderer(new StyleSheet([':root' => new Style(gap: 1)])); $root = new ContainerWidget(); - $root->addStyleClass('root'); $root->add(new TextWidget('Short')); $root->add(new TextWidget('This is a longer text that should wrap properly.')->setStyle(Style::padding([0, 1]))); $root->add(new TextWidget('End')); diff --git a/src/Symfony/Component/Tui/Tui.php b/src/Symfony/Component/Tui/Tui.php index c55769e3aa8bf..7c9d7992b8f93 100644 --- a/src/Symfony/Component/Tui/Tui.php +++ b/src/Symfony/Component/Tui/Tui.php @@ -44,9 +44,9 @@ * - Focus management * - Input handling * - * The root container is created internally with the style class "root". + * The root container is created internally. * Use add(), remove(), and clear() to build the widget tree. - * Style the root via the stylesheet using the ".root" selector. + * Style the root via the stylesheet using the ":root" pseudo-class selector. * * Rendering is delegated to: * - Renderer: widget tree → lines (content generation) @@ -102,7 +102,6 @@ public function __construct( $this->keybindings = $keybindings ?? new Keybindings(); $this->root = new ContainerWidget(); $this->root->expandVertically(true); - $this->root->addStyleClass('root'); $this->renderer = $renderer ?? new Renderer($styleSheet, $fontRegistry); $this->screenWriter = $screenWriter ?? new ScreenWriter($terminal); $this->eventDispatcher = $eventDispatcher ?? new EventDispatcher(); diff --git a/src/Symfony/Component/Tui/Widget/AbstractWidget.php b/src/Symfony/Component/Tui/Widget/AbstractWidget.php index e61071450fbab..3d9b8b517d768 100644 --- a/src/Symfony/Component/Tui/Widget/AbstractWidget.php +++ b/src/Symfony/Component/Tui/Widget/AbstractWidget.php @@ -176,6 +176,10 @@ final public function getStateFlags(): array { $flags = []; + if (null === $this->parent) { + $flags[] = 'root'; + } + if ($this instanceof FocusableInterface && $this->isFocused()) { $flags[] = 'focused'; } From fe529f5cdef7045e78cc3a6a4a99703531221d60 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Thu, 26 Mar 2026 16:09:44 +0100 Subject: [PATCH 03/22] Rename :focused pseudo-class to :focus to match CSS conventions --- .../Component/Tui/Style/DefaultStyleSheet.php | 8 ++++---- src/Symfony/Component/Tui/Style/StyleSheet.php | 18 +++++++++--------- .../Component/Tui/Style/TailwindStylesheet.php | 2 +- .../Tui/Tests/Style/StyleSheetTest.php | 14 +++++++------- .../Tui/Tests/Style/TailwindStylesheetTest.php | 10 +++++----- .../Component/Tui/Widget/AbstractWidget.php | 4 ++-- 6 files changed, 28 insertions(+), 28 deletions(-) diff --git a/src/Symfony/Component/Tui/Style/DefaultStyleSheet.php b/src/Symfony/Component/Tui/Style/DefaultStyleSheet.php index fbec41454aec2..58c3b3224da7e 100644 --- a/src/Symfony/Component/Tui/Style/DefaultStyleSheet.php +++ b/src/Symfony/Component/Tui/Style/DefaultStyleSheet.php @@ -50,7 +50,7 @@ public static function create(): StyleSheet '.column' => new Style(), // CancellableLoaderWidget - CancellableLoaderWidget::class.':focused' => new Style()->withBold(), + CancellableLoaderWidget::class.':focus' => new Style()->withBold(), // LoaderWidget LoaderWidget::class.'::spinner' => new Style()->withColor('cyan'), @@ -65,17 +65,17 @@ public static function create(): StyleSheet // SelectListWidget SelectListWidget::class.'::selected' => new Style()->withBold(), - SelectListWidget::class.'::selected:focused' => new Style()->withBold(), + SelectListWidget::class.'::selected:focus' => new Style()->withBold(), SelectListWidget::class.'::description' => new Style()->withColor('gray'), SelectListWidget::class.'::scroll-info' => new Style()->withColor('gray'), SelectListWidget::class.'::no-match' => new Style()->withColor('yellow'), // SettingsListWidget SettingsListWidget::class.'::label-selected' => new Style()->withBold(), - SettingsListWidget::class.'::label-selected:focused' => new Style()->withBold(), + SettingsListWidget::class.'::label-selected:focus' => new Style()->withBold(), SettingsListWidget::class.'::value' => new Style()->withColor('gray'), SettingsListWidget::class.'::value-selected' => new Style()->withColor('cyan'), - SettingsListWidget::class.'::value-selected:focused' => new Style()->withColor('cyan'), + SettingsListWidget::class.'::value-selected:focus' => new Style()->withColor('cyan'), SettingsListWidget::class.'::description' => new Style()->withColor('gray'), SettingsListWidget::class.'::hint' => new Style()->withColor('gray'), diff --git a/src/Symfony/Component/Tui/Style/StyleSheet.php b/src/Symfony/Component/Tui/Style/StyleSheet.php index eb9b84ca54748..494fcee7ad990 100644 --- a/src/Symfony/Component/Tui/Style/StyleSheet.php +++ b/src/Symfony/Component/Tui/Style/StyleSheet.php @@ -18,15 +18,15 @@ * * Selectors can be: * - FQCN: 'Symfony\Component\Tui\Widget\Input' or Input::class - * - FQCN with state: 'Symfony\Component\Tui\Widget\Input:focused' + * - FQCN with state: 'Symfony\Component\Tui\Widget\Input:focus' * - CSS class: '.sidebar' - * - CSS class with state: '.sidebar:focused' + * - CSS class with state: '.sidebar:focus' * - Standalone pseudo-class: ':root' (matches the root widget) * - Universal: '*' (matches all widgets) * - Sub-element (pseudo-element): SelectList::class.'::selected' - * - Sub-element with state: SelectList::class.'::selected:focused' + * - Sub-element with state: SelectList::class.'::selected:focus' * - Class sub-element: '.my-list::selected' - * - Class sub-element with state: '.my-list::selected:focused' + * - Class sub-element with state: '.my-list::selected:focus' * * ## Style Inheritance * @@ -34,7 +34,7 @@ * 1. Universal selector ('*') * 2. Widget FQCN selector (e.g., Text::class) * 3. CSS class selectors (e.g., '.header') - * 4. State selectors (e.g., ':root', Input::class.':focused') + * 4. State selectors (e.g., ':root', Input::class.':focus') * 5. Instance style (widget's own setStyle()) * * All style properties use `null` to mean "inherit from earlier rules": @@ -196,7 +196,7 @@ public function getRules(): array * 1. Universal selector (*) * 2. FQCN selector (widget class and parent classes, parent first) * 3. CSS class selectors (.class): only classes from {@see getCssClasses()} - * 4. State selectors (:focused, :disabled, etc.) + * 4. State selectors (:focus, :disabled, etc.) * 5. Breakpoint rules (ascending min-columns order) * 6. Extra styles from subclasses (see {@see resolveExtraStyles()}) * 7. Instance style (widget's own style) @@ -280,13 +280,13 @@ public function resolve(AbstractWidget $widget, ?int $columns = null): Style * Resolution order (later overrides earlier): * 1. FQCN::element (e.g., SelectListWidget::class.'::selected') * 2. .class::element (e.g., '.my-list::selected') - * 3. FQCN::element:state (e.g., SelectListWidget::class.'::selected:focused') - * 4. .class::element:state (e.g., '.my-list::selected:focused') + * 3. FQCN::element:state (e.g., SelectListWidget::class.'::selected:focus') + * 4. .class::element:state (e.g., '.my-list::selected:focus') * * Example stylesheet rules: * * SelectListWidget::class.'::selected' => new Style()->withBold(), - * SelectListWidget::class.'::selected:focused' => new Style()->withBold()->withColor('cyan'), + * SelectListWidget::class.'::selected:focus' => new Style()->withBold()->withColor('cyan'), * '.my-list::selected' => new Style()->withColor('green'), */ public function resolveElement(AbstractWidget $widget, string $element): Style diff --git a/src/Symfony/Component/Tui/Style/TailwindStylesheet.php b/src/Symfony/Component/Tui/Style/TailwindStylesheet.php index 845bfc8ebb0ba..e9707bafc6576 100644 --- a/src/Symfony/Component/Tui/Style/TailwindStylesheet.php +++ b/src/Symfony/Component/Tui/Style/TailwindStylesheet.php @@ -88,7 +88,7 @@ * 1. Universal selector (*) * 2. FQCN selector * 3. CSS class selectors (.class): utility classes excluded - * 4. State selectors (:focused) + * 4. State selectors (:focus) * 5. Breakpoint rules * 6. **Utility class styles** (immutable, override all above) * 7. Instance style (widget's own setStyle()) diff --git a/src/Symfony/Component/Tui/Tests/Style/StyleSheetTest.php b/src/Symfony/Component/Tui/Tests/Style/StyleSheetTest.php index 21484288731ec..d1d1ea9f2052b 100644 --- a/src/Symfony/Component/Tui/Tests/Style/StyleSheetTest.php +++ b/src/Symfony/Component/Tui/Tests/Style/StyleSheetTest.php @@ -376,11 +376,11 @@ public function testMergeStateSelectors() { // Default sheet $defaultSheet = new StyleSheet() - ->addRule(InputWidget::class.':focused', new Style()->withBold()); + ->addRule(InputWidget::class.':focus', new Style()->withBold()); // User sheet $userSheet = new StyleSheet() - ->addRule(InputWidget::class.':focused', new Style()->withColor('yellow')); + ->addRule(InputWidget::class.':focus', new Style()->withColor('yellow')); // Merge replaces the state selector rule $merged = $defaultSheet->merge($userSheet); @@ -523,7 +523,7 @@ public function testResolveElementWithStateFlag() { $stylesheet = new StyleSheet([ InputWidget::class.'::cursor' => new Style()->withReverse(), - InputWidget::class.'::cursor:focused' => new Style()->withColor('cyan'), + InputWidget::class.'::cursor:focus' => new Style()->withColor('cyan'), ]); // Unfocused: only reverse @@ -543,7 +543,7 @@ public function testResolveElementClassWithStateFlag() { $stylesheet = new StyleSheet([ '.my-input::cursor' => new Style()->withReverse(), - '.my-input::cursor:focused' => new Style()->withColor('yellow'), + '.my-input::cursor:focus' => new Style()->withColor('yellow'), ]); $widget = new InputWidget(); @@ -567,7 +567,7 @@ public function testResolveElementCascadeOrder() $stylesheet = new StyleSheet([ SelectListWidget::class.'::selected' => new Style()->withColor('red'), '.themed::selected' => new Style()->withColor('blue'), - '.themed::selected:focused' => new Style()->withColor('green'), + '.themed::selected:focus' => new Style()->withColor('green'), ]); $items = [['value' => 'a', 'label' => 'A']]; @@ -579,7 +579,7 @@ public function testResolveElementCascadeOrder() // Blue from .themed overrides red from FQCN $this->assertSame(Color::named('blue')->toForegroundCode(), $style->getColor()->toForegroundCode()); - // Focused: .themed::selected:focused on top + // Focused: .themed::selected:focus on top $widget->setFocused(true); $style = $stylesheet->resolveElement($widget, 'selected'); $this->assertSame(Color::named('green')->toForegroundCode(), $style->getColor()->toForegroundCode()); @@ -730,7 +730,7 @@ public function testBreakpointWithFqcnSelector() public function testBreakpointWithStateSelector() { $stylesheet = new StyleSheet(); - $stylesheet->addBreakpoint(100, InputWidget::class.':focused', new Style()->withBold()); + $stylesheet->addBreakpoint(100, InputWidget::class.':focus', new Style()->withBold()); $widget = new InputWidget(); diff --git a/src/Symfony/Component/Tui/Tests/Style/TailwindStylesheetTest.php b/src/Symfony/Component/Tui/Tests/Style/TailwindStylesheetTest.php index 9a3bae81922f0..48c684d0e1a04 100644 --- a/src/Symfony/Component/Tui/Tests/Style/TailwindStylesheetTest.php +++ b/src/Symfony/Component/Tui/Tests/Style/TailwindStylesheetTest.php @@ -518,7 +518,7 @@ public function testUtilityOverridesUniversalRule() public function testUtilityOverridesStateSelectors() { $stylesheet = new TailwindStylesheet(); - $stylesheet->addRule(InputWidget::class.':focused', new Style()->withBackground('blue')); + $stylesheet->addRule(InputWidget::class.':focus', new Style()->withBackground('blue')); $widget = new InputWidget(); $widget->setFocused(true); @@ -526,7 +526,7 @@ public function testUtilityOverridesStateSelectors() $resolved = $stylesheet->resolve($widget); - // Utility bg-red-500 overrides :focused background + // Utility bg-red-500 overrides :focus background $this->assertSame(Color::hex('#ef4444')->toRgb(), $resolved->getBackground()->toRgb()); } @@ -566,7 +566,7 @@ public function testRegularStateSelectorsStillWork() { $stylesheet = new TailwindStylesheet(); $stylesheet->addRule('.input-field', new Style()->withBackground('black')); - $stylesheet->addRule('.input-field:focused', new Style()->withBackground('blue')); + $stylesheet->addRule('.input-field:focus', new Style()->withBackground('blue')); $widget = new InputWidget(); $widget->addStyleClass('input-field'); @@ -738,7 +738,7 @@ public function testResolveElementWithUtilityAndCssClassCombination() public function testResolveElementWithStateDoesNotMatchUtilityClassNames() { $stylesheet = new TailwindStylesheet(); - $stylesheet->addRule('.bold::cursor:focused', new Style()->withColor('cyan')); + $stylesheet->addRule('.bold::cursor:focus', new Style()->withColor('cyan')); $widget = new InputWidget(); $widget->addStyleClass('bold'); @@ -746,7 +746,7 @@ public function testResolveElementWithStateDoesNotMatchUtilityClassNames() $style = $stylesheet->resolveElement($widget, 'cursor'); - // "bold" is a utility class; ".bold::cursor:focused" must not match + // "bold" is a utility class; ".bold::cursor:focus" must not match $this->assertNull($style->getColor()); } diff --git a/src/Symfony/Component/Tui/Widget/AbstractWidget.php b/src/Symfony/Component/Tui/Widget/AbstractWidget.php index 3d9b8b517d768..2f6d1543d9f78 100644 --- a/src/Symfony/Component/Tui/Widget/AbstractWidget.php +++ b/src/Symfony/Component/Tui/Widget/AbstractWidget.php @@ -181,7 +181,7 @@ final public function getStateFlags(): array } if ($this instanceof FocusableInterface && $this->isFocused()) { - $flags[] = 'focused'; + $flags[] = 'focus'; } return $flags; @@ -408,7 +408,7 @@ protected function onDetach(): void * Resolution order: * 1. FQCN::element * 2. .class::element - * 3. FQCN::element:state (e.g., :focused) + * 3. FQCN::element:state (e.g., :focus) * 4. .class::element:state * * @see StyleSheet::resolveElement() From 7dd3a11cd0b2619bdd8a51d70a84de423a6297f6 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Thu, 26 Mar 2026 19:18:20 +0100 Subject: [PATCH 04/22] Fix CS --- src/Symfony/Component/Tui/Loop/FixedStepAccumulator.php | 4 ++-- src/Symfony/Component/Tui/Loop/PeriodicStepper.php | 4 ++-- src/Symfony/Component/Tui/Loop/TickScheduler.php | 2 +- src/Symfony/Component/Tui/Render/Renderer.php | 2 +- src/Symfony/Component/Tui/Render/ScreenWriter.php | 2 +- src/Symfony/Component/Tui/Style/Border.php | 2 +- src/Symfony/Component/Tui/Style/Color.php | 4 ++-- src/Symfony/Component/Tui/Style/Padding.php | 2 +- .../Component/Tui/Tests/Widget/Figlet/FontRegistryTest.php | 2 +- src/Symfony/Component/Tui/Widget/ContainerWidget.php | 2 +- src/Symfony/Component/Tui/Widget/Figlet/FontRegistry.php | 2 +- src/Symfony/Component/Tui/Widget/LoaderWidget.php | 2 +- src/Symfony/Component/Tui/Widget/ScheduledTickTrait.php | 2 +- 13 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/Symfony/Component/Tui/Loop/FixedStepAccumulator.php b/src/Symfony/Component/Tui/Loop/FixedStepAccumulator.php index 261cb4d150a19..4c050c4892f38 100644 --- a/src/Symfony/Component/Tui/Loop/FixedStepAccumulator.php +++ b/src/Symfony/Component/Tui/Loop/FixedStepAccumulator.php @@ -29,7 +29,7 @@ public function __construct( private int $maxStepsPerUpdate = 5, ) { if ($stepsPerSecond <= 0.0) { - throw new InvalidArgumentException(\sprintf('Steps per second must be greater than 0, got %s.', $stepsPerSecond)); + throw new InvalidArgumentException(\sprintf('Steps per second must be greater than 0, got %d.', $stepsPerSecond)); } if ($maxStepsPerUpdate < 1) { @@ -60,7 +60,7 @@ public function computeSteps(?float $deltaTime): int public function setStepsPerSecond(float $stepsPerSecond): void { if ($stepsPerSecond <= 0.0) { - throw new InvalidArgumentException(\sprintf('Steps per second must be greater than 0, got %s.', $stepsPerSecond)); + throw new InvalidArgumentException(\sprintf('Steps per second must be greater than 0, got %d.', $stepsPerSecond)); } $this->stepsPerSecond = $stepsPerSecond; diff --git a/src/Symfony/Component/Tui/Loop/PeriodicStepper.php b/src/Symfony/Component/Tui/Loop/PeriodicStepper.php index fb723e771a304..82406d9925c6f 100644 --- a/src/Symfony/Component/Tui/Loop/PeriodicStepper.php +++ b/src/Symfony/Component/Tui/Loop/PeriodicStepper.php @@ -30,7 +30,7 @@ public function __construct( int $maxStepsPerUpdate = 8, ) { if ($intervalSeconds <= 0.0) { - throw new InvalidArgumentException(\sprintf('Interval must be greater than 0, got %s.', $intervalSeconds)); + throw new InvalidArgumentException(\sprintf('Interval must be greater than 0, got %d.', $intervalSeconds)); } $this->accumulator = new FixedStepAccumulator(1.0 / $intervalSeconds, $maxStepsPerUpdate); @@ -60,7 +60,7 @@ public function reset(): void public function setIntervalSeconds(float $intervalSeconds): void { if ($intervalSeconds <= 0.0) { - throw new InvalidArgumentException(\sprintf('Interval must be greater than 0, got %s.', $intervalSeconds)); + throw new InvalidArgumentException(\sprintf('Interval must be greater than 0, got %d.', $intervalSeconds)); } $this->intervalSeconds = $intervalSeconds; diff --git a/src/Symfony/Component/Tui/Loop/TickScheduler.php b/src/Symfony/Component/Tui/Loop/TickScheduler.php index 00530b16b090f..8fb9073f3d61c 100644 --- a/src/Symfony/Component/Tui/Loop/TickScheduler.php +++ b/src/Symfony/Component/Tui/Loop/TickScheduler.php @@ -39,7 +39,7 @@ final class TickScheduler public function schedule(callable $callback, float $intervalSeconds): string { if ($intervalSeconds <= 0) { - throw new InvalidArgumentException(\sprintf('Interval must be greater than 0, got %s.', $intervalSeconds)); + throw new InvalidArgumentException(\sprintf('Interval must be greater than 0, got %d.', $intervalSeconds)); } $id = 'interval-'.(++$this->counter); diff --git a/src/Symfony/Component/Tui/Render/Renderer.php b/src/Symfony/Component/Tui/Render/Renderer.php index fab4cfd511315..df1ecc1650c42 100644 --- a/src/Symfony/Component/Tui/Render/Renderer.php +++ b/src/Symfony/Component/Tui/Render/Renderer.php @@ -174,7 +174,7 @@ public function renderWidget(AbstractWidget $widget, RenderContext $context): ar $lineWidth = AnsiUtils::visibleWidth($line); if ($lineWidth > $availableColumns) { - throw new RenderException(\sprintf("Widget %s rendered line %d with width %d, exceeding the available %d columns.\nLine preview: %s", $widget::class, $i, $lineWidth, $availableColumns, mb_substr(AnsiUtils::stripAnsiCodes($line), 0, 100)), $i, $lineWidth, $availableColumns); + throw new RenderException(\sprintf("Widget \"%s\" rendered line %d with width %d, exceeding the available %d columns.\nLine preview: %d.", $widget::class, $i, $lineWidth, $availableColumns, mb_substr(AnsiUtils::stripAnsiCodes($line), 0, 100)), $i, $lineWidth, $availableColumns); } } diff --git a/src/Symfony/Component/Tui/Render/ScreenWriter.php b/src/Symfony/Component/Tui/Render/ScreenWriter.php index c17d9a3df1afe..c30178f564921 100644 --- a/src/Symfony/Component/Tui/Render/ScreenWriter.php +++ b/src/Symfony/Component/Tui/Render/ScreenWriter.php @@ -383,7 +383,7 @@ private function differentialRender(array $newLines, ?array $cursorPos, int $fir $plainLine = preg_replace('/\x1b(?:\[[0-9;]*[a-zA-Z]|\][^\x07]*\x07)/', '', $line); $preview = mb_substr($plainLine, 0, 100); - throw new RenderException(\sprintf("Rendered line %d exceeds terminal width (%d > %d).\nLine preview: %s%s", $i, $lineWidth, $width, $preview, mb_strlen($plainLine) > 100 ? '...' : ''), $i, $lineWidth, $width); + throw new RenderException(\sprintf("Rendered line %d exceeds terminal width (%d > %d).\nLine preview: %d%d.", $i, $lineWidth, $width, $preview, mb_strlen($plainLine) > 100 ? '...' : ''), $i, $lineWidth, $width); } $buffer .= $line; diff --git a/src/Symfony/Component/Tui/Style/Border.php b/src/Symfony/Component/Tui/Style/Border.php index 95028118417ea..f057141607e74 100644 --- a/src/Symfony/Component/Tui/Style/Border.php +++ b/src/Symfony/Component/Tui/Style/Border.php @@ -99,7 +99,7 @@ public static function from(self|array $border, BorderPattern|string|null $patte 2 => new self($border[0], $border[1], $border[0], $border[1], $pattern, $color), 3 => new self($border[0], $border[1], $border[2], $border[1], $pattern, $color), 4 => new self($border[0], $border[1], $border[2], $border[3], $pattern, $color), - default => throw new InvalidArgumentException('Border array must have 1, 2, 3, or 4 elements'), + default => throw new InvalidArgumentException('Border array must have 1, 2, 3, or 4 elements.'), }; } diff --git a/src/Symfony/Component/Tui/Style/Color.php b/src/Symfony/Component/Tui/Style/Color.php index cb17cb3e22a35..247cb134bab09 100644 --- a/src/Symfony/Component/Tui/Style/Color.php +++ b/src/Symfony/Component/Tui/Style/Color.php @@ -81,7 +81,7 @@ public static function named(string $name): self { $name = strtolower($name); if (!isset(self::BASIC_COLORS[$name])) { - throw new InvalidArgumentException(\sprintf('Unknown color name: %s', $name)); + throw new InvalidArgumentException(\sprintf('Unknown color name: "%s".', $name)); } return new self(ColorType::Named, $name); @@ -114,7 +114,7 @@ public static function hex(string $hex): self } if (6 !== \strlen($hex) || !ctype_xdigit($hex)) { - throw new InvalidArgumentException(\sprintf('Invalid hex color: %s', $hex)); + throw new InvalidArgumentException(\sprintf('Invalid hex color: "%s".', $hex)); } return new self(ColorType::Hex, $hex); diff --git a/src/Symfony/Component/Tui/Style/Padding.php b/src/Symfony/Component/Tui/Style/Padding.php index 4fe7d1ed14e83..5c76bbb667e65 100644 --- a/src/Symfony/Component/Tui/Style/Padding.php +++ b/src/Symfony/Component/Tui/Style/Padding.php @@ -82,7 +82,7 @@ public static function from(self|array $padding): self 2 => new self($padding[0], $padding[1], $padding[0], $padding[1]), 3 => new self($padding[0], $padding[1], $padding[2], $padding[1]), 4 => new self($padding[0], $padding[1], $padding[2], $padding[3]), - default => throw new InvalidArgumentException('Padding array must have 1, 2, 3, or 4 elements'), + default => throw new InvalidArgumentException('Padding array must have 1, 2, 3, or 4 elements.'), }; } diff --git a/src/Symfony/Component/Tui/Tests/Widget/Figlet/FontRegistryTest.php b/src/Symfony/Component/Tui/Tests/Widget/Figlet/FontRegistryTest.php index ea698b14b56de..1f8f2d2c41360 100644 --- a/src/Symfony/Component/Tui/Tests/Widget/Figlet/FontRegistryTest.php +++ b/src/Symfony/Component/Tui/Tests/Widget/Figlet/FontRegistryTest.php @@ -82,7 +82,7 @@ public function testGetUnregisteredFontExceptionListsAvailable() $registry = new FontRegistry(); $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('big, small, slant, standard, mini'); + $this->expectExceptionMessage('"big", "small", "slant", "standard", "mini"'); $registry->get('unknown'); } diff --git a/src/Symfony/Component/Tui/Widget/ContainerWidget.php b/src/Symfony/Component/Tui/Widget/ContainerWidget.php index 4d635151c4836..2da3d4328ee57 100644 --- a/src/Symfony/Component/Tui/Widget/ContainerWidget.php +++ b/src/Symfony/Component/Tui/Widget/ContainerWidget.php @@ -163,6 +163,6 @@ public function isVerticallyExpanded(): bool */ public function render(RenderContext $context): array { - throw new LogicException(\sprintf('%s rendering is handled by the Renderer via renderContainer(). This method should never be called directly.', static::class)); + throw new LogicException(\sprintf('"%s" rendering is handled by the Renderer via "renderContainer()"; this method should never be called directly.', static::class)); } } diff --git a/src/Symfony/Component/Tui/Widget/Figlet/FontRegistry.php b/src/Symfony/Component/Tui/Widget/Figlet/FontRegistry.php index 8b9ad205d7737..e4fbd5d1e7979 100644 --- a/src/Symfony/Component/Tui/Widget/Figlet/FontRegistry.php +++ b/src/Symfony/Component/Tui/Widget/Figlet/FontRegistry.php @@ -76,7 +76,7 @@ public function get(string $name): FigletFont } if (!isset($this->paths[$name])) { - throw new InvalidArgumentException(\sprintf('Font "%s" is not registered. Available fonts: %s.', $name, implode(', ', array_keys($this->paths)))); + throw new InvalidArgumentException(\sprintf('Font "%s" is not registered. Available fonts: "%s".', $name, implode('", "', array_keys($this->paths)))); } return $this->fonts[$name] = FigletFont::load($this->paths[$name]); diff --git a/src/Symfony/Component/Tui/Widget/LoaderWidget.php b/src/Symfony/Component/Tui/Widget/LoaderWidget.php index fc64e87919f8f..e37fb2b874168 100644 --- a/src/Symfony/Component/Tui/Widget/LoaderWidget.php +++ b/src/Symfony/Component/Tui/Widget/LoaderWidget.php @@ -135,7 +135,7 @@ public static function addSpinner(string $name, array $frames): void public function setSpinner(string $name): self { if (!isset(self::$styles[$name])) { - throw new \InvalidArgumentException(\sprintf('Unknown loader style "%s". Available styles: %s.', $name, implode(', ', array_keys(self::$styles)))); + throw new \InvalidArgumentException(\sprintf('Unknown loader style "%s". Available styles: "%s".', $name, implode('", "', array_keys(self::$styles)))); } $this->frames = self::$styles[$name]; diff --git a/src/Symfony/Component/Tui/Widget/ScheduledTickTrait.php b/src/Symfony/Component/Tui/Widget/ScheduledTickTrait.php index 05a1950c59bc2..6655727449787 100644 --- a/src/Symfony/Component/Tui/Widget/ScheduledTickTrait.php +++ b/src/Symfony/Component/Tui/Widget/ScheduledTickTrait.php @@ -32,7 +32,7 @@ abstract protected function onScheduledTick(): void; protected function startScheduledTick(float $intervalSeconds): void { if ($intervalSeconds <= 0.0) { - throw new InvalidArgumentException(\sprintf('Interval must be greater than 0, got %s.', $intervalSeconds)); + throw new InvalidArgumentException(\sprintf('Interval must be greater than 0, got %d.', $intervalSeconds)); } if (null !== $this->scheduledTickId && null !== $this->scheduledTickInterval && abs($this->scheduledTickInterval - $intervalSeconds) < 0.000001) { From 632d8628ae83dde89d06e048deba6f5deea2170d Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Fri, 27 Mar 2026 07:22:13 +0100 Subject: [PATCH 05/22] Fix CS --- .../Tui/Ansi/ScreenBufferHtmlRenderer.php | 4 +- .../Component/Tui/Render/WidgetRect.php | 2 +- src/Symfony/Component/Tui/Style/Border.php | 2 +- .../Component/Tui/Style/BorderPattern.php | 2 +- .../Component/Tui/Terminal/ScreenBuffer.php | 26 +-- .../Component/Tui/Terminal/TeeTerminal.php | 6 +- .../Tui/Terminal/VirtualTerminal.php | 2 +- .../Tui/Tests/Focus/FocusManagerTest.php | 2 +- .../Tui/Tests/Input/StdinBufferTest.php | 32 +-- .../Tui/Tests/Loop/TickSchedulerTest.php | 4 +- .../Tui/Tests/Render/CellBufferTest.php | 4 +- .../Tui/Tests/Render/RendererTest.php | 2 +- src/Symfony/Component/Tui/Tests/StateDiff.php | 182 +++++++++--------- .../Tests/Terminal/VirtualTerminalTest.php | 24 +-- src/Symfony/Component/Tui/Tests/TuiTest.php | 8 +- .../Widget/CancellableLoaderWidgetTest.php | 10 +- .../Tui/Tests/Widget/ContainerTest.php | 18 +- .../Widget/Editor/EditorDocumentTest.php | 4 +- .../Component/Tui/Tests/Widget/EditorTest.php | 2 +- .../Tests/Widget/Figlet/FigletFontTest.php | 2 +- .../Component/Tui/Tests/Widget/InputTest.php | 12 +- .../Tui/Tests/Widget/MarkdownTest.php | 2 +- .../Tui/Tests/Widget/ProgressBarTest.php | 8 +- .../Tui/Tests/Widget/SelectListTest.php | 10 +- .../Tui/Tests/Widget/SettingsListTest.php | 20 +- .../Component/Tui/Widget/AbstractWidget.php | 8 +- .../Tui/Widget/Editor/EditorRenderer.php | 4 +- .../Tui/Widget/Markdown/DarkTerminalTheme.php | 2 +- .../Component/Tui/Widget/MarkdownWidget.php | 6 +- .../Component/Tui/Widget/SelectListWidget.php | 2 +- .../Tui/Widget/SettingsListWidget.php | 4 +- 31 files changed, 206 insertions(+), 210 deletions(-) diff --git a/src/Symfony/Component/Tui/Ansi/ScreenBufferHtmlRenderer.php b/src/Symfony/Component/Tui/Ansi/ScreenBufferHtmlRenderer.php index d428ce400ad0d..22cbeaac5ba0d 100644 --- a/src/Symfony/Component/Tui/Ansi/ScreenBufferHtmlRenderer.php +++ b/src/Symfony/Component/Tui/Ansi/ScreenBufferHtmlRenderer.php @@ -55,7 +55,7 @@ public function convert(ScreenBuffer $screen): string // Only include lines up to the last non-empty line if ($lastNonEmpty >= 0) { - $result = array_slice($result, 0, $lastNonEmpty + 1); + $result = \array_slice($result, 0, $lastNonEmpty + 1); } else { $result = []; } @@ -106,7 +106,7 @@ private function convertLine(array $cells): string $lastStyle = $style; } - $html .= htmlspecialchars($char, ENT_QUOTES | ENT_HTML5); + $html .= htmlspecialchars($char, \ENT_QUOTES | \ENT_HTML5); } if ($inSpan) { diff --git a/src/Symfony/Component/Tui/Render/WidgetRect.php b/src/Symfony/Component/Tui/Render/WidgetRect.php index a6ae2dbfc80a0..c35c3346c3e9c 100644 --- a/src/Symfony/Component/Tui/Render/WidgetRect.php +++ b/src/Symfony/Component/Tui/Render/WidgetRect.php @@ -20,7 +20,7 @@ * * @author Fabien Potencier */ -final readonly class WidgetRect +final class WidgetRect { public function __construct( private int $row, diff --git a/src/Symfony/Component/Tui/Style/Border.php b/src/Symfony/Component/Tui/Style/Border.php index f057141607e74..9ace82977061a 100644 --- a/src/Symfony/Component/Tui/Style/Border.php +++ b/src/Symfony/Component/Tui/Style/Border.php @@ -183,7 +183,7 @@ public function wrapLines(array $innerLines, int $innerWidth, Style $innerStyle, $chars = $pattern->getChars(); $strategies = $pattern->getStrategies(); - $outerStyle = $outerStyle ?? new Style(); + $outerStyle ??= new Style(); $borderColor = $this->color ?? $innerStyle->getColor(); $lines = []; diff --git a/src/Symfony/Component/Tui/Style/BorderPattern.php b/src/Symfony/Component/Tui/Style/BorderPattern.php index 6171cf49eff2b..1dbf6e99b5ab0 100644 --- a/src/Symfony/Component/Tui/Style/BorderPattern.php +++ b/src/Symfony/Component/Tui/Style/BorderPattern.php @@ -236,7 +236,7 @@ public static function fromName(string $style): self self::WIDE_MEDIUM => self::wideMedium(), self::TALL_LARGE => self::tallLarge(), self::WIDE_LARGE => self::wideLarge(), - default => throw new InvalidArgumentException(sprintf('Unknown border pattern "%s".', $style)), + default => throw new InvalidArgumentException(\sprintf('Unknown border pattern "%s".', $style)), }; } diff --git a/src/Symfony/Component/Tui/Terminal/ScreenBuffer.php b/src/Symfony/Component/Tui/Terminal/ScreenBuffer.php index 3a746385e49aa..c8feea1fad510 100644 --- a/src/Symfony/Component/Tui/Terminal/ScreenBuffer.php +++ b/src/Symfony/Component/Tui/Terminal/ScreenBuffer.php @@ -104,7 +104,7 @@ public function clear(): void public function write(string $data): void { $i = 0; - $len = strlen($data); + $len = \strlen($data); while ($i < $len) { $char = $data[$i]; @@ -165,7 +165,7 @@ public function write(string $data): void } // Skip other control characters - if (ord($char) < 32 && "\x1b" !== $char) { + if (\ord($char) < 32 && "\x1b" !== $char) { ++$i; continue; } @@ -200,7 +200,7 @@ public function getScreen(): string // Only include lines up to the last non-empty line if ($lastNonEmpty >= 0) { - $result = array_slice($result, 0, $lastNonEmpty + 1); + $result = \array_slice($result, 0, $lastNonEmpty + 1); } else { $result = []; } @@ -231,7 +231,7 @@ public function getStyledScreen(): string // Only include lines up to the last non-empty line if ($lastNonEmpty >= 0) { - $result = array_slice($result, 0, $lastNonEmpty + 1); + $result = \array_slice($result, 0, $lastNonEmpty + 1); } else { $result = []; } @@ -353,7 +353,7 @@ private function putChar(string $char): void } // Fill any gaps with spaces - for ($col = count($this->cells[$this->cursorRow]); $col < $this->cursorCol; ++$col) { + for ($col = \count($this->cells[$this->cursorRow]); $col < $this->cursorCol; ++$col) { $this->cells[$this->cursorRow][$col] = ['char' => ' ', 'style' => '']; } @@ -460,13 +460,13 @@ private function scrollUp(): void */ private function parseEscapeSequence(string $data, int $start): int { - $len = strlen($data); + $len = \strlen($data); if ($start + 1 >= $len) { return 1; } $next = $data[$start + 1]; - $nextOrd = ord($next); + $nextOrd = \ord($next); // CSI sequence: ESC [ if ('[' === $next) { @@ -482,10 +482,10 @@ private function parseEscapeSequence(string $data, int $start): int // nF announced sequences: ESC + intermediate bytes (0x20-0x2F)+ + final byte (0x30-0x7E) if ($nextOrd >= 0x20 && $nextOrd <= 0x2F) { $j = $start + 2; - while ($j < $len && ord($data[$j]) >= 0x20 && ord($data[$j]) <= 0x2F) { + while ($j < $len && \ord($data[$j]) >= 0x20 && \ord($data[$j]) <= 0x2F) { ++$j; } - if ($j < $len && ord($data[$j]) >= 0x30 && ord($data[$j]) <= 0x7E) { + if ($j < $len && \ord($data[$j]) >= 0x30 && \ord($data[$j]) <= 0x7E) { return $j + 1 - $start; } @@ -507,7 +507,7 @@ private function parseEscapeSequence(string $data, int $start): int */ private function parseStringSequence(string $data, int $start): int { - $len = strlen($data); + $len = \strlen($data); $i = $start + 2; // Skip ESC + introducer byte // Find terminator: ST (ESC \) or BEL (\x07) @@ -532,18 +532,18 @@ private function parseStringSequence(string $data, int $start): int */ private function parseCsiSequence(string $data, int $start): int { - $len = strlen($data); + $len = \strlen($data); $i = $start + 2; // Skip ESC [ // Collect parameter bytes (0x30-0x3F) $params = ''; - while ($i < $len && ord($data[$i]) >= 0x30 && ord($data[$i]) <= 0x3F) { + while ($i < $len && \ord($data[$i]) >= 0x30 && \ord($data[$i]) <= 0x3F) { $params .= $data[$i]; ++$i; } // Collect intermediate bytes (0x20-0x2F) - while ($i < $len && ord($data[$i]) >= 0x20 && ord($data[$i]) <= 0x2F) { + while ($i < $len && \ord($data[$i]) >= 0x20 && \ord($data[$i]) <= 0x2F) { ++$i; } diff --git a/src/Symfony/Component/Tui/Terminal/TeeTerminal.php b/src/Symfony/Component/Tui/Terminal/TeeTerminal.php index fb055fce9bfea..dc5e5705fb98a 100644 --- a/src/Symfony/Component/Tui/Terminal/TeeTerminal.php +++ b/src/Symfony/Component/Tui/Terminal/TeeTerminal.php @@ -44,9 +44,9 @@ public function start(callable $onInput, callable $onResize, callable $onKittyPr $this->primary->start($onInput, $onResize, $onKittyProtocolActivated); // Start secondary with no-op callbacks (they just record) - $noopInput = function (string $data): void {}; - $noopResize = function (): void {}; - $noopKitty = function (): void {}; + $noopInput = static function (string $data): void {}; + $noopResize = static function (): void {}; + $noopKitty = static function (): void {}; $this->secondary->start($noopInput, $noopResize, $noopKitty); } diff --git a/src/Symfony/Component/Tui/Terminal/VirtualTerminal.php b/src/Symfony/Component/Tui/Terminal/VirtualTerminal.php index fd4beb72d57bd..76ea28387d4ae 100644 --- a/src/Symfony/Component/Tui/Terminal/VirtualTerminal.php +++ b/src/Symfony/Component/Tui/Terminal/VirtualTerminal.php @@ -53,7 +53,7 @@ public function start(callable $onInput, callable $onResize, callable $onKittyPr $this->stdinBuffer->onData($onInput); // Re-wrap paste content with bracketed paste markers (matches real Terminal behavior) - $this->stdinBuffer->onPaste(function (string $content) use ($onInput): void { + $this->stdinBuffer->onPaste(static function (string $content) use ($onInput): void { $onInput("\x1b[200~".$content."\x1b[201~"); }); } diff --git a/src/Symfony/Component/Tui/Tests/Focus/FocusManagerTest.php b/src/Symfony/Component/Tui/Tests/Focus/FocusManagerTest.php index c4de5ec16c2af..b077f2bb1cdf0 100644 --- a/src/Symfony/Component/Tui/Tests/Focus/FocusManagerTest.php +++ b/src/Symfony/Component/Tui/Tests/Focus/FocusManagerTest.php @@ -69,7 +69,7 @@ public function testFocusChangedEventProvidesPreviousWidget() $focusManager->add($first)->add($second); $received = null; - $tui->on(FocusEvent::class, function (FocusEvent $event) use (&$received): void { + $tui->on(FocusEvent::class, static function (FocusEvent $event) use (&$received): void { $received = $event; }); diff --git a/src/Symfony/Component/Tui/Tests/Input/StdinBufferTest.php b/src/Symfony/Component/Tui/Tests/Input/StdinBufferTest.php index ff0747638996b..f512cb6916a73 100644 --- a/src/Symfony/Component/Tui/Tests/Input/StdinBufferTest.php +++ b/src/Symfony/Component/Tui/Tests/Input/StdinBufferTest.php @@ -26,7 +26,7 @@ public function testProcess(string $input, array $expectedSequences) $buffer = new StdinBuffer(); $sequences = []; - $buffer->onData(function (string $data) use (&$sequences) { + $buffer->onData(static function (string $data) use (&$sequences) { $sequences[] = bin2hex($data); }); @@ -145,7 +145,7 @@ public function testHighByteMetaConversion(string $input, array $expectedSequenc $buffer = new StdinBuffer(); $sequences = []; - $buffer->onData(function (string $data) use (&$sequences) { + $buffer->onData(static function (string $data) use (&$sequences) { $sequences[] = bin2hex($data); }); @@ -185,11 +185,11 @@ public function testBracketedPaste() $sequences = []; $pastedContent = null; - $buffer->onData(function (string $data) use (&$sequences) { + $buffer->onData(static function (string $data) use (&$sequences) { $sequences[] = $data; }); - $buffer->onPaste(function (string $content) use (&$pastedContent) { + $buffer->onPaste(static function (string $content) use (&$pastedContent) { $pastedContent = $content; }); @@ -204,7 +204,7 @@ public function testIncrementalInput() $buffer = new StdinBuffer(); $sequences = []; - $buffer->onData(function (string $data) use (&$sequences) { + $buffer->onData(static function (string $data) use (&$sequences) { $sequences[] = bin2hex($data); }); @@ -222,7 +222,7 @@ public function testEscapeThenArrowIncremental() $buffer = new StdinBuffer(); $sequences = []; - $buffer->onData(function (string $data) use (&$sequences) { + $buffer->onData(static function (string $data) use (&$sequences) { $sequences[] = bin2hex($data); }); @@ -243,7 +243,7 @@ public function testFlushEmitsStandaloneEscape() $buffer = new StdinBuffer(); $sequences = []; - $buffer->onData(function (string $data) use (&$sequences) { + $buffer->onData(static function (string $data) use (&$sequences) { $sequences[] = bin2hex($data); }); @@ -260,7 +260,7 @@ public function testFlushDoesNothingWithoutPendingEscape() $buffer = new StdinBuffer(); $sequences = []; - $buffer->onData(function (string $data) use (&$sequences) { + $buffer->onData(static function (string $data) use (&$sequences) { $sequences[] = $data; }); @@ -275,7 +275,7 @@ public function testClearResetsAllState() $buffer = new StdinBuffer(); $sequences = []; - $buffer->onData(function (string $data) use (&$sequences) { + $buffer->onData(static function (string $data) use (&$sequences) { $sequences[] = $data; }); @@ -296,7 +296,7 @@ public function testSingleSequence(string $input) $buffer = new StdinBuffer(); $sequences = []; - $buffer->onData(function (string $data) use (&$sequences) { + $buffer->onData(static function (string $data) use (&$sequences) { $sequences[] = bin2hex($data); }); @@ -325,7 +325,7 @@ public function testIncrementalPaste() $pasteCount = 0; $pastedContent = null; - $buffer->onPaste(function (string $content) use (&$pastedContent, &$pasteCount) { + $buffer->onPaste(static function (string $content) use (&$pastedContent, &$pasteCount) { $pastedContent = $content; ++$pasteCount; }); @@ -350,10 +350,10 @@ public function testDataAfterPasteEndIsProcessed() $sequences = []; $pastedContent = null; - $buffer->onData(function (string $data) use (&$sequences) { + $buffer->onData(static function (string $data) use (&$sequences) { $sequences[] = $data; }); - $buffer->onPaste(function (string $content) use (&$pastedContent) { + $buffer->onPaste(static function (string $content) use (&$pastedContent) { $pastedContent = $content; }); @@ -369,7 +369,7 @@ public function testUtf8MultiByte() $buffer = new StdinBuffer(); $sequences = []; - $buffer->onData(function (string $data) use (&$sequences) { + $buffer->onData(static function (string $data) use (&$sequences) { $sequences[] = $data; }); @@ -384,7 +384,7 @@ public function testIncompleteUtf8WaitsForMoreData() $buffer = new StdinBuffer(); $sequences = []; - $buffer->onData(function (string $data) use (&$sequences) { + $buffer->onData(static function (string $data) use (&$sequences) { $sequences[] = $data; }); @@ -404,7 +404,7 @@ public function testIncompleteCsiWaitsForTerminator() $buffer = new StdinBuffer(); $sequences = []; - $buffer->onData(function (string $data) use (&$sequences) { + $buffer->onData(static function (string $data) use (&$sequences) { $sequences[] = bin2hex($data); }); diff --git a/src/Symfony/Component/Tui/Tests/Loop/TickSchedulerTest.php b/src/Symfony/Component/Tui/Tests/Loop/TickSchedulerTest.php index 3cc397725f413..71f01f6fc1096 100644 --- a/src/Symfony/Component/Tui/Tests/Loop/TickSchedulerTest.php +++ b/src/Symfony/Component/Tui/Tests/Loop/TickSchedulerTest.php @@ -31,7 +31,7 @@ public function testRunDueExecutesAndReschedulesCallbacks() $calls = 0; $start = microtime(true); - $scheduler->schedule(function () use (&$calls): void { + $scheduler->schedule(static function () use (&$calls): void { ++$calls; }, 0.5); @@ -54,7 +54,7 @@ public function testCancelPreventsFutureExecution() $calls = 0; $start = microtime(true); - $id = $scheduler->schedule(function () use (&$calls): void { + $id = $scheduler->schedule(static function () use (&$calls): void { ++$calls; }, 0.01); diff --git a/src/Symfony/Component/Tui/Tests/Render/CellBufferTest.php b/src/Symfony/Component/Tui/Tests/Render/CellBufferTest.php index 97c7ab6b454f8..c4189aa3a9aa4 100644 --- a/src/Symfony/Component/Tui/Tests/Render/CellBufferTest.php +++ b/src/Symfony/Component/Tui/Tests/Render/CellBufferTest.php @@ -142,7 +142,7 @@ public function testOverlayCompositing() ], 1, 5); $lines = $buf->toLines(); - $plain = array_map(fn ($l) => preg_replace('/\x1b\[[0-9;]*m/', '', $l), $lines); + $plain = array_map(static fn ($l) => preg_replace('/\x1b\[[0-9;]*m/', '', $l), $lines); $this->assertSame('Background content ', $plain[0]); $this->assertSame('Line OVERLAYse ', $plain[1]); // Overlay overwrites cols 5-11 @@ -273,7 +273,7 @@ public function testRoundtripPlainText() $buf->writeAnsiLines(['Hello', 'World', '!']); $lines = $buf->toLines(); - $plain = array_map(fn ($l) => preg_replace('/\x1b\[[0-9;]*m/', '', $l), $lines); + $plain = array_map(static fn ($l) => preg_replace('/\x1b\[[0-9;]*m/', '', $l), $lines); $this->assertSame('Hello ', $plain[0]); $this->assertSame('World ', $plain[1]); diff --git a/src/Symfony/Component/Tui/Tests/Render/RendererTest.php b/src/Symfony/Component/Tui/Tests/Render/RendererTest.php index da4c498fdb50d..0fb6447187efb 100644 --- a/src/Symfony/Component/Tui/Tests/Render/RendererTest.php +++ b/src/Symfony/Component/Tui/Tests/Render/RendererTest.php @@ -248,7 +248,7 @@ public function testHiddenChildrenDoNotTakeLayoutSpace() // Only 2 visible children should produce lines $this->assertCount(2, $result); - $visible = implode("\n", array_map(fn (string $line) => AnsiUtils::stripAnsiCodes($line), $result)); + $visible = implode("\n", array_map(static fn (string $line) => AnsiUtils::stripAnsiCodes($line), $result)); $this->assertStringContainsString('Visible', $visible); $this->assertStringContainsString('Also visible', $visible); $this->assertStringNotContainsString('Hidden', $visible); diff --git a/src/Symfony/Component/Tui/Tests/StateDiff.php b/src/Symfony/Component/Tui/Tests/StateDiff.php index 4399f937eb355..683103b764892 100644 --- a/src/Symfony/Component/Tui/Tests/StateDiff.php +++ b/src/Symfony/Component/Tui/Tests/StateDiff.php @@ -50,7 +50,7 @@ public static function compare(string $expected, string $actual): array return [ 'identical' => false, - 'summary' => sprintf( + 'summary' => \sprintf( '%d added, %d removed, %d changed, %d unchanged', $stats['added'], $stats['removed'], @@ -72,7 +72,7 @@ public static function sideBySide(string $expected, string $actual, int $width = { $expectedLines = explode("\n", $expected); $actualLines = explode("\n", $actual); - $maxLines = max(count($expectedLines), count($actualLines)); + $maxLines = max(\count($expectedLines), \count($actualLines)); $colWidth = (int) (($width - 3) / 2); $output = []; @@ -107,8 +107,8 @@ public static function sideBySide(string $expected, string $actual, int $width = */ public static function generateHtmlReport(array $failures, array $successes = [], int $totalSteps = 0, int $passedSteps = 0, string $title = 'TUI Regression Report'): string { - $failureCount = count($failures); - $successCount = count($successes); + $failureCount = \count($failures); + $successCount = \count($successes); $hasFailures = $failureCount > 0; $titleColor = $hasFailures ? '#d32f2f' : '#2e7d32'; @@ -116,68 +116,68 @@ public static function generateHtmlReport(array $failures, array $successes = [] $summaryBg = $hasFailures ? '#ffebee' : '#e8f5e9'; $html = <<<'HTML' - - - - - %TITLE% - - - -

%TITLE_ICON% %TITLE%

-
- Results: %PASSED_STEPS%/%TOTAL_STEPS% steps passed - (%SUCCESS_COUNT% examples passed, %FAILURE_COUNT% failed) -
-HTML; + + + + + %TITLE% + + + +

%TITLE_ICON% %TITLE%

+
+ Results: %PASSED_STEPS%/%TOTAL_STEPS% steps passed + (%SUCCESS_COUNT% examples passed, %FAILURE_COUNT% failed) +
+ HTML; $html = str_replace( ['%TITLE%', '%TITLE_COLOR%', '%TITLE_ICON%', '%SUMMARY_BG%', '%PASSED_STEPS%', '%TOTAL_STEPS%', '%SUCCESS_COUNT%', '%FAILURE_COUNT%'], [$title, $titleColor, $titleIcon, $summaryBg, (string) $passedSteps, (string) $totalSteps, (string) $successCount, (string) $failureCount], @@ -186,7 +186,7 @@ public static function generateHtmlReport(array $failures, array $successes = [] // Failed examples section if ([] !== $failures) { - $html .= '

❌ Failed Examples ('.count($failures).')

'; + $html .= '

❌ Failed Examples ('.\count($failures).')

'; $failureIndex = 0; foreach ($failures as $name => $data) { @@ -203,7 +203,7 @@ public static function generateHtmlReport(array $failures, array $successes = [] // Tab bar $html .= '
'; - $stepCount = count($data['all_steps']); + $stepCount = \count($data['all_steps']); foreach ($data['all_steps'] as $index => $step) { $isFailedStep = $index === $stepCount - 1; $tabClass = 'tab-btn'.($isFailedStep ? ' tab-failed' : '').(0 === $index ? ' active' : ''); @@ -317,7 +317,7 @@ public static function generateHtmlReport(array $failures, array $successes = [] // Passed examples section if ([] !== $successes) { - $html .= '

✅ Passed Examples ('.count($successes).')

'; + $html .= '

✅ Passed Examples ('.\count($successes).')

'; $successIndex = 0; foreach ($successes as $name => $data) { @@ -325,7 +325,7 @@ public static function generateHtmlReport(array $failures, array $successes = [] $steps = $data['all_steps']; $html .= '
'; - $html .= '

PASSED '.htmlspecialchars($data['example']).'.php ('.count($steps).' steps)

'; + $html .= '

PASSED '.htmlspecialchars($data['example']).'.php ('.\count($steps).' steps)

'; // Show all steps as tabs $html .= '
'; @@ -375,23 +375,23 @@ public static function generateHtmlReport(array $failures, array $successes = [] // Add JavaScript for tab switching $html .= <<<'HTML' - - -HTML; + + + HTML; return $html; } @@ -406,8 +406,8 @@ function showTab(prefix, index) { */ private static function computeDiff(array $expected, array $actual): array { - $m = count($expected); - $n = count($actual); + $m = \count($expected); + $n = \count($actual); // Build LCS table $lcs = []; @@ -458,7 +458,7 @@ private static function computeDiff(array $expected, array $actual): array // Detect "changed" lines (adjacent removed + added) $result = []; - $diffCount = count($diff); + $diffCount = \count($diff); for ($k = 0; $k < $diffCount; ++$k) { $current = $diff[$k]; @@ -512,7 +512,7 @@ private static function formatUnifiedDiff(array $diff, array $expected, array $a $lines = []; $lines[] = '--- expected'; $lines[] = '+++ actual'; - $lines[] = sprintf('@@ -%d,%d +%d,%d @@', 1, count($expected), 1, count($actual)); + $lines[] = \sprintf('@@ -%d,%d +%d,%d @@', 1, \count($expected), 1, \count($actual)); foreach ($diff as $entry) { switch ($entry['type']) { diff --git a/src/Symfony/Component/Tui/Tests/Terminal/VirtualTerminalTest.php b/src/Symfony/Component/Tui/Tests/Terminal/VirtualTerminalTest.php index 0b9f455b01bd6..1547bdaae88e8 100644 --- a/src/Symfony/Component/Tui/Tests/Terminal/VirtualTerminalTest.php +++ b/src/Symfony/Component/Tui/Tests/Terminal/VirtualTerminalTest.php @@ -23,9 +23,9 @@ public function testSimulateInputForwardsKeySequences() $received = []; $terminal->start( - function (string $data) use (&$received) { $received[] = $data; }, - function () {}, - function () {}, + static function (string $data) use (&$received) { $received[] = $data; }, + static function () {}, + static function () {}, ); $terminal->simulateInput('abc'); @@ -41,9 +41,9 @@ public function testSimulateInputForwardsPasteContent() $received = []; $terminal->start( - function (string $data) use (&$received) { $received[] = $data; }, - function () {}, - function () {}, + static function (string $data) use (&$received) { $received[] = $data; }, + static function () {}, + static function () {}, ); $terminal->simulateInput("\x1b[200~Hello World\x1b[201~"); @@ -59,9 +59,9 @@ public function testSimulateInputForwardsPasteMixedWithKeys() $received = []; $terminal->start( - function (string $data) use (&$received) { $received[] = $data; }, - function () {}, - function () {}, + static function (string $data) use (&$received) { $received[] = $data; }, + static function () {}, + static function () {}, ); $terminal->simulateInput("a\x1b[200~pasted\x1b[201~b"); @@ -112,9 +112,9 @@ public function testSimulateInputPasteWithMultilineContent() $received = []; $terminal->start( - function (string $data) use (&$received) { $received[] = $data; }, - function () {}, - function () {}, + static function (string $data) use (&$received) { $received[] = $data; }, + static function () {}, + static function () {}, ); $terminal->simulateInput("\x1b[200~line1\nline2\nline3\x1b[201~"); diff --git a/src/Symfony/Component/Tui/Tests/TuiTest.php b/src/Symfony/Component/Tui/Tests/TuiTest.php index aac49ffbc8f73..2e966b19a8f2d 100644 --- a/src/Symfony/Component/Tui/Tests/TuiTest.php +++ b/src/Symfony/Component/Tui/Tests/TuiTest.php @@ -161,7 +161,7 @@ public function testTickInvalidationQueuesRenderWithoutExplicitRequest() $ticks = 0; $tui->add($text); - $tui->onTick(function () use ($text, &$ticks): void { + $tui->onTick(static function () use ($text, &$ticks): void { if (0 === $ticks) { $text->setText('1'); } @@ -187,7 +187,7 @@ public function testOnTickReceivesDeltaTime() $tui = new Tui(terminal: $terminal); $deltas = []; - $tui->onTick(function (TickEvent $event) use (&$deltas): void { + $tui->onTick(static function (TickEvent $event) use (&$deltas): void { $deltas[] = $event->getDeltaTime(); }); @@ -289,7 +289,7 @@ public function testDefaultRendersContentAtTop() $output = $terminal->getOutput(); $lines = explode("\r\n", $output); - $stripped = array_map(fn (string $l) => AnsiUtils::stripAnsiCodes($l), $lines); + $stripped = array_map(static fn (string $l) => AnsiUtils::stripAnsiCodes($l), $lines); // Content should start at the first line, not be pushed to the bottom $headerLineIndex = null; @@ -434,7 +434,7 @@ public function testQuitOnRunsBeforeOnInput() $tui->quitOn('ctrl+c'); $onInputCalled = false; - $tui->onInput(function () use (&$onInputCalled): bool { + $tui->onInput(static function () use (&$onInputCalled): bool { $onInputCalled = true; return true; diff --git a/src/Symfony/Component/Tui/Tests/Widget/CancellableLoaderWidgetTest.php b/src/Symfony/Component/Tui/Tests/Widget/CancellableLoaderWidgetTest.php index 48ce99f723896..93a800b115f9c 100644 --- a/src/Symfony/Component/Tui/Tests/Widget/CancellableLoaderWidgetTest.php +++ b/src/Symfony/Component/Tui/Tests/Widget/CancellableLoaderWidgetTest.php @@ -41,7 +41,7 @@ public function testCancelViaCtrlC() [$loader, $tui] = $this->createLoaderWithTui(); $cancelCalled = false; - $tui->on(CancelEvent::class, function () use (&$cancelCalled): void { + $tui->on(CancelEvent::class, static function () use (&$cancelCalled): void { $cancelCalled = true; }); @@ -102,11 +102,11 @@ public function testMultipleListenersAllCalled() $firstCalled = false; $secondCalled = false; - $tui->on(CancelEvent::class, function () use (&$firstCalled): void { + $tui->on(CancelEvent::class, static function () use (&$firstCalled): void { $firstCalled = true; }); - $tui->on(CancelEvent::class, function () use (&$secondCalled): void { + $tui->on(CancelEvent::class, static function () use (&$secondCalled): void { $secondCalled = true; }); @@ -121,7 +121,7 @@ public function testMultipleCancellationsCallListenerEachTime() [$loader, $tui] = $this->createLoaderWithTui(); $callCount = 0; - $tui->on(CancelEvent::class, function () use (&$callCount): void { + $tui->on(CancelEvent::class, static function () use (&$callCount): void { ++$callCount; }); @@ -137,7 +137,7 @@ public function testListenersRemovedOnDetach() [$loader, $tui] = $this->createLoaderWithTui(); $callCount = 0; - $loader->onCancel(function () use (&$callCount): void { + $loader->onCancel(static function () use (&$callCount): void { ++$callCount; }); diff --git a/src/Symfony/Component/Tui/Tests/Widget/ContainerTest.php b/src/Symfony/Component/Tui/Tests/Widget/ContainerTest.php index 799da49eca828..c75a805f4dc72 100644 --- a/src/Symfony/Component/Tui/Tests/Widget/ContainerTest.php +++ b/src/Symfony/Component/Tui/Tests/Widget/ContainerTest.php @@ -55,7 +55,7 @@ public function testRenderEmptyWithBorder() $lines = $this->renderContainer($container); - $stripped = array_map(fn ($l) => AnsiUtils::stripAnsiCodes($l), $lines); + $stripped = array_map(static fn ($l) => AnsiUtils::stripAnsiCodes($l), $lines); // Should have at least a top and bottom border line $this->assertGreaterThanOrEqual(2, \count($stripped)); $this->assertStringContainsString('┌', $stripped[0]); @@ -81,7 +81,7 @@ public function testRenderAllChildrenHiddenWithBorder() $lines = $this->renderContainer($container); - $stripped = array_map(fn ($l) => AnsiUtils::stripAnsiCodes($l), $lines); + $stripped = array_map(static fn ($l) => AnsiUtils::stripAnsiCodes($l), $lines); $this->assertStringContainsString('┌', $stripped[0]); $this->assertStringContainsString('└', $stripped[\count($stripped) - 1]); } @@ -344,7 +344,7 @@ public function testHiddenWidgetIsNotRendered(?StyleSheet $stylesheet) $renderer = new Renderer($stylesheet); $lines = $renderer->render($root, 40, 24); - $text = implode("\n", array_map(fn ($l) => AnsiUtils::stripAnsiCodes($l), $lines)); + $text = implode("\n", array_map(static fn ($l) => AnsiUtils::stripAnsiCodes($l), $lines)); $this->assertStringContainsString('Visible', $text); $this->assertStringNotContainsString('Hidden', $text); $this->assertStringContainsString('Also Visible', $text); @@ -416,13 +416,13 @@ public function testHiddenWidgetViaBreakpoint() // Narrow: both visible $lines = $renderer->render($root, 60, 24); - $text = implode("\n", array_map(fn ($l) => AnsiUtils::stripAnsiCodes($l), $lines)); + $text = implode("\n", array_map(static fn ($l) => AnsiUtils::stripAnsiCodes($l), $lines)); $this->assertStringContainsString('Always', $text); $this->assertStringContainsString('Mobile Only', $text); // Wide: hint hidden $lines = $renderer->render($root, 120, 24); - $text = implode("\n", array_map(fn ($l) => AnsiUtils::stripAnsiCodes($l), $lines)); + $text = implode("\n", array_map(static fn ($l) => AnsiUtils::stripAnsiCodes($l), $lines)); $this->assertStringContainsString('Always', $text); $this->assertStringNotContainsString('Mobile Only', $text); } @@ -442,7 +442,7 @@ public function testHiddenFalseOverridesInheritedHidden() $renderer = new Renderer($stylesheet); $lines = $renderer->render($root, 40, 24); - $text = implode("\n", array_map(fn ($l) => AnsiUtils::stripAnsiCodes($l), $lines)); + $text = implode("\n", array_map(static fn ($l) => AnsiUtils::stripAnsiCodes($l), $lines)); $this->assertStringContainsString('Visible', $text); } @@ -465,7 +465,7 @@ public function testHiddenContainerHidesAllChildren() $renderer = new Renderer(); $lines = $renderer->render($root, 40, 24); - $text = implode("\n", array_map(fn ($l) => AnsiUtils::stripAnsiCodes($l), $lines)); + $text = implode("\n", array_map(static fn ($l) => AnsiUtils::stripAnsiCodes($l), $lines)); $this->assertStringContainsString('Before', $text); $this->assertStringNotContainsString('Child A', $text); $this->assertStringNotContainsString('Child B', $text); @@ -499,7 +499,7 @@ public function testChildrenStylesAppliedByRenderer() $lines = $this->renderContainer($container); // The text should be indented by 4 characters (left padding) - $content = implode("\n", array_map(fn ($l) => AnsiUtils::stripAnsiCodes($l), $lines)); + $content = implode("\n", array_map(static fn ($l) => AnsiUtils::stripAnsiCodes($l), $lines)); $this->assertStringContainsString(' Padded', $content); } @@ -555,7 +555,7 @@ public function testContainerWithGapAndBeforeRender() $lines = $this->renderContainer($container, 40); - $stripped = array_map(fn ($l) => AnsiUtils::stripAnsiCodes($l), $lines); + $stripped = array_map(static fn ($l) => AnsiUtils::stripAnsiCodes($l), $lines); // First and "updated" text should both be present with a gap between them $firstIdx = null; diff --git a/src/Symfony/Component/Tui/Tests/Widget/Editor/EditorDocumentTest.php b/src/Symfony/Component/Tui/Tests/Widget/Editor/EditorDocumentTest.php index 6e145a27451d7..b532eaed80687 100644 --- a/src/Symfony/Component/Tui/Tests/Widget/Editor/EditorDocumentTest.php +++ b/src/Symfony/Component/Tui/Tests/Widget/Editor/EditorDocumentTest.php @@ -483,7 +483,7 @@ public function testSmallPasteDoesNotCreateMarker() public function testLargePasteCreatesMarker() { $doc = new EditorDocument(); - $content = implode("\n", array_map(fn ($i) => "line $i", range(1, 15))); + $content = implode("\n", array_map(static fn ($i) => "line $i", range(1, 15))); $doc->handlePaste($content); $markers = $doc->getPasteMarkers(); @@ -497,7 +497,7 @@ public function testLargePasteCreatesMarker() public function testSetTextClearsPasteMarkers() { $doc = new EditorDocument(); - $content = implode("\n", array_map(fn ($i) => "line $i", range(1, 15))); + $content = implode("\n", array_map(static fn ($i) => "line $i", range(1, 15))); $doc->handlePaste($content); $this->assertNotSame([], $doc->getPasteMarkers()); diff --git a/src/Symfony/Component/Tui/Tests/Widget/EditorTest.php b/src/Symfony/Component/Tui/Tests/Widget/EditorTest.php index 3103beff709ce..ee7d5596f2dcb 100644 --- a/src/Symfony/Component/Tui/Tests/Widget/EditorTest.php +++ b/src/Symfony/Component/Tui/Tests/Widget/EditorTest.php @@ -96,7 +96,7 @@ public function testOnChangeCallback() [$editor, $tui] = $this->createEditorWithTui(); $changedText = null; - $tui->on(ChangeEvent::class, function (ChangeEvent $e) use (&$changedText) { + $tui->on(ChangeEvent::class, static function (ChangeEvent $e) use (&$changedText) { $changedText = $e->getValue(); }); diff --git a/src/Symfony/Component/Tui/Tests/Widget/Figlet/FigletFontTest.php b/src/Symfony/Component/Tui/Tests/Widget/Figlet/FigletFontTest.php index 02907904af1ab..427ec960c87d3 100644 --- a/src/Symfony/Component/Tui/Tests/Widget/Figlet/FigletFontTest.php +++ b/src/Symfony/Component/Tui/Tests/Widget/Figlet/FigletFontTest.php @@ -43,7 +43,7 @@ public function testLoadBundledFont(string $name, int $expectedHeight) for ($code = 32; $code <= 126; ++$code) { $this->assertTrue( $font->hasCharacter($code), - \sprintf('Font "%s" is missing character %d (%s)', $name, $code, chr($code)), + \sprintf('Font "%s" is missing character %d (%s)', $name, $code, \chr($code)), ); } } diff --git a/src/Symfony/Component/Tui/Tests/Widget/InputTest.php b/src/Symfony/Component/Tui/Tests/Widget/InputTest.php index 5519ad83f1c35..39aa9c65f555d 100644 --- a/src/Symfony/Component/Tui/Tests/Widget/InputTest.php +++ b/src/Symfony/Component/Tui/Tests/Widget/InputTest.php @@ -114,7 +114,7 @@ public function testOnSubmitCallback() [$input, $tui] = $this->createInputWithTui(); $submitted = null; - $tui->on(SubmitEvent::class, function (SubmitEvent $e) use (&$submitted) { + $tui->on(SubmitEvent::class, static function (SubmitEvent $e) use (&$submitted) { $submitted = $e->getValue(); }); @@ -129,7 +129,7 @@ public function testOnCancelCallback() [$input, $tui] = $this->createInputWithTui(); $cancelled = false; - $tui->on(CancelEvent::class, function (CancelEvent $e) use (&$cancelled) { + $tui->on(CancelEvent::class, static function (CancelEvent $e) use (&$cancelled) { $cancelled = true; }); @@ -143,7 +143,7 @@ public function testOnInputCallbackConsumesEvent() $input = new InputWidget(); $intercepted = false; - $input->onInput(function (string $data) use (&$intercepted): bool { + $input->onInput(static function (string $data) use (&$intercepted): bool { if ('x' === $data) { $intercepted = true; @@ -162,9 +162,7 @@ public function testOnInputCallbackPassesThrough() { $input = new InputWidget(); - $input->onInput(function (string $data): bool { - return 'x' === $data; - }); + $input->onInput(static fn (string $data): bool => 'x' === $data); $input->handleInput('y'); $this->assertSame('y', $input->getValue(), 'Non-consumed input should be typed'); @@ -253,7 +251,7 @@ public function testOnChangeCallback() [$input, $tui] = $this->createInputWithTui(); $changedValue = null; - $tui->on(ChangeEvent::class, function (ChangeEvent $e) use (&$changedValue) { + $tui->on(ChangeEvent::class, static function (ChangeEvent $e) use (&$changedValue) { $changedValue = $e->getValue(); }); diff --git a/src/Symfony/Component/Tui/Tests/Widget/MarkdownTest.php b/src/Symfony/Component/Tui/Tests/Widget/MarkdownTest.php index 3514c9005538d..444937447cf2a 100644 --- a/src/Symfony/Component/Tui/Tests/Widget/MarkdownTest.php +++ b/src/Symfony/Component/Tui/Tests/Widget/MarkdownTest.php @@ -71,7 +71,7 @@ public function testRenderWithPadding() $lines = $this->renderThroughRenderer($md, 40, 24); // Should have top + content + bottom = 3 lines - $this->assertSame(3, \count($lines)); + $this->assertCount(3, $lines); } public function testAllLinesRespectWidth() diff --git a/src/Symfony/Component/Tui/Tests/Widget/ProgressBarTest.php b/src/Symfony/Component/Tui/Tests/Widget/ProgressBarTest.php index 690b9a0811f4b..bc822c65eece5 100644 --- a/src/Symfony/Component/Tui/Tests/Widget/ProgressBarTest.php +++ b/src/Symfony/Component/Tui/Tests/Widget/ProgressBarTest.php @@ -248,7 +248,7 @@ public function testCustomPlaceholderFormatter() { $bar = new ProgressBarWidget(100); $bar->setFormat('%custom%'); - $bar->setPlaceholderFormatter('custom', fn (ProgressBarWidget $b) => 'step-'.$b->getProgress()); + $bar->setPlaceholderFormatter('custom', static fn (ProgressBarWidget $b) => 'step-'.$b->getProgress()); $bar->setProgress(7); @@ -260,7 +260,7 @@ public function testCustomPlaceholderFormatter() public function testDefaultPlaceholderFormatter() { - ProgressBarWidget::setDefaultPlaceholderFormatter('global_test', fn (ProgressBarWidget $b) => 'G'.$b->getProgress()); + ProgressBarWidget::setDefaultPlaceholderFormatter('global_test', static fn (ProgressBarWidget $b) => 'G'.$b->getProgress()); $bar = new ProgressBarWidget(100); $bar->setFormat('%global_test%'); @@ -274,11 +274,11 @@ public function testDefaultPlaceholderFormatter() public function testInstanceFormatterOverridesDefault() { - ProgressBarWidget::setDefaultPlaceholderFormatter('override_test', fn (ProgressBarWidget $b) => 'DEFAULT'); + ProgressBarWidget::setDefaultPlaceholderFormatter('override_test', static fn (ProgressBarWidget $b) => 'DEFAULT'); $bar = new ProgressBarWidget(100); $bar->setFormat('%override_test%'); - $bar->setPlaceholderFormatter('override_test', fn (ProgressBarWidget $b) => 'INSTANCE'); + $bar->setPlaceholderFormatter('override_test', static fn (ProgressBarWidget $b) => 'INSTANCE'); $lines = $bar->render(new RenderContext(80, 24)); $content = $lines[0]; diff --git a/src/Symfony/Component/Tui/Tests/Widget/SelectListTest.php b/src/Symfony/Component/Tui/Tests/Widget/SelectListTest.php index 8625397984d67..f2759cc4d4aa7 100644 --- a/src/Symfony/Component/Tui/Tests/Widget/SelectListTest.php +++ b/src/Symfony/Component/Tui/Tests/Widget/SelectListTest.php @@ -93,7 +93,7 @@ public function testOnSelectCallback() [$list, $tui] = $this->createTestListWithTui(); $selectedItem = null; - $tui->on(SelectEvent::class, function (SelectEvent $e) use (&$selectedItem) { + $tui->on(SelectEvent::class, static function (SelectEvent $e) use (&$selectedItem) { $selectedItem = $e->getItem(); }); @@ -108,7 +108,7 @@ public function testOnCancelCallback() [$list, $tui] = $this->createTestListWithTui(); $cancelled = false; - $tui->on(CancelEvent::class, function (CancelEvent $e) use (&$cancelled) { + $tui->on(CancelEvent::class, static function (CancelEvent $e) use (&$cancelled) { $cancelled = true; }); @@ -176,7 +176,7 @@ public function testOnSelectionChangeCallback() [$list, $tui] = $this->createTestListWithTui(); $changedItem = null; - $tui->on(SelectionChangeEvent::class, function (SelectionChangeEvent $e) use (&$changedItem) { + $tui->on(SelectionChangeEvent::class, static function (SelectionChangeEvent $e) use (&$changedItem) { $changedItem = $e->getItem(); }); @@ -193,7 +193,7 @@ public function testNavigationOnEmptyFilteredItemsDoesNotCrash(string $input) $list->setFilter('nonexistent'); $selectionChanged = false; - $tui->on(SelectionChangeEvent::class, function () use (&$selectionChanged) { + $tui->on(SelectionChangeEvent::class, static function () use (&$selectionChanged) { $selectionChanged = true; }); @@ -221,7 +221,7 @@ public function testCancelStillWorksOnEmptyFilteredItems() $list->setFilter('nonexistent'); $cancelled = false; - $tui->on(CancelEvent::class, function () use (&$cancelled) { + $tui->on(CancelEvent::class, static function () use (&$cancelled) { $cancelled = true; }); diff --git a/src/Symfony/Component/Tui/Tests/Widget/SettingsListTest.php b/src/Symfony/Component/Tui/Tests/Widget/SettingsListTest.php index 24b83e77345fe..a6d48bc63f5e8 100644 --- a/src/Symfony/Component/Tui/Tests/Widget/SettingsListTest.php +++ b/src/Symfony/Component/Tui/Tests/Widget/SettingsListTest.php @@ -67,7 +67,7 @@ public function testOnChangeCallback() $changedId = null; $changedValue = null; - $tui->on(SettingChangeEvent::class, function (SettingChangeEvent $e) use (&$changedId, &$changedValue) { + $tui->on(SettingChangeEvent::class, static function (SettingChangeEvent $e) use (&$changedId, &$changedValue) { $changedId = $e->getId(); $changedValue = $e->getValue(); }); @@ -84,7 +84,7 @@ public function testOnCancelCallback() [$widget, $tui] = $this->createWithTui(); $cancelled = false; - $tui->on(CancelEvent::class, function (CancelEvent $e) use (&$cancelled) { + $tui->on(CancelEvent::class, static function (CancelEvent $e) use (&$cancelled) { $cancelled = true; }); @@ -116,7 +116,7 @@ public function testSubmenuRenderedThroughRenderer() id: 'model', label: 'Model', currentValue: 'gpt-4', - submenu: function (string $currentValue, callable $onDone) use (&$submenuWidget) { + submenu: static function (string $currentValue, callable $onDone) use (&$submenuWidget) { $list = new SelectListWidget([ ['value' => 'gpt-4', 'label' => 'GPT-4'], ['value' => 'claude', 'label' => 'Claude'], @@ -173,7 +173,7 @@ public function testSubmenuCloseSetsChildrenEmpty() id: 'model', label: 'Model', currentValue: 'gpt-4', - submenu: function (string $currentValue, callable $onDone) use (&$onDoneCallback) { + submenu: static function (string $currentValue, callable $onDone) use (&$onDoneCallback) { $onDoneCallback = $onDone; $list = new SelectListWidget([ @@ -206,7 +206,7 @@ public function testDetachClearsActiveSubmenu() id: 'model', label: 'Model', currentValue: 'gpt-4', - submenu: function (string $currentValue, callable $onDone) { + submenu: static function (string $currentValue, callable $onDone) { $list = new SelectListWidget([ ['value' => 'gpt-4', 'label' => 'GPT-4'], ['value' => 'claude', 'label' => 'Claude'], @@ -240,12 +240,10 @@ public function testSubmenuListenersCleanedUpOnDetach() id: 'model', label: 'Model', currentValue: 'gpt-4', - submenu: function (string $currentValue, callable $onDone) { - return new SelectListWidget([ - ['value' => 'gpt-4', 'label' => 'GPT-4'], - ['value' => 'claude', 'label' => 'Claude'], - ], 5); - }, + submenu: static fn (string $currentValue, callable $onDone) => new SelectListWidget([ + ['value' => 'gpt-4', 'label' => 'GPT-4'], + ['value' => 'claude', 'label' => 'Claude'], + ], 5), ), ]; diff --git a/src/Symfony/Component/Tui/Widget/AbstractWidget.php b/src/Symfony/Component/Tui/Widget/AbstractWidget.php index 2f6d1543d9f78..bb985c3965837 100644 --- a/src/Symfony/Component/Tui/Widget/AbstractWidget.php +++ b/src/Symfony/Component/Tui/Widget/AbstractWidget.php @@ -109,7 +109,7 @@ final public function findById(string $id): ?self return null; } - final public function getParent(): ?AbstractWidget + final public function getParent(): ?self { return $this->parent; } @@ -158,7 +158,7 @@ final public function removeStyleClass(string $class): self { $newClasses = array_values(array_filter( $this->styleClasses, - fn (string $c) => $c !== $class, + static fn (string $c) => $c !== $class, )); if ($newClasses !== $this->styleClasses) { @@ -200,7 +200,7 @@ final public function invalidate(): void /** * @internal */ - final public function attach(?AbstractWidget $parent, WidgetContext $context): void + final public function attach(?self $parent, WidgetContext $context): void { $this->parent = $parent; $this->context = $context; @@ -384,7 +384,7 @@ abstract public function render(RenderContext $context): array; /** * @internal */ - final protected function setParent(?AbstractWidget $parent): void + final protected function setParent(?self $parent): void { $this->parent = $parent; $this->invalidate(); diff --git a/src/Symfony/Component/Tui/Widget/Editor/EditorRenderer.php b/src/Symfony/Component/Tui/Widget/Editor/EditorRenderer.php index 040fc7f469468..90397d76e1c4a 100644 --- a/src/Symfony/Component/Tui/Widget/Editor/EditorRenderer.php +++ b/src/Symfony/Component/Tui/Widget/Editor/EditorRenderer.php @@ -182,10 +182,10 @@ private function renderCursorInChunk(string $chunkText, int $cursorPosInChunk, i $cursorCharIndex = \count($graphemes); } - $beforeCursor = implode('', array_slice($graphemes, 0, $cursorCharIndex)); + $beforeCursor = implode('', \array_slice($graphemes, 0, $cursorCharIndex)); if (isset($graphemes[$cursorCharIndex])) { $atCursor = $graphemes[$cursorCharIndex]; - $afterCursor = implode('', array_slice($graphemes, $cursorCharIndex + 1)); + $afterCursor = implode('', \array_slice($graphemes, $cursorCharIndex + 1)); } } if (false === $graphemes) { diff --git a/src/Symfony/Component/Tui/Widget/Markdown/DarkTerminalTheme.php b/src/Symfony/Component/Tui/Widget/Markdown/DarkTerminalTheme.php index 19ef250e8e522..f6d9418d47dcd 100644 --- a/src/Symfony/Component/Tui/Widget/Markdown/DarkTerminalTheme.php +++ b/src/Symfony/Component/Tui/Widget/Markdown/DarkTerminalTheme.php @@ -58,7 +58,7 @@ public function before(TokenType $tokenType): string } // Use 24-bit RGB escape sequence - return sprintf("\x1b[38;2;%d;%d;%dm", $rgb[0], $rgb[1], $rgb[2]); + return \sprintf("\x1b[38;2;%d;%d;%dm", $rgb[0], $rgb[1], $rgb[2]); } public function after(TokenType $tokenType): string diff --git a/src/Symfony/Component/Tui/Widget/MarkdownWidget.php b/src/Symfony/Component/Tui/Widget/MarkdownWidget.php index a69ef9e645a09..9b872d4a64430 100644 --- a/src/Symfony/Component/Tui/Widget/MarkdownWidget.php +++ b/src/Symfony/Component/Tui/Widget/MarkdownWidget.php @@ -458,7 +458,7 @@ private function formatTable(array $headers, array $rows, int $availableColumns) $lines = []; // Top border - $topBorderCells = array_map(fn (int $w) => str_repeat('─', $w), $columnWidths); + $topBorderCells = array_map(static fn (int $w) => str_repeat('─', $w), $columnWidths); $lines[] = '┌─'.implode('─┬─', $topBorderCells).'─┐'; // Header row @@ -482,7 +482,7 @@ private function formatTable(array $headers, array $rows, int $availableColumns) } // Separator - $separatorCells = array_map(fn (int $w) => str_repeat('─', $w), $columnWidths); + $separatorCells = array_map(static fn (int $w) => str_repeat('─', $w), $columnWidths); $lines[] = '├─'.implode('─┼─', $separatorCells).'─┤'; } @@ -506,7 +506,7 @@ private function formatTable(array $headers, array $rows, int $availableColumns) } // Bottom border - $bottomBorderCells = array_map(fn (int $w) => str_repeat('─', $w), $columnWidths); + $bottomBorderCells = array_map(static fn (int $w) => str_repeat('─', $w), $columnWidths); $lines[] = '└─'.implode('─┴─', $bottomBorderCells).'─┘'; return $lines; diff --git a/src/Symfony/Component/Tui/Widget/SelectListWidget.php b/src/Symfony/Component/Tui/Widget/SelectListWidget.php index 36c3ae7ee3c63..ef8b68776ff36 100644 --- a/src/Symfony/Component/Tui/Widget/SelectListWidget.php +++ b/src/Symfony/Component/Tui/Widget/SelectListWidget.php @@ -75,7 +75,7 @@ public function setFilter(string $filter): self $filteredItems = array_values(array_filter( $this->items, - fn ($item) => str_starts_with(strtolower($item['value']), $filter), + static fn ($item) => str_starts_with(strtolower($item['value']), $filter), )); if ($filteredItems !== $this->filteredItems) { diff --git a/src/Symfony/Component/Tui/Widget/SettingsListWidget.php b/src/Symfony/Component/Tui/Widget/SettingsListWidget.php index 483a80c3accd6..a444bf3f43029 100644 --- a/src/Symfony/Component/Tui/Widget/SettingsListWidget.php +++ b/src/Symfony/Component/Tui/Widget/SettingsListWidget.php @@ -378,12 +378,12 @@ private function activateCurrentItem(): void // Wire submenu events: when the inner widget dispatches // SelectEvent or CancelEvent, route to the onDone callback $dispatcher = $context->getEventDispatcher(); - $selectListener = function (SelectEvent $e) use ($submenu, $onDone): void { + $selectListener = static function (SelectEvent $e) use ($submenu, $onDone): void { if ($e->getTarget() === $submenu) { $onDone($e->getValue()); } }; - $cancelListener = function (CancelEvent $e) use ($submenu, $onDone): void { + $cancelListener = static function (CancelEvent $e) use ($submenu, $onDone): void { if ($e->getTarget() === $submenu) { $onDone(null); } From ec93ef204177dd4d42e8da8d2912aa4df069a781 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Fri, 27 Mar 2026 07:34:36 +0100 Subject: [PATCH 06/22] Add @internal annotation to Tui component implementation classes --- src/Symfony/Component/Tui/Ansi/AnsiCodeTracker.php | 2 ++ src/Symfony/Component/Tui/Input/KeyParser.php | 2 ++ src/Symfony/Component/Tui/Input/StdinBuffer.php | 2 ++ src/Symfony/Component/Tui/Loop/AdaptativeTicker.php | 2 ++ src/Symfony/Component/Tui/Loop/FixedStepAccumulator.php | 2 ++ src/Symfony/Component/Tui/Loop/LoopClock.php | 2 ++ src/Symfony/Component/Tui/Loop/PeriodicStepper.php | 2 ++ src/Symfony/Component/Tui/Loop/TickRuntimeInterface.php | 2 ++ src/Symfony/Component/Tui/Loop/TickScheduler.php | 2 ++ src/Symfony/Component/Tui/Render/CellBuffer.php | 2 ++ src/Symfony/Component/Tui/Render/ChromeApplier.php | 2 ++ src/Symfony/Component/Tui/Render/Compositor.php | 2 ++ src/Symfony/Component/Tui/Render/Layer.php | 2 ++ src/Symfony/Component/Tui/Render/LayoutEngine.php | 2 ++ src/Symfony/Component/Tui/Render/PositionTracker.php | 2 ++ src/Symfony/Component/Tui/Render/RenderRequestorInterface.php | 2 ++ src/Symfony/Component/Tui/Render/Renderer.php | 2 ++ src/Symfony/Component/Tui/Render/ScreenWriter.php | 2 ++ src/Symfony/Component/Tui/Render/WidgetRect.php | 2 ++ src/Symfony/Component/Tui/Render/WidgetRendererInterface.php | 2 ++ src/Symfony/Component/Tui/Style/ColorType.php | 2 ++ src/Symfony/Component/Tui/Style/DefaultStyleSheet.php | 2 ++ src/Symfony/Component/Tui/Widget/DirtyWidgetTrait.php | 2 ++ src/Symfony/Component/Tui/Widget/Figlet/FigletFont.php | 2 ++ src/Symfony/Component/Tui/Widget/Figlet/FigletRenderer.php | 2 ++ src/Symfony/Component/Tui/Widget/Figlet/FontRegistry.php | 2 ++ src/Symfony/Component/Tui/Widget/Markdown/DarkTerminalTheme.php | 2 ++ src/Symfony/Component/Tui/Widget/Util/KillRing.php | 2 ++ src/Symfony/Component/Tui/Widget/Util/Line.php | 2 ++ src/Symfony/Component/Tui/Widget/Util/StringUtils.php | 2 ++ src/Symfony/Component/Tui/Widget/Util/WordNavigator.php | 2 ++ src/Symfony/Component/Tui/Widget/WidgetTree.php | 2 ++ 32 files changed, 64 insertions(+) diff --git a/src/Symfony/Component/Tui/Ansi/AnsiCodeTracker.php b/src/Symfony/Component/Tui/Ansi/AnsiCodeTracker.php index 490c115bf30c5..02e3ba66cb806 100644 --- a/src/Symfony/Component/Tui/Ansi/AnsiCodeTracker.php +++ b/src/Symfony/Component/Tui/Ansi/AnsiCodeTracker.php @@ -16,6 +16,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ final class AnsiCodeTracker diff --git a/src/Symfony/Component/Tui/Input/KeyParser.php b/src/Symfony/Component/Tui/Input/KeyParser.php index bb6bac6ecedea..cf64e28a3b484 100644 --- a/src/Symfony/Component/Tui/Input/KeyParser.php +++ b/src/Symfony/Component/Tui/Input/KeyParser.php @@ -18,6 +18,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ final class KeyParser diff --git a/src/Symfony/Component/Tui/Input/StdinBuffer.php b/src/Symfony/Component/Tui/Input/StdinBuffer.php index aadf6382df7c1..9a076eb82e4fa 100644 --- a/src/Symfony/Component/Tui/Input/StdinBuffer.php +++ b/src/Symfony/Component/Tui/Input/StdinBuffer.php @@ -19,6 +19,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ final class StdinBuffer diff --git a/src/Symfony/Component/Tui/Loop/AdaptativeTicker.php b/src/Symfony/Component/Tui/Loop/AdaptativeTicker.php index 26fa0e1c93add..ff5e637bc92de 100644 --- a/src/Symfony/Component/Tui/Loop/AdaptativeTicker.php +++ b/src/Symfony/Component/Tui/Loop/AdaptativeTicker.php @@ -18,6 +18,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ final class AdaptativeTicker diff --git a/src/Symfony/Component/Tui/Loop/FixedStepAccumulator.php b/src/Symfony/Component/Tui/Loop/FixedStepAccumulator.php index 4c050c4892f38..5d4aaecb68d91 100644 --- a/src/Symfony/Component/Tui/Loop/FixedStepAccumulator.php +++ b/src/Symfony/Component/Tui/Loop/FixedStepAccumulator.php @@ -18,6 +18,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ final class FixedStepAccumulator diff --git a/src/Symfony/Component/Tui/Loop/LoopClock.php b/src/Symfony/Component/Tui/Loop/LoopClock.php index 7cfd2cb6c0699..2e3ff1c5f8093 100644 --- a/src/Symfony/Component/Tui/Loop/LoopClock.php +++ b/src/Symfony/Component/Tui/Loop/LoopClock.php @@ -16,6 +16,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ final class LoopClock diff --git a/src/Symfony/Component/Tui/Loop/PeriodicStepper.php b/src/Symfony/Component/Tui/Loop/PeriodicStepper.php index 82406d9925c6f..9ab557f47b0ef 100644 --- a/src/Symfony/Component/Tui/Loop/PeriodicStepper.php +++ b/src/Symfony/Component/Tui/Loop/PeriodicStepper.php @@ -18,6 +18,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ final class PeriodicStepper diff --git a/src/Symfony/Component/Tui/Loop/TickRuntimeInterface.php b/src/Symfony/Component/Tui/Loop/TickRuntimeInterface.php index 56901f423b6aa..06ac471be74d3 100644 --- a/src/Symfony/Component/Tui/Loop/TickRuntimeInterface.php +++ b/src/Symfony/Component/Tui/Loop/TickRuntimeInterface.php @@ -16,6 +16,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ interface TickRuntimeInterface diff --git a/src/Symfony/Component/Tui/Loop/TickScheduler.php b/src/Symfony/Component/Tui/Loop/TickScheduler.php index 8fb9073f3d61c..cfbf32c5a96f3 100644 --- a/src/Symfony/Component/Tui/Loop/TickScheduler.php +++ b/src/Symfony/Component/Tui/Loop/TickScheduler.php @@ -18,6 +18,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ final class TickScheduler diff --git a/src/Symfony/Component/Tui/Render/CellBuffer.php b/src/Symfony/Component/Tui/Render/CellBuffer.php index 304317b567983..045d3daa91938 100644 --- a/src/Symfony/Component/Tui/Render/CellBuffer.php +++ b/src/Symfony/Component/Tui/Render/CellBuffer.php @@ -28,6 +28,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ final class CellBuffer diff --git a/src/Symfony/Component/Tui/Render/ChromeApplier.php b/src/Symfony/Component/Tui/Render/ChromeApplier.php index 7de261c72fad8..eebdd0a2a626a 100644 --- a/src/Symfony/Component/Tui/Render/ChromeApplier.php +++ b/src/Symfony/Component/Tui/Render/ChromeApplier.php @@ -25,6 +25,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ final class ChromeApplier diff --git a/src/Symfony/Component/Tui/Render/Compositor.php b/src/Symfony/Component/Tui/Render/Compositor.php index 86e429913f98d..002bebb379707 100644 --- a/src/Symfony/Component/Tui/Render/Compositor.php +++ b/src/Symfony/Component/Tui/Render/Compositor.php @@ -33,6 +33,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ final class Compositor diff --git a/src/Symfony/Component/Tui/Render/Layer.php b/src/Symfony/Component/Tui/Render/Layer.php index 1769d45ac0d98..a17a75870896b 100644 --- a/src/Symfony/Component/Tui/Render/Layer.php +++ b/src/Symfony/Component/Tui/Render/Layer.php @@ -20,6 +20,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ final class Layer diff --git a/src/Symfony/Component/Tui/Render/LayoutEngine.php b/src/Symfony/Component/Tui/Render/LayoutEngine.php index c602d941164f2..ce32ae80edb7e 100644 --- a/src/Symfony/Component/Tui/Render/LayoutEngine.php +++ b/src/Symfony/Component/Tui/Render/LayoutEngine.php @@ -28,6 +28,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ final class LayoutEngine diff --git a/src/Symfony/Component/Tui/Render/PositionTracker.php b/src/Symfony/Component/Tui/Render/PositionTracker.php index 5ae6caf71e8a6..d0e6f6ad69352 100644 --- a/src/Symfony/Component/Tui/Render/PositionTracker.php +++ b/src/Symfony/Component/Tui/Render/PositionTracker.php @@ -21,6 +21,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ final class PositionTracker diff --git a/src/Symfony/Component/Tui/Render/RenderRequestorInterface.php b/src/Symfony/Component/Tui/Render/RenderRequestorInterface.php index 0f8f874df9d00..c5f82e187f6d3 100644 --- a/src/Symfony/Component/Tui/Render/RenderRequestorInterface.php +++ b/src/Symfony/Component/Tui/Render/RenderRequestorInterface.php @@ -20,6 +20,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ interface RenderRequestorInterface diff --git a/src/Symfony/Component/Tui/Render/Renderer.php b/src/Symfony/Component/Tui/Render/Renderer.php index df1ecc1650c42..c995c84a3fa07 100644 --- a/src/Symfony/Component/Tui/Render/Renderer.php +++ b/src/Symfony/Component/Tui/Render/Renderer.php @@ -37,6 +37,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ final class Renderer implements WidgetRendererInterface diff --git a/src/Symfony/Component/Tui/Render/ScreenWriter.php b/src/Symfony/Component/Tui/Render/ScreenWriter.php index c30178f564921..89b0f90decf4a 100644 --- a/src/Symfony/Component/Tui/Render/ScreenWriter.php +++ b/src/Symfony/Component/Tui/Render/ScreenWriter.php @@ -29,6 +29,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ final class ScreenWriter diff --git a/src/Symfony/Component/Tui/Render/WidgetRect.php b/src/Symfony/Component/Tui/Render/WidgetRect.php index c35c3346c3e9c..10b835a254657 100644 --- a/src/Symfony/Component/Tui/Render/WidgetRect.php +++ b/src/Symfony/Component/Tui/Render/WidgetRect.php @@ -18,6 +18,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ final class WidgetRect diff --git a/src/Symfony/Component/Tui/Render/WidgetRendererInterface.php b/src/Symfony/Component/Tui/Render/WidgetRendererInterface.php index c970f789dcb2e..f7c09d7b2a059 100644 --- a/src/Symfony/Component/Tui/Render/WidgetRendererInterface.php +++ b/src/Symfony/Component/Tui/Render/WidgetRendererInterface.php @@ -22,6 +22,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ interface WidgetRendererInterface diff --git a/src/Symfony/Component/Tui/Style/ColorType.php b/src/Symfony/Component/Tui/Style/ColorType.php index 01fd558fde01f..2cde06f4f6de6 100644 --- a/src/Symfony/Component/Tui/Style/ColorType.php +++ b/src/Symfony/Component/Tui/Style/ColorType.php @@ -16,6 +16,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ enum ColorType diff --git a/src/Symfony/Component/Tui/Style/DefaultStyleSheet.php b/src/Symfony/Component/Tui/Style/DefaultStyleSheet.php index 58c3b3224da7e..86c1e6e3f9f10 100644 --- a/src/Symfony/Component/Tui/Style/DefaultStyleSheet.php +++ b/src/Symfony/Component/Tui/Style/DefaultStyleSheet.php @@ -28,6 +28,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ final class DefaultStyleSheet diff --git a/src/Symfony/Component/Tui/Widget/DirtyWidgetTrait.php b/src/Symfony/Component/Tui/Widget/DirtyWidgetTrait.php index 87eb836fedabd..c171426aaf323 100644 --- a/src/Symfony/Component/Tui/Widget/DirtyWidgetTrait.php +++ b/src/Symfony/Component/Tui/Widget/DirtyWidgetTrait.php @@ -16,6 +16,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ trait DirtyWidgetTrait diff --git a/src/Symfony/Component/Tui/Widget/Figlet/FigletFont.php b/src/Symfony/Component/Tui/Widget/Figlet/FigletFont.php index 0aba52ee3bcf1..244ba1ce1a062 100644 --- a/src/Symfony/Component/Tui/Widget/Figlet/FigletFont.php +++ b/src/Symfony/Component/Tui/Widget/Figlet/FigletFont.php @@ -28,6 +28,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ final class FigletFont diff --git a/src/Symfony/Component/Tui/Widget/Figlet/FigletRenderer.php b/src/Symfony/Component/Tui/Widget/Figlet/FigletRenderer.php index 5218adb7e4ce2..054da48af7092 100644 --- a/src/Symfony/Component/Tui/Widget/Figlet/FigletRenderer.php +++ b/src/Symfony/Component/Tui/Widget/Figlet/FigletRenderer.php @@ -28,6 +28,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ final class FigletRenderer diff --git a/src/Symfony/Component/Tui/Widget/Figlet/FontRegistry.php b/src/Symfony/Component/Tui/Widget/Figlet/FontRegistry.php index e4fbd5d1e7979..b25e811be258e 100644 --- a/src/Symfony/Component/Tui/Widget/Figlet/FontRegistry.php +++ b/src/Symfony/Component/Tui/Widget/Figlet/FontRegistry.php @@ -30,6 +30,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ final class FontRegistry diff --git a/src/Symfony/Component/Tui/Widget/Markdown/DarkTerminalTheme.php b/src/Symfony/Component/Tui/Widget/Markdown/DarkTerminalTheme.php index f6d9418d47dcd..3a076f891f09d 100644 --- a/src/Symfony/Component/Tui/Widget/Markdown/DarkTerminalTheme.php +++ b/src/Symfony/Component/Tui/Widget/Markdown/DarkTerminalTheme.php @@ -32,6 +32,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ final class DarkTerminalTheme implements TerminalTheme diff --git a/src/Symfony/Component/Tui/Widget/Util/KillRing.php b/src/Symfony/Component/Tui/Widget/Util/KillRing.php index bb29f9a7228c6..dded6ee6cf10a 100644 --- a/src/Symfony/Component/Tui/Widget/Util/KillRing.php +++ b/src/Symfony/Component/Tui/Widget/Util/KillRing.php @@ -19,6 +19,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ class KillRing diff --git a/src/Symfony/Component/Tui/Widget/Util/Line.php b/src/Symfony/Component/Tui/Widget/Util/Line.php index f26c6e348f377..5c9e390f23139 100644 --- a/src/Symfony/Component/Tui/Widget/Util/Line.php +++ b/src/Symfony/Component/Tui/Widget/Util/Line.php @@ -23,6 +23,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ final class Line diff --git a/src/Symfony/Component/Tui/Widget/Util/StringUtils.php b/src/Symfony/Component/Tui/Widget/Util/StringUtils.php index 17d4ccfb0e904..f22b2d13fdea2 100644 --- a/src/Symfony/Component/Tui/Widget/Util/StringUtils.php +++ b/src/Symfony/Component/Tui/Widget/Util/StringUtils.php @@ -16,6 +16,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ final class StringUtils diff --git a/src/Symfony/Component/Tui/Widget/Util/WordNavigator.php b/src/Symfony/Component/Tui/Widget/Util/WordNavigator.php index 8f98bbac63518..4af376219aa54 100644 --- a/src/Symfony/Component/Tui/Widget/Util/WordNavigator.php +++ b/src/Symfony/Component/Tui/Widget/Util/WordNavigator.php @@ -22,6 +22,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ final class WordNavigator diff --git a/src/Symfony/Component/Tui/Widget/WidgetTree.php b/src/Symfony/Component/Tui/Widget/WidgetTree.php index 4df6e1c91b1dd..5421406874e5c 100644 --- a/src/Symfony/Component/Tui/Widget/WidgetTree.php +++ b/src/Symfony/Component/Tui/Widget/WidgetTree.php @@ -23,6 +23,8 @@ * * @experimental * + * @internal + * * @author Fabien Potencier */ final class WidgetTree From 374b5bcc8127947068f4695268bd40fa1442c355 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Fri, 27 Mar 2026 08:15:49 +0100 Subject: [PATCH 07/22] Remove onDebug entirely, users can use onInput or onTick instead --- src/Symfony/Component/Tui/Tui.php | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/src/Symfony/Component/Tui/Tui.php b/src/Symfony/Component/Tui/Tui.php index 7c9d7992b8f93..8c78411c97466 100644 --- a/src/Symfony/Component/Tui/Tui.php +++ b/src/Symfony/Component/Tui/Tui.php @@ -74,9 +74,6 @@ class Tui implements RenderRequestorInterface, TickRuntimeInterface /** @var (callable(string): bool)|null */ private $onInput; - /** @var callable(): void */ - private $onDebug; - /** @var callable(TickEvent): mixed */ private $onTick; @@ -338,16 +335,6 @@ public function quitOn(string ...$keys): self return $this; } - /** - * @return $this - */ - public function onDebug(?callable $onDebug): self - { - $this->onDebug = $onDebug; - - return $this; - } - /** * @param callable(TickEvent): mixed $onTick * @@ -525,13 +512,6 @@ public function handleInput(string $data): void return; } - // Global debug key handler (Shift+Ctrl+D) - if ("\x1b[68;6u" === $data && null !== $this->onDebug) { - ($this->onDebug)(); - - return; - } - if ($this->focusManager->handleInput($data)) { return; } From 73f91043acc366298470a34e8aaaeba1cfd6f2ef Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sat, 28 Mar 2026 10:57:09 +0100 Subject: [PATCH 08/22] [Tui] Remove quitOn() in favor of onInput() --- src/Symfony/Component/Tui/Tests/TuiTest.php | 54 --------------------- src/Symfony/Component/Tui/Tui.php | 31 ------------ 2 files changed, 85 deletions(-) diff --git a/src/Symfony/Component/Tui/Tests/TuiTest.php b/src/Symfony/Component/Tui/Tests/TuiTest.php index 2e966b19a8f2d..5f2afb7ca2dfe 100644 --- a/src/Symfony/Component/Tui/Tests/TuiTest.php +++ b/src/Symfony/Component/Tui/Tests/TuiTest.php @@ -393,60 +393,6 @@ public function testOnInputCanConsumeKittyCtrlCSequence() $this->assertSame('', $input->getValue()); } - public function testQuitOnStopsTuiWhenMatchingKeyIsPressed() - { - $terminal = new VirtualTerminal(40, 10); - $tui = new Tui(terminal: $terminal); - - $input = new InputWidget(); - $tui->add($input); - $tui->setFocus($input); - $tui->quitOn('ctrl+c'); - - $tui->start(); - $this->assertTrue($tui->isRunning()); - - $tui->handleInput("\x03"); - - $this->assertFalse($tui->isRunning()); - $this->assertSame('', $input->getValue()); - } - - public function testQuitOnDoesNotConsumeNonMatchingKeys() - { - $terminal = new VirtualTerminal(40, 10); - $tui = new Tui(terminal: $terminal); - - $input = new InputWidget(); - $tui->add($input); - $tui->setFocus($input); - $tui->quitOn('ctrl+c'); - - $tui->handleInput('a'); - - $this->assertSame('a', $input->getValue()); - } - - public function testQuitOnRunsBeforeOnInput() - { - $terminal = new VirtualTerminal(40, 10); - $tui = new Tui(terminal: $terminal); - $tui->quitOn('ctrl+c'); - - $onInputCalled = false; - $tui->onInput(static function () use (&$onInputCalled): bool { - $onInputCalled = true; - - return true; - }); - - $tui->start(); - $tui->handleInput("\x03"); - - $this->assertFalse($tui->isRunning()); - $this->assertFalse($onInputCalled); - } - public function testGetById() { $terminal = new VirtualTerminal(40, 10); diff --git a/src/Symfony/Component/Tui/Tui.php b/src/Symfony/Component/Tui/Tui.php index 8c78411c97466..f58cb80ff88bc 100644 --- a/src/Symfony/Component/Tui/Tui.php +++ b/src/Symfony/Component/Tui/Tui.php @@ -68,9 +68,6 @@ class Tui implements RenderRequestorInterface, TickRuntimeInterface private AdaptativeTicker $adaptativeTicker; private EventDispatcherInterface $eventDispatcher; - /** @var string[] */ - private array $quitKeys = []; - /** @var (callable(string): bool)|null */ private $onInput; @@ -318,23 +315,6 @@ public function onInput(?callable $onInput): self return $this; } - /** - * Register key patterns that stop the TUI. - * - * Quit keys are checked at the very start of input handling, - * before the onInput interceptor and focus manager. - * - * @param string ...$keys Key identifiers (e.g., 'ctrl+c', Key::ESCAPE) - * - * @return $this - */ - public function quitOn(string ...$keys): self - { - $this->quitKeys = $keys; - - return $this; - } - /** * @param callable(TickEvent): mixed $onTick * @@ -497,17 +477,6 @@ public function processRender(): void */ public function handleInput(string $data): void { - // Check quit keys - if ([] !== $this->quitKeys) { - foreach ($this->quitKeys as $key) { - if ($this->keybindings->getParser()->matches($data, $key)) { - $this->stop(); - - return; - } - } - } - if (null !== $this->onInput && ($this->onInput)($data)) { return; } From e60a83c01ee70d4ca07ed6b40d0f2d3403b44002 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sat, 28 Mar 2026 11:02:11 +0100 Subject: [PATCH 09/22] [Tui] Replace onInput() callback with InputEvent --- .../Component/Tui/Event/InputEvent.php | 41 +++++++++++++++++++ src/Symfony/Component/Tui/Tests/TuiTest.php | 35 ++++++++-------- src/Symfony/Component/Tui/Tui.php | 24 ++--------- 3 files changed, 61 insertions(+), 39 deletions(-) create mode 100644 src/Symfony/Component/Tui/Event/InputEvent.php diff --git a/src/Symfony/Component/Tui/Event/InputEvent.php b/src/Symfony/Component/Tui/Event/InputEvent.php new file mode 100644 index 0000000000000..f129a48b9469b --- /dev/null +++ b/src/Symfony/Component/Tui/Event/InputEvent.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Event; + +use Symfony\Contracts\EventDispatcher\Event; + +/** + * Event dispatched when raw terminal input is received. + * + * Dispatched before focus navigation and before the focused widget + * receives input. Call {@see stopPropagation()} to consume the input + * and prevent further processing. + * + * @experimental + * + * @author Fabien Potencier + */ +class InputEvent extends Event +{ + public function __construct( + private readonly string $data, + ) { + } + + /** + * The raw input data from the terminal. + */ + public function getData(): string + { + return $this->data; + } +} diff --git a/src/Symfony/Component/Tui/Tests/TuiTest.php b/src/Symfony/Component/Tui/Tests/TuiTest.php index 5f2afb7ca2dfe..db1c015cb5363 100644 --- a/src/Symfony/Component/Tui/Tests/TuiTest.php +++ b/src/Symfony/Component/Tui/Tests/TuiTest.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Event\InputEvent; use Symfony\Component\Tui\Event\TickEvent; use Symfony\Component\Tui\Exception\InvalidArgumentException; use Symfony\Component\Tui\Input\Key; @@ -322,7 +323,7 @@ public function testSimulateResizeTriggersRender() $this->assertStringContainsString('Hello', $terminal->getOutput()); } - public function testOnInputCanConsumeGlobalInputBeforeFocusedWidget() + public function testInputEventCanConsumeGlobalInputBeforeFocusedWidget() { $terminal = new VirtualTerminal(40, 10); $tui = new Tui(terminal: $terminal); @@ -333,14 +334,11 @@ public function testOnInputCanConsumeGlobalInputBeforeFocusedWidget() $called = false; $globalKeys = new Keybindings(['quit' => [Key::ctrl('c')]]); - $tui->onInput(static function (string $data) use (&$called, $globalKeys): bool { - if (!$globalKeys->matches($data, 'quit')) { - return false; + $tui->on(InputEvent::class, static function (InputEvent $event) use (&$called, $globalKeys): void { + if ($globalKeys->matches($event->getData(), 'quit')) { + $called = true; + $event->stopPropagation(); } - - $called = true; - - return true; }); $tui->handleInput("\x03"); @@ -349,7 +347,7 @@ public function testOnInputCanConsumeGlobalInputBeforeFocusedWidget() $this->assertSame('', $input->getValue()); } - public function testOnInputCanPassThroughToFocusedWidget() + public function testInputEventCanPassThroughToFocusedWidget() { $terminal = new VirtualTerminal(40, 10); $tui = new Tui(terminal: $terminal); @@ -359,14 +357,18 @@ public function testOnInputCanPassThroughToFocusedWidget() $tui->setFocus($input); $globalKeys = new Keybindings(['quit' => [Key::ctrl('c')]]); - $tui->onInput(static fn (string $data): bool => $globalKeys->matches($data, 'quit')); + $tui->on(InputEvent::class, static function (InputEvent $event) use ($globalKeys): void { + if ($globalKeys->matches($event->getData(), 'quit')) { + $event->stopPropagation(); + } + }); $tui->handleInput('a'); $this->assertSame('a', $input->getValue()); } - public function testOnInputCanConsumeKittyCtrlCSequence() + public function testInputEventCanConsumeKittyCtrlCSequence() { $terminal = new VirtualTerminal(40, 10); $tui = new Tui(terminal: $terminal); @@ -377,14 +379,11 @@ public function testOnInputCanConsumeKittyCtrlCSequence() $called = false; $globalKeys = new Keybindings(['quit' => [Key::ctrl('c')]]); - $tui->onInput(static function (string $data) use (&$called, $globalKeys): bool { - if (!$globalKeys->matches($data, 'quit')) { - return false; + $tui->on(InputEvent::class, static function (InputEvent $event) use (&$called, $globalKeys): void { + if ($globalKeys->matches($event->getData(), 'quit')) { + $called = true; + $event->stopPropagation(); } - - $called = true; - - return true; }); $tui->handleInput("\x1b[99;5u"); diff --git a/src/Symfony/Component/Tui/Tui.php b/src/Symfony/Component/Tui/Tui.php index f58cb80ff88bc..b4a1f9b46c52d 100644 --- a/src/Symfony/Component/Tui/Tui.php +++ b/src/Symfony/Component/Tui/Tui.php @@ -16,6 +16,7 @@ use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Tui\Event\AbstractEvent; +use Symfony\Component\Tui\Event\InputEvent; use Symfony\Component\Tui\Event\TickEvent; use Symfony\Component\Tui\Exception\InvalidArgumentException; use Symfony\Component\Tui\Focus\FocusManager; @@ -68,9 +69,6 @@ class Tui implements RenderRequestorInterface, TickRuntimeInterface private AdaptativeTicker $adaptativeTicker; private EventDispatcherInterface $eventDispatcher; - /** @var (callable(string): bool)|null */ - private $onInput; - /** @var callable(TickEvent): mixed */ private $onTick; @@ -298,23 +296,6 @@ public function isRunning(): bool return $this->running; } - /** - * Register a global input interceptor. - * - * Called before focus navigation and before the focused widget receives input. - * Return true to consume the input. - * - * @param (callable(string): bool)|null $onInput - * - * @return $this - */ - public function onInput(?callable $onInput): self - { - $this->onInput = $onInput; - - return $this; - } - /** * @param callable(TickEvent): mixed $onTick * @@ -477,7 +458,8 @@ public function processRender(): void */ public function handleInput(string $data): void { - if (null !== $this->onInput && ($this->onInput)($data)) { + $event = $this->eventDispatcher->dispatch(new InputEvent($data)); + if ($event->isPropagationStopped()) { return; } From 599a2bbe6c5e9565d8c64bbdd9410b2ed86e7e72 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sat, 28 Mar 2026 11:08:59 +0100 Subject: [PATCH 10/22] Remove comment --- .../Tui/Widget/Markdown/DarkTerminalTheme.php | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/Symfony/Component/Tui/Widget/Markdown/DarkTerminalTheme.php b/src/Symfony/Component/Tui/Widget/Markdown/DarkTerminalTheme.php index 3a076f891f09d..5ba2d8c6ad0d5 100644 --- a/src/Symfony/Component/Tui/Widget/Markdown/DarkTerminalTheme.php +++ b/src/Symfony/Component/Tui/Widget/Markdown/DarkTerminalTheme.php @@ -19,17 +19,6 @@ /** * Dark terminal theme for syntax highlighting. * - * Colors: - * - syntaxComment: #6a6a7a - * - syntaxKeyword: #ff7ab2 - * - syntaxFunction: #4eb0ff - * - syntaxVariable: #78c7ff - * - syntaxString: #d9c97c - * - syntaxNumber: #d9c97c - * - syntaxType: #acf2e4 - * - syntaxOperator: #b281eb - * - syntaxPunctuation: #e5e5e7 - * * @experimental * * @internal From 8218f5c15034fa12a4c95c880872ef51e257a58a Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sat, 28 Mar 2026 11:15:32 +0100 Subject: [PATCH 11/22] [Tui] Fix Escape key and multi-byte quit key handling in StdinBuffer flush --- .../Component/Tui/Terminal/Terminal.php | 6 +++ .../Tui/Terminal/VirtualTerminal.php | 14 +------ src/Symfony/Component/Tui/Tests/TuiTest.php | 39 +++++++++++++++++++ 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/src/Symfony/Component/Tui/Terminal/Terminal.php b/src/Symfony/Component/Tui/Terminal/Terminal.php index 6ac34bc001bc9..f67cd127d0398 100644 --- a/src/Symfony/Component/Tui/Terminal/Terminal.php +++ b/src/Symfony/Component/Tui/Terminal/Terminal.php @@ -98,6 +98,12 @@ public function start(callable $onInput, callable $onResize, callable $onKittyPr $data = fread(\STDIN, 4096); if (false !== $data && '' !== $data && null !== $this->stdinBuffer) { $this->stdinBuffer->process($data); + // Flush any pending lone ESC byte. OS terminals deliver + // complete escape sequences atomically, so a lone \x1b + // remaining after process() can only mean the Escape key. + // Use nullsafe because an InputEvent listener may call + // stop(), which sets stdinBuffer to null during process(). + $this->stdinBuffer?->flush(); } }); } diff --git a/src/Symfony/Component/Tui/Terminal/VirtualTerminal.php b/src/Symfony/Component/Tui/Terminal/VirtualTerminal.php index 76ea28387d4ae..a92e1a666efd3 100644 --- a/src/Symfony/Component/Tui/Terminal/VirtualTerminal.php +++ b/src/Symfony/Component/Tui/Terminal/VirtualTerminal.php @@ -178,19 +178,7 @@ public function simulateInput(string $data): void { if (null !== $this->stdinBuffer) { $this->stdinBuffer->process($data); - } - } - - /** - * Flush any pending input in the buffer. - * - * This should be called when no more input is expected (e.g., end of test input) - * to ensure any pending Escape key is emitted. - */ - public function flushInput(): void - { - if (null !== $this->stdinBuffer) { - $this->stdinBuffer->flush(); + $this->stdinBuffer?->flush(); } } diff --git a/src/Symfony/Component/Tui/Tests/TuiTest.php b/src/Symfony/Component/Tui/Tests/TuiTest.php index db1c015cb5363..dde2a61ce9452 100644 --- a/src/Symfony/Component/Tui/Tests/TuiTest.php +++ b/src/Symfony/Component/Tui/Tests/TuiTest.php @@ -392,6 +392,45 @@ public function testInputEventCanConsumeKittyCtrlCSequence() $this->assertSame('', $input->getValue()); } + public function testEscapeKeyIsDispatchedViaSimulateInput() + { + $terminal = new VirtualTerminal(40, 10); + $tui = new Tui(terminal: $terminal); + + $received = null; + $tui->on(InputEvent::class, static function (InputEvent $event) use (&$received): void { + $received = $event->getData(); + }); + + $tui->start(); + $terminal->simulateInput("\x1b"); + + $this->assertSame("\x1b", $received); + $tui->stop(); + } + + public function testStopFromInputEventListenerDoesNotCrash() + { + $terminal = new VirtualTerminal(40, 10); + $tui = new Tui(terminal: $terminal); + + $keys = new Keybindings(['quit' => [Key::UP]]); + $tui->on(InputEvent::class, static function (InputEvent $event) use ($tui, $keys): void { + if ($keys->matches($event->getData(), 'quit')) { + $tui->stop(); + } + }); + + $tui->start(); + $this->assertTrue($tui->isRunning()); + + // Arrow Up is a multi-byte sequence dispatched during process(). + // stop() sets stdinBuffer to null; the nullsafe flush() must not crash. + $terminal->simulateInput("\x1b[A"); + + $this->assertFalse($tui->isRunning()); + } + public function testGetById() { $terminal = new VirtualTerminal(40, 10); From bc99ad5d83ef4900fd69dd30fb37b797805f8370 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sat, 28 Mar 2026 11:24:31 +0100 Subject: [PATCH 12/22] [Tui] Remove unused VirtualTerminal methods and property --- .../Tui/Terminal/VirtualTerminal.php | 27 ------------------- 1 file changed, 27 deletions(-) diff --git a/src/Symfony/Component/Tui/Terminal/VirtualTerminal.php b/src/Symfony/Component/Tui/Terminal/VirtualTerminal.php index a92e1a666efd3..4edc989338489 100644 --- a/src/Symfony/Component/Tui/Terminal/VirtualTerminal.php +++ b/src/Symfony/Component/Tui/Terminal/VirtualTerminal.php @@ -31,7 +31,6 @@ final class VirtualTerminal implements TerminalInterface { private string $output = ''; - private bool $cursorVisible = true; private ?StdinBuffer $stdinBuffer = null; /** @var callable(): void|null */ @@ -95,13 +94,11 @@ public function moveBy(int $lines): void public function hideCursor(): void { - $this->cursorVisible = false; $this->write("\x1b[?25l"); } public function showCursor(): void { - $this->cursorVisible = true; $this->write("\x1b[?25h"); } @@ -195,14 +192,6 @@ public function simulateResize(int $columns, int $rows): void } } - /** - * Check if cursor is visible. - */ - public function isCursorVisible(): bool - { - return $this->cursorVisible; - } - /** * Set Kitty protocol state. */ @@ -210,20 +199,4 @@ public function setKittyProtocolActive(bool $active): void { $this->kittyProtocolActive = $active; } - - /** - * Get output split into lines (stripping ANSI codes for comparison). - * - * @return string[] - */ - public function getOutputLines(): array - { - $output = $this->output; - - // Remove synchronized output markers - $output = str_replace(["\x1b[?2026h", "\x1b[?2026l"], '', $output); - - // Split by newlines - return explode("\n", $output); - } } From 91b5fcb8f1dc1ebdb723058516782c8767e14f72 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sat, 28 Mar 2026 18:54:40 +0100 Subject: [PATCH 13/22] [Tui] Remove unused composer dependencies --- src/Symfony/Component/Tui/composer.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Symfony/Component/Tui/composer.json b/src/Symfony/Component/Tui/composer.json index a0a9157ad5523..5ece65500267d 100644 --- a/src/Symfony/Component/Tui/composer.json +++ b/src/Symfony/Component/Tui/composer.json @@ -18,15 +18,11 @@ "require": { "php": ">=8.4", "revolt/event-loop": "^1.0", - "symfony/console": "^8.0", "symfony/event-dispatcher": "^8.0", - "symfony/mime": "^8.0", - "symfony/string": "^8.0", - "twig/twig": "^3.0" + "symfony/string": "^8.0" }, "require-dev": { "league/commonmark": "^2.9", - "symfony/process": "^8.0", "tempest/highlight": "^2.16" }, "autoload": { From b871c1d463c126abbf23b291c7080a308f742b77 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sat, 28 Mar 2026 18:56:40 +0100 Subject: [PATCH 14/22] [Tui] Add class_exists checks for optional dependencies in MarkdownWidget --- src/Symfony/Component/Tui/Widget/MarkdownWidget.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Symfony/Component/Tui/Widget/MarkdownWidget.php b/src/Symfony/Component/Tui/Widget/MarkdownWidget.php index 9b872d4a64430..0f69267d320a5 100644 --- a/src/Symfony/Component/Tui/Widget/MarkdownWidget.php +++ b/src/Symfony/Component/Tui/Widget/MarkdownWidget.php @@ -38,6 +38,7 @@ use League\CommonMark\Parser\MarkdownParser; use Symfony\Component\Tui\Ansi\AnsiUtils; use Symfony\Component\Tui\Ansi\TextWrapper; +use Symfony\Component\Tui\Exception\LogicException; use Symfony\Component\Tui\Render\RenderContext; use Symfony\Component\Tui\Widget\Markdown\DarkTerminalTheme; use Symfony\Component\Tui\Widget\Util\StringUtils; @@ -72,6 +73,10 @@ public function __construct( ?MarkdownParser $parser = null, ?Highlighter $highlighter = null, ) { + if (!class_exists(MarkdownParser::class)) { + throw new LogicException(\sprintf('You cannot use "%s" as the CommonMark package is not installed. Try running "composer require league/commonmark".', __CLASS__)); + } + $this->text = StringUtils::sanitizeUtf8($text); if (null === $parser) { $environment = new Environment(); @@ -80,6 +85,10 @@ public function __construct( $parser = new MarkdownParser($environment); } $this->parser = $parser; + + if (null === $highlighter && !class_exists(Highlighter::class)) { + throw new LogicException(\sprintf('You cannot use "%s" as the Tempest Highlight package is not installed. Try running "composer require tempest/highlight".', __CLASS__)); + } $this->highlighter = $highlighter ?? new Highlighter(new DarkTerminalTheme()); } From b463c9470de8202c1515052aff85b8b0067a02df Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sun, 29 Mar 2026 10:21:17 +0200 Subject: [PATCH 15/22] [Tui] Use underscore keys for all internal arrays --- .../Component/Tui/Ansi/TextWrapper.php | 44 ++++----- src/Symfony/Component/Tui/Input/Key.php | 4 +- src/Symfony/Component/Tui/Input/KeyParser.php | 96 +++++++++---------- .../Component/Tui/Loop/TickScheduler.php | 10 +- .../Component/Tui/Render/ScreenWriter.php | 16 ++-- .../Tui/Style/TailwindStylesheet.php | 8 +- .../Component/Tui/Terminal/ScreenBuffer.php | 16 ++-- .../Tui/Tests/Ansi/TextWrapperTest.php | 40 ++++---- .../Tui/Tests/Render/ScreenWriterTest.php | 6 +- .../Widget/Editor/EditorRendererTest.php | 12 +-- .../Widget/Editor/EditorViewportTest.php | 18 ++-- .../Tui/Tests/Widget/Util/KillRingTest.php | 10 +- src/Symfony/Component/Tui/Tui.php | 4 +- .../Tui/Widget/Editor/EditorDocument.php | 40 ++++---- .../Tui/Widget/Editor/EditorRenderer.php | 40 ++++---- .../Tui/Widget/Editor/EditorViewport.php | 16 ++-- .../Component/Tui/Widget/EditorWidget.php | 8 +- .../Component/Tui/Widget/Util/KillRing.php | 6 +- 18 files changed, 197 insertions(+), 197 deletions(-) diff --git a/src/Symfony/Component/Tui/Ansi/TextWrapper.php b/src/Symfony/Component/Tui/Ansi/TextWrapper.php index f4c82beb158d2..03012cb6af560 100644 --- a/src/Symfony/Component/Tui/Ansi/TextWrapper.php +++ b/src/Symfony/Component/Tui/Ansi/TextWrapper.php @@ -33,27 +33,27 @@ final class TextWrapper * @param string $line A single line of text (no newlines) * @param int $width Maximum visible width per chunk * - * @return list + * @return list */ public static function wrapLineIntoChunks(string $line, int $width): array { if ('' === $line) { - return [['text' => '', 'startIndex' => 0, 'endIndex' => 0]]; + return [['text' => '', 'start_index' => 0, 'end_index' => 0]]; } if ($width <= 0) { - return [['text' => $line, 'startIndex' => 0, 'endIndex' => \strlen($line)]]; + return [['text' => $line, 'start_index' => 0, 'end_index' => \strlen($line)]]; } $lineWidth = AnsiUtils::visibleWidth($line); if ($lineWidth <= $width) { - return [['text' => $line, 'startIndex' => 0, 'endIndex' => \strlen($line)]]; + return [['text' => $line, 'start_index' => 0, 'end_index' => \strlen($line)]]; } $chunks = []; $graphemes = grapheme_str_split($line); if (false === $graphemes) { - return [['text' => $line, 'startIndex' => 0, 'endIndex' => \strlen($line)]]; + return [['text' => $line, 'start_index' => 0, 'end_index' => \strlen($line)]]; } $currentWidth = 0; @@ -79,8 +79,8 @@ public static function wrapLineIntoChunks(string $line, int $width): array // Backtrack to last wrap opportunity (word boundary). $chunks[] = [ 'text' => substr($line, $chunkStart, $wrapOppIndex - $chunkStart), - 'startIndex' => $chunkStart, - 'endIndex' => $wrapOppIndex, + 'start_index' => $chunkStart, + 'end_index' => $wrapOppIndex, ]; $chunkStart = $wrapOppIndex; $currentWidth -= $wrapOppWidth; @@ -88,8 +88,8 @@ public static function wrapLineIntoChunks(string $line, int $width): array // No word boundary available: force-break at current position. $chunks[] = [ 'text' => substr($line, $chunkStart, $byteOffset - $chunkStart), - 'startIndex' => $chunkStart, - 'endIndex' => $byteOffset, + 'start_index' => $chunkStart, + 'end_index' => $byteOffset, ]; $chunkStart = $byteOffset; $currentWidth = 0; @@ -113,8 +113,8 @@ public static function wrapLineIntoChunks(string $line, int $width): array // Push the final chunk. $chunks[] = [ 'text' => substr($line, $chunkStart), - 'startIndex' => $chunkStart, - 'endIndex' => \strlen($line), + 'start_index' => $chunkStart, + 'end_index' => \strlen($line), ]; return $chunks; @@ -196,7 +196,7 @@ private static function wrapSingleLine(string $line, int $width): array foreach ($tokens as $token) { $tokenText = $token['text']; $tokenVisibleLength = $token['width']; - $isWhitespace = $token['isWhitespace']; + $isWhitespace = $token['is_whitespace']; // Token itself is too long - break it character by character if ($tokenVisibleLength > $width && !$isWhitespace) { @@ -218,7 +218,7 @@ private static function wrapSingleLine(string $line, int $width): array $wrapped[] = $brokenLines[$i]; } $currentLine = $brokenLines[$lastIndex] ?? ''; - $currentVisibleLength = $broken['lastWidth']; + $currentVisibleLength = $broken['last_width']; continue; } @@ -248,7 +248,7 @@ private static function wrapSingleLine(string $line, int $width): array $currentVisibleLength += $tokenVisibleLength; } - if ($token['hasAnsi']) { + if ($token['has_ansi']) { $tracker->processText($tokenText); } } @@ -264,7 +264,7 @@ private static function wrapSingleLine(string $line, int $width): array /** * Split text into tokens (words and whitespace runs) while keeping ANSI codes attached. * - * @return array + * @return array */ private static function splitIntoTokensWithAnsi(string $text): array { @@ -308,8 +308,8 @@ private static function splitIntoTokensWithAnsi(string $text): array $tokens[] = [ 'text' => $current, 'width' => $needsUnicodeWidth ? AnsiUtils::visibleWidth($current) : $currentWidth, - 'isWhitespace' => $inWhitespace, - 'hasAnsi' => $currentHasAnsi, + 'is_whitespace' => $inWhitespace, + 'has_ansi' => $currentHasAnsi, ]; $current = ''; $currentWidth = 0; @@ -364,8 +364,8 @@ private static function splitIntoTokensWithAnsi(string $text): array $tokens[] = [ 'text' => $current, 'width' => $needsUnicodeWidth ? AnsiUtils::visibleWidth($current) : $currentWidth, - 'isWhitespace' => $inWhitespace, - 'hasAnsi' => $currentHasAnsi, + 'is_whitespace' => $inWhitespace, + 'has_ansi' => $currentHasAnsi, ]; } @@ -375,7 +375,7 @@ private static function splitIntoTokensWithAnsi(string $text): array /** * Break a long word into multiple lines. * - * @return array{lines: string[], lastWidth: int} + * @return array{lines: string[], last_width: int} */ private static function breakLongWord(string $word, int $width, AnsiCodeTracker $tracker): array { @@ -453,9 +453,9 @@ private static function breakLongWord(string $word, int $width, AnsiCodeTracker } if ([] === $lines) { - return ['lines' => [''], 'lastWidth' => 0]; + return ['lines' => [''], 'last_width' => 0]; } - return ['lines' => $lines, 'lastWidth' => $currentWidth]; + return ['lines' => $lines, 'last_width' => $currentWidth]; } } diff --git a/src/Symfony/Component/Tui/Input/Key.php b/src/Symfony/Component/Tui/Input/Key.php index 0c0d6ad36c14e..873fe69c07023 100644 --- a/src/Symfony/Component/Tui/Input/Key.php +++ b/src/Symfony/Component/Tui/Input/Key.php @@ -32,8 +32,8 @@ final class Key public const INSERT = 'insert'; public const HOME = 'home'; public const END = 'end'; - public const PAGE_UP = 'pageUp'; - public const PAGE_DOWN = 'pageDown'; + public const PAGE_UP = 'page_up'; + public const PAGE_DOWN = 'page_down'; // Arrow keys public const UP = 'up'; diff --git a/src/Symfony/Component/Tui/Input/KeyParser.php b/src/Symfony/Component/Tui/Input/KeyParser.php index cf64e28a3b484..64fa68047260f 100644 --- a/src/Symfony/Component/Tui/Input/KeyParser.php +++ b/src/Symfony/Component/Tui/Input/KeyParser.php @@ -39,7 +39,7 @@ final class KeyParser 'enter' => 13, 'space' => 32, 'backspace' => 127, - 'kpEnter' => 57414, + 'kp_enter' => 57414, ]; private const ARROW_CODEPOINTS = [ @@ -52,8 +52,8 @@ final class KeyParser private const FUNCTIONAL_CODEPOINTS = [ 'delete' => -10, 'insert' => -11, - 'pageUp' => -12, - 'pageDown' => -13, + 'page_up' => -12, + 'page_down' => -13, 'home' => -14, 'end' => -15, ]; @@ -67,8 +67,8 @@ final class KeyParser 'end' => ["\x1b[F", "\x1bOF", "\x1b[4~", "\x1b[8~"], 'insert' => ["\x1b[2~"], 'delete' => ["\x1b[3~"], - 'pageUp' => ["\x1b[5~", "\x1b[[5~"], - 'pageDown' => ["\x1b[6~", "\x1b[[6~"], + 'page_up' => ["\x1b[5~", "\x1b[[5~"], + 'page_down' => ["\x1b[6~", "\x1b[[6~"], 'clear' => ["\x1b[E", "\x1bOE"], 'f1' => ["\x1bOP", "\x1b[11~", "\x1b[[A"], 'f2' => ["\x1bOQ", "\x1b[12~", "\x1b[[B"], @@ -114,8 +114,8 @@ final class KeyParser 'clear' => ["\x1b[e"], 'insert' => ["\x1b[2$"], 'delete' => ["\x1b[3$"], - 'pageUp' => ["\x1b[5$"], - 'pageDown' => ["\x1b[6$"], + 'page_up' => ["\x1b[5$"], + 'page_down' => ["\x1b[6$"], 'home' => ["\x1b[7$"], 'end' => ["\x1b[8$"], ]; @@ -128,8 +128,8 @@ final class KeyParser 'clear' => ["\x1bOe"], 'insert' => ["\x1b[2^"], 'delete' => ["\x1b[3^"], - 'pageUp' => ["\x1b[5^"], - 'pageDown' => ["\x1b[6^"], + 'page_up' => ["\x1b[5^"], + 'page_down' => ["\x1b[6^"], 'home' => ["\x1b[7^"], 'end' => ["\x1b[8^"], ]; @@ -150,8 +150,8 @@ final class KeyParser "\x1b[2^" => 'ctrl+insert', "\x1b[3$" => 'shift+delete', "\x1b[3^" => 'ctrl+delete', - "\x1b[[5~" => 'pageUp', - "\x1b[[6~" => 'pageDown', + "\x1b[[5~" => 'page_up', + "\x1b[[6~" => 'page_down', "\x1b[a" => 'shift+up', "\x1b[b" => 'shift+down', "\x1b[c" => 'shift+right', @@ -160,12 +160,12 @@ final class KeyParser "\x1bOb" => 'ctrl+down', "\x1bOc" => 'ctrl+right', "\x1bOd" => 'ctrl+left', - "\x1b[5$" => 'shift+pageUp', - "\x1b[6$" => 'shift+pageDown', + "\x1b[5$" => 'shift+page_up', + "\x1b[6$" => 'shift+page_down', "\x1b[7$" => 'shift+home', "\x1b[8$" => 'shift+end', - "\x1b[5^" => 'ctrl+pageUp', - "\x1b[6^" => 'ctrl+pageDown', + "\x1b[5^" => 'ctrl+page_up', + "\x1b[6^" => 'ctrl+page_down', "\x1b[7^" => 'ctrl+home', "\x1b[8^" => 'ctrl+end', "\x1bOP" => 'f1', @@ -216,7 +216,7 @@ public function isKittyProtocolActive(): bool /** * Parse raw input and return the key identifier. * - * @return array{key: string, modifiers: array, eventType: int}|null + * @return array{key: string, modifiers: array, event_type: int}|null */ public function parse(string $data): ?array { @@ -238,7 +238,7 @@ public function parse(string $data): ?array return [ 'key' => $key, 'modifiers' => $modifiers, - 'eventType' => $parsed['eventType'], + 'event_type' => $parsed['event_type'], ]; } @@ -275,7 +275,7 @@ public function isKeyRepeat(string $data): bool /** * Parse input data into a key identifier and event type. * - * @return array{key: string, eventType: int}|null + * @return array{key: string, event_type: int}|null */ private function parseKey(string $data): ?array { @@ -294,22 +294,22 @@ private function parseKey(string $data): ?array $mods = $this->modsFromFlags($kitty['modifier']); $key = [] !== $mods ? implode('+', $mods).'+'.$keyName : $keyName; - return ['key' => $key, 'eventType' => $kitty['eventType']]; + return ['key' => $key, 'event_type' => $kitty['event_type']]; } } } if ($this->kittyProtocolActive) { if ("\x1b\r" === $data || "\n" === $data) { - return ['key' => 'shift+enter', 'eventType' => self::EVENT_PRESS]; + return ['key' => 'shift+enter', 'event_type' => self::EVENT_PRESS]; } } if (isset(self::LEGACY_SEQUENCE_KEY_IDS[$data])) { - return ['key' => self::LEGACY_SEQUENCE_KEY_IDS[$data], 'eventType' => self::EVENT_PRESS]; + return ['key' => self::LEGACY_SEQUENCE_KEY_IDS[$data], 'event_type' => self::EVENT_PRESS]; } - $press = static fn (string $key): array => ['key' => $key, 'eventType' => self::EVENT_PRESS]; + $press = static fn (string $key): array => ['key' => $key, 'event_type' => self::EVENT_PRESS]; $matched = match ($data) { "\x1b" => $press('escape'), @@ -334,8 +334,8 @@ private function parseKey(string $data): ?array "\x1b[H" => $press('home'), "\x1b[F" => $press('end'), "\x1b[3~" => $press('delete'), - "\x1b[5~" => $press('pageUp'), - "\x1b[6~" => $press('pageDown'), + "\x1b[5~" => $press('page_up'), + "\x1b[6~" => $press('page_down'), default => null, }; if (null !== $matched) { @@ -386,7 +386,7 @@ private function parseKey(string $data): ?array } /** - * @return array{codepoint: int, modifier: int, eventType: int}|null + * @return array{codepoint: int, modifier: int, event_type: int}|null */ private function parseKittySequence(string $data): ?array { @@ -403,7 +403,7 @@ private function parseKittySequence(string $data): ?array return [ 'codepoint' => $codepoint, 'modifier' => $modifierValue - 1, - 'eventType' => $eventType, + 'event_type' => $eventType, ]; } @@ -420,7 +420,7 @@ private function parseKittySequence(string $data): ?array return [ 'codepoint' => $arrowCodes[$match[3]], 'modifier' => $modifierValue - 1, - 'eventType' => $eventType, + 'event_type' => $eventType, ]; } @@ -431,8 +431,8 @@ private function parseKittySequence(string $data): ?array $funcCodes = [ 2 => self::FUNCTIONAL_CODEPOINTS['insert'], 3 => self::FUNCTIONAL_CODEPOINTS['delete'], - 5 => self::FUNCTIONAL_CODEPOINTS['pageUp'], - 6 => self::FUNCTIONAL_CODEPOINTS['pageDown'], + 5 => self::FUNCTIONAL_CODEPOINTS['page_up'], + 6 => self::FUNCTIONAL_CODEPOINTS['page_down'], 7 => self::FUNCTIONAL_CODEPOINTS['home'], 8 => self::FUNCTIONAL_CODEPOINTS['end'], ]; @@ -441,7 +441,7 @@ private function parseKittySequence(string $data): ?array return [ 'codepoint' => $funcCodes[$keyNum], 'modifier' => $modifierValue - 1, - 'eventType' => $eventType, + 'event_type' => $eventType, ]; } } @@ -456,7 +456,7 @@ private function parseKittySequence(string $data): ?array return [ 'codepoint' => $codepoint, 'modifier' => $modifierValue - 1, - 'eventType' => $eventType, + 'event_type' => $eventType, ]; } @@ -481,15 +481,15 @@ private function keyNameFromCodepoint(int $codepoint): ?string return match ($codepoint) { self::CODEPOINTS['escape'] => 'escape', self::CODEPOINTS['tab'] => 'tab', - self::CODEPOINTS['enter'], self::CODEPOINTS['kpEnter'] => 'enter', + self::CODEPOINTS['enter'], self::CODEPOINTS['kp_enter'] => 'enter', self::CODEPOINTS['space'] => 'space', self::CODEPOINTS['backspace'] => 'backspace', self::FUNCTIONAL_CODEPOINTS['delete'] => 'delete', self::FUNCTIONAL_CODEPOINTS['insert'] => 'insert', self::FUNCTIONAL_CODEPOINTS['home'] => 'home', self::FUNCTIONAL_CODEPOINTS['end'] => 'end', - self::FUNCTIONAL_CODEPOINTS['pageUp'] => 'pageUp', - self::FUNCTIONAL_CODEPOINTS['pageDown'] => 'pageDown', + self::FUNCTIONAL_CODEPOINTS['page_up'] => 'page_up', + self::FUNCTIONAL_CODEPOINTS['page_down'] => 'page_down', self::ARROW_CODEPOINTS['up'] => 'up', self::ARROW_CODEPOINTS['down'] => 'down', self::ARROW_CODEPOINTS['left'] => 'left', @@ -595,7 +595,7 @@ private function matchesKey(string $data, string $keyId): bool if ($shift && !$ctrl && !$alt) { if ( $this->matchesKittySequence($data, self::CODEPOINTS['enter'], self::MOD_SHIFT) - || $this->matchesKittySequence($data, self::CODEPOINTS['kpEnter'], self::MOD_SHIFT) + || $this->matchesKittySequence($data, self::CODEPOINTS['kp_enter'], self::MOD_SHIFT) ) { return true; } @@ -611,7 +611,7 @@ private function matchesKey(string $data, string $keyId): bool if ($alt && !$ctrl && !$shift) { if ( $this->matchesKittySequence($data, self::CODEPOINTS['enter'], self::MOD_ALT) - || $this->matchesKittySequence($data, self::CODEPOINTS['kpEnter'], self::MOD_ALT) + || $this->matchesKittySequence($data, self::CODEPOINTS['kp_enter'], self::MOD_ALT) ) { return true; } @@ -629,11 +629,11 @@ private function matchesKey(string $data, string $keyId): bool || (!$this->kittyProtocolActive && "\n" === $data) || "\x1bOM" === $data || $this->matchesKittySequence($data, self::CODEPOINTS['enter'], 0) - || $this->matchesKittySequence($data, self::CODEPOINTS['kpEnter'], 0); + || $this->matchesKittySequence($data, self::CODEPOINTS['kp_enter'], 0); } return $this->matchesKittySequence($data, self::CODEPOINTS['enter'], $modifier) - || $this->matchesKittySequence($data, self::CODEPOINTS['kpEnter'], $modifier); + || $this->matchesKittySequence($data, self::CODEPOINTS['kp_enter'], $modifier); case 'backspace': if ($alt && !$ctrl && !$shift) { @@ -700,27 +700,27 @@ private function matchesKey(string $data, string $keyId): bool return $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['end'], $modifier); - case 'pageup': + case 'page_up': if (0 === $modifier) { - return $this->matchesLegacySequence($data, self::LEGACY_KEY_SEQUENCES['pageUp']) - || $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['pageUp'], 0); + return $this->matchesLegacySequence($data, self::LEGACY_KEY_SEQUENCES['page_up']) + || $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['page_up'], 0); } - if ($this->matchesLegacyModifierSequence($data, 'pageUp', $modifier)) { + if ($this->matchesLegacyModifierSequence($data, 'page_up', $modifier)) { return true; } - return $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['pageUp'], $modifier); + return $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['page_up'], $modifier); - case 'pagedown': + case 'page_down': if (0 === $modifier) { - return $this->matchesLegacySequence($data, self::LEGACY_KEY_SEQUENCES['pageDown']) - || $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['pageDown'], 0); + return $this->matchesLegacySequence($data, self::LEGACY_KEY_SEQUENCES['page_down']) + || $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['page_down'], 0); } - if ($this->matchesLegacyModifierSequence($data, 'pageDown', $modifier)) { + if ($this->matchesLegacyModifierSequence($data, 'page_down', $modifier)) { return true; } - return $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['pageDown'], $modifier); + return $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['page_down'], $modifier); case 'up': if ($alt && !$ctrl && !$shift) { diff --git a/src/Symfony/Component/Tui/Loop/TickScheduler.php b/src/Symfony/Component/Tui/Loop/TickScheduler.php index cfbf32c5a96f3..c427ef45935fc 100644 --- a/src/Symfony/Component/Tui/Loop/TickScheduler.php +++ b/src/Symfony/Component/Tui/Loop/TickScheduler.php @@ -30,7 +30,7 @@ final class TickScheduler * @var array */ private array $intervals = []; @@ -48,7 +48,7 @@ public function schedule(callable $callback, float $intervalSeconds): string $this->intervals[$id] = [ 'callback' => $callback, 'interval' => $intervalSeconds, - 'nextRunAt' => microtime(true) + $intervalSeconds, + 'next_run_at' => microtime(true) + $intervalSeconds, ]; return $id; @@ -78,11 +78,11 @@ public function runDue(?float $now = null): void continue; } - if ($interval['nextRunAt'] > $now) { + if ($interval['next_run_at'] > $now) { continue; } - $this->intervals[$id]['nextRunAt'] = $now + $interval['interval']; + $this->intervals[$id]['next_run_at'] = $now + $interval['interval']; ($interval['callback'])(); } } @@ -97,7 +97,7 @@ public function getNextDelay(?float $now = null): ?float $nextAt = null; foreach ($this->intervals as $interval) { - $nextAt = null === $nextAt ? $interval['nextRunAt'] : min($nextAt, $interval['nextRunAt']); + $nextAt = null === $nextAt ? $interval['next_run_at'] : min($nextAt, $interval['next_run_at']); } return max(0.001, $nextAt - $now); diff --git a/src/Symfony/Component/Tui/Render/ScreenWriter.php b/src/Symfony/Component/Tui/Render/ScreenWriter.php index 89b0f90decf4a..0ba3983f5c226 100644 --- a/src/Symfony/Component/Tui/Render/ScreenWriter.php +++ b/src/Symfony/Component/Tui/Render/ScreenWriter.php @@ -121,7 +121,7 @@ public function writeLines(array $lines): void } $rawLines = $lines; - ['lines' => $lines, 'cursorPos' => $cursorPos, 'firstChanged' => $firstChanged, 'lastChanged' => $lastChanged] = $this->prepareLines($lines); + ['lines' => $lines, 'cursor_pos' => $cursorPos, 'first_changed' => $firstChanged, 'last_changed' => $lastChanged] = $this->prepareLines($lines); $this->writeInternal($lines, $cursorPos, $firstChanged, $lastChanged); $this->previousRawLines = $rawLines; @@ -148,13 +148,13 @@ public function reset(): void /** * Get the final cursor position for cleanup when stopping. * - * @return array{lineCount: int, cursorRow: int} + * @return array{line_count: int, cursor_row: int} */ public function getState(): array { return [ - 'lineCount' => \count($this->previousLines), - 'cursorRow' => $this->hardwareCursorRow, + 'line_count' => \count($this->previousLines), + 'cursor_row' => $this->hardwareCursorRow, ]; } @@ -436,7 +436,7 @@ private function differentialRender(array $newLines, ?array $cursorPos, int $fir * * @param string[] $lines * - * @return array{lines: string[], cursorPos: array{row: int, col: int, shape: int}|null, firstChanged: int, lastChanged: int} + * @return array{lines: string[], cursor_pos: array{row: int, col: int, shape: int}|null, first_changed: int, last_changed: int} */ private function prepareLines(array $lines): array { @@ -498,9 +498,9 @@ private function prepareLines(array $lines): array return [ 'lines' => $lines, - 'cursorPos' => $cursorPos, - 'firstChanged' => $firstChanged, - 'lastChanged' => $lastChanged, + 'cursor_pos' => $cursorPos, + 'first_changed' => $firstChanged, + 'last_changed' => $lastChanged, ]; } diff --git a/src/Symfony/Component/Tui/Style/TailwindStylesheet.php b/src/Symfony/Component/Tui/Style/TailwindStylesheet.php index e9707bafc6576..4e9875e5291a0 100644 --- a/src/Symfony/Component/Tui/Style/TailwindStylesheet.php +++ b/src/Symfony/Component/Tui/Style/TailwindStylesheet.php @@ -312,7 +312,7 @@ private function parseSingleUtility(string $class): ?array default => null, }; if (null !== $textAlign) { - return ['textAlign' => $textAlign]; + return ['text_align' => $textAlign]; } // === TEXT COLOR === @@ -357,7 +357,7 @@ private function parseSingleUtility(string $class): ?array default => null, }; if (null !== $verticalAlign) { - return ['verticalAlign' => $verticalAlign]; + return ['vertical_align' => $verticalAlign]; } // === SIMPLE KEYWORDS === @@ -459,10 +459,10 @@ private function buildStyleFromSlots(array $slots): Style direction: $slots['direction'] ?? null, gap: $slots['gap'] ?? null, hidden: $slots['hidden'] ?? null, - textAlign: $slots['textAlign'] ?? null, + textAlign: $slots['text_align'] ?? null, font: $slots['font'] ?? null, align: $slots['align'] ?? null, - verticalAlign: $slots['verticalAlign'] ?? null, + verticalAlign: $slots['vertical_align'] ?? null, flex: $slots['flex'] ?? null, ); } diff --git a/src/Symfony/Component/Tui/Terminal/ScreenBuffer.php b/src/Symfony/Component/Tui/Terminal/ScreenBuffer.php index c8feea1fad510..f3cadde32fd03 100644 --- a/src/Symfony/Component/Tui/Terminal/ScreenBuffer.php +++ b/src/Symfony/Component/Tui/Terminal/ScreenBuffer.php @@ -36,7 +36,7 @@ final class ScreenBuffer * strikethrough: bool, * fg: string|null, * bg: string|null, - * underlineColor: string|null + * underline_color: string|null * } */ private const DEFAULT_STYLE_STATE = [ @@ -49,7 +49,7 @@ final class ScreenBuffer 'strikethrough' => false, 'fg' => null, 'bg' => null, - 'underlineColor' => null, + 'underline_color' => null, ]; /** @var array> */ @@ -72,7 +72,7 @@ final class ScreenBuffer * strikethrough: bool, * fg: string|null, * bg: string|null, - * underlineColor: string|null + * underline_color: string|null * } */ private array $styleState = self::DEFAULT_STYLE_STATE; @@ -435,8 +435,8 @@ private function buildStyleString(): string if (null !== $this->styleState['bg']) { $codes[] = $this->styleState['bg']; } - if (null !== $this->styleState['underlineColor']) { - $codes[] = $this->styleState['underlineColor']; + if (null !== $this->styleState['underline_color']) { + $codes[] = $this->styleState['underline_color']; } if ([] === $codes) { @@ -744,11 +744,11 @@ private function handleSgr(string $params): void if (isset($codes[$i + 1])) { if (5 === $codes[$i + 1] && isset($codes[$i + 2])) { // 256-color mode - $this->styleState['underlineColor'] = '58;5;'.$codes[$i + 2]; + $this->styleState['underline_color'] = '58;5;'.$codes[$i + 2]; $i += 2; } elseif (2 === $codes[$i + 1] && isset($codes[$i + 2], $codes[$i + 3], $codes[$i + 4])) { // True-color mode - $this->styleState['underlineColor'] = '58;2;'.$codes[$i + 2].';'.$codes[$i + 3].';'.$codes[$i + 4]; + $this->styleState['underline_color'] = '58;2;'.$codes[$i + 2].';'.$codes[$i + 3].';'.$codes[$i + 4]; $i += 4; } } @@ -756,7 +756,7 @@ private function handleSgr(string $params): void // Default underline color case 59: - $this->styleState['underlineColor'] = null; + $this->styleState['underline_color'] = null; break; } diff --git a/src/Symfony/Component/Tui/Tests/Ansi/TextWrapperTest.php b/src/Symfony/Component/Tui/Tests/Ansi/TextWrapperTest.php index 4ae45500a55f5..de3a654e79f6b 100644 --- a/src/Symfony/Component/Tui/Tests/Ansi/TextWrapperTest.php +++ b/src/Symfony/Component/Tui/Tests/Ansi/TextWrapperTest.php @@ -102,8 +102,8 @@ public function testChunksBasic(string $input, int $width, array $expected) $this->assertCount(\count($expected), $chunks); foreach ($expected as $i => $exp) { $this->assertSame($exp['text'], $chunks[$i]['text'], "Chunk $i text"); - $this->assertSame($exp['startIndex'], $chunks[$i]['startIndex'], "Chunk $i startIndex"); - $this->assertSame($exp['endIndex'], $chunks[$i]['endIndex'], "Chunk $i endIndex"); + $this->assertSame($exp['start_index'], $chunks[$i]['start_index'], "Chunk $i startIndex"); + $this->assertSame($exp['end_index'], $chunks[$i]['end_index'], "Chunk $i endIndex"); } } @@ -112,8 +112,8 @@ public function testChunksBasic(string $input, int $width, array $expected) */ public static function chunksBasicProvider(): iterable { - yield 'empty string' => ['', 20, [['text' => '', 'startIndex' => 0, 'endIndex' => 0]]]; - yield 'short line' => ['Hello', 20, [['text' => 'Hello', 'startIndex' => 0, 'endIndex' => 5]]]; + yield 'empty string' => ['', 20, [['text' => '', 'start_index' => 0, 'end_index' => 0]]]; + yield 'short line' => ['Hello', 20, [['text' => 'Hello', 'start_index' => 0, 'end_index' => 5]]]; } public function testChunksWordWrap() @@ -124,14 +124,14 @@ public function testChunksWordWrap() $this->assertCount(2, $chunks); // First chunk: "hello " (includes trailing space) at [0, 6) - $this->assertSame(0, $chunks[0]['startIndex']); - $this->assertSame(6, $chunks[0]['endIndex']); + $this->assertSame(0, $chunks[0]['start_index']); + $this->assertSame(6, $chunks[0]['end_index']); $this->assertStringContainsString('hello', $chunks[0]['text']); // Second chunk: "world" at [6, 11) $this->assertSame('world', $chunks[1]['text']); - $this->assertSame(6, $chunks[1]['startIndex']); - $this->assertSame(11, $chunks[1]['endIndex']); + $this->assertSame(6, $chunks[1]['start_index']); + $this->assertSame(11, $chunks[1]['end_index']); } public function testChunksMultipleWraps() @@ -143,10 +143,10 @@ public function testChunksMultipleWraps() // Each chunk's startIndex should match the start of its word // (the space between words is consumed by wrapping) - $this->assertSame(0, $chunks[0]['startIndex']); - $this->assertSame(3, $chunks[1]['startIndex']); - $this->assertSame(6, $chunks[2]['startIndex']); - $this->assertSame(9, $chunks[3]['startIndex']); + $this->assertSame(0, $chunks[0]['start_index']); + $this->assertSame(3, $chunks[1]['start_index']); + $this->assertSame(6, $chunks[2]['start_index']); + $this->assertSame(9, $chunks[3]['start_index']); // Verify text content $this->assertStringContainsString('aa', $chunks[0]['text']); @@ -163,12 +163,12 @@ public function testChunksLongWord() $this->assertGreaterThan(1, \count($chunks)); // Chunks should cover the entire string - $this->assertSame(0, $chunks[0]['startIndex']); - $this->assertSame(\strlen('abcdefghij'), $chunks[\count($chunks) - 1]['endIndex']); + $this->assertSame(0, $chunks[0]['start_index']); + $this->assertSame(\strlen('abcdefghij'), $chunks[\count($chunks) - 1]['end_index']); // No gaps between chunks for ($i = 1; $i < \count($chunks); ++$i) { - $this->assertSame($chunks[$i - 1]['endIndex'], $chunks[$i]['startIndex'], + $this->assertSame($chunks[$i - 1]['end_index'], $chunks[$i]['start_index'], 'Chunks should be contiguous for force-broken words'); } } @@ -181,8 +181,8 @@ public function testChunksMultipleSpaces() // "def" should be in the last chunk starting at byte 6 $lastChunk = $chunks[\count($chunks) - 1]; $this->assertSame('def', $lastChunk['text']); - $this->assertSame(6, $lastChunk['startIndex']); - $this->assertSame(9, $lastChunk['endIndex']); + $this->assertSame(6, $lastChunk['start_index']); + $this->assertSame(9, $lastChunk['end_index']); } public function testChunksAllRespectWidth() @@ -201,8 +201,8 @@ public function testChunksAllRespectWidth() } // First chunk starts at 0, last chunk ends at string length - $this->assertSame(0, $chunks[0]['startIndex']); - $this->assertSame(\strlen($text), $chunks[\count($chunks) - 1]['endIndex']); + $this->assertSame(0, $chunks[0]['start_index']); + $this->assertSame(\strlen($text), $chunks[\count($chunks) - 1]['end_index']); } public function testChunksUtf8() @@ -213,7 +213,7 @@ public function testChunksUtf8() $this->assertCount(2, $chunks); $this->assertStringContainsString('café', $chunks[0]['text']); // "café " is 6 bytes (c=1, a=1, f=1, é=2, space=1) - $this->assertSame(6, $chunks[1]['startIndex']); + $this->assertSame(6, $chunks[1]['start_index']); $this->assertSame('world', $chunks[1]['text']); } diff --git a/src/Symfony/Component/Tui/Tests/Render/ScreenWriterTest.php b/src/Symfony/Component/Tui/Tests/Render/ScreenWriterTest.php index 481c18b2e4353..537aa90fee41a 100644 --- a/src/Symfony/Component/Tui/Tests/Render/ScreenWriterTest.php +++ b/src/Symfony/Component/Tui/Tests/Render/ScreenWriterTest.php @@ -528,13 +528,13 @@ public function testResetClearsState() $writer->writeLines(['A', 'B', 'C']); $state = $writer->getState(); - $this->assertSame(3, $state['lineCount']); + $this->assertSame(3, $state['line_count']); $writer->reset(); $state = $writer->getState(); - $this->assertSame(0, $state['lineCount']); - $this->assertSame(0, $state['cursorRow']); + $this->assertSame(0, $state['line_count']); + $this->assertSame(0, $state['cursor_row']); } // --- RenderException tests (pre-existing) --- diff --git a/src/Symfony/Component/Tui/Tests/Widget/Editor/EditorRendererTest.php b/src/Symfony/Component/Tui/Tests/Widget/Editor/EditorRendererTest.php index d08b2e4971b01..c6d24e0691d28 100644 --- a/src/Symfony/Component/Tui/Tests/Widget/Editor/EditorRendererTest.php +++ b/src/Symfony/Component/Tui/Tests/Widget/Editor/EditorRendererTest.php @@ -61,7 +61,7 @@ public function testRenderScrollIndicatorAbove() { $lines = $this->renderWithViewport( ['Line 0', 'Line 1', 'Line 2'], - ['scrollOffset' => 1, 'visibleLineCount' => 2, 'linesAbove' => 1, 'linesBelow' => 0], + ['scroll_offset' => 1, 'visible_line_count' => 2, 'lines_above' => 1, 'lines_below' => 0], 1, 0, 40, 10, ); @@ -74,7 +74,7 @@ public function testRenderScrollIndicatorBelow() { $lines = $this->renderWithViewport( ['Line 0', 'Line 1', 'Line 2'], - ['scrollOffset' => 0, 'visibleLineCount' => 2, 'linesAbove' => 0, 'linesBelow' => 1], + ['scroll_offset' => 0, 'visible_line_count' => 2, 'lines_above' => 0, 'lines_below' => 1], 0, 0, 40, 10, ); @@ -134,10 +134,10 @@ public function testRenderEmojiProducesValidUtf8() private function renderSimple(array $docLines, int $cursorLine, int $cursorCol, int $columns, int $maxDisplayRows, bool $verticallyExpanded = false, bool $focused = false): array { $viewport = [ - 'scrollOffset' => 0, - 'visibleLineCount' => \count($docLines), - 'linesAbove' => 0, - 'linesBelow' => 0, + 'scroll_offset' => 0, + 'visible_line_count' => \count($docLines), + 'lines_above' => 0, + 'lines_below' => 0, ]; return $this->renderWithViewport($docLines, $viewport, $cursorLine, $cursorCol, $columns, $maxDisplayRows, $verticallyExpanded, $focused); diff --git a/src/Symfony/Component/Tui/Tests/Widget/Editor/EditorViewportTest.php b/src/Symfony/Component/Tui/Tests/Widget/Editor/EditorViewportTest.php index 31a998b2bdc3c..b6c31e5b7c6a2 100644 --- a/src/Symfony/Component/Tui/Tests/Widget/Editor/EditorViewportTest.php +++ b/src/Symfony/Component/Tui/Tests/Widget/Editor/EditorViewportTest.php @@ -27,8 +27,8 @@ public function testComputeViewportKeepsCursorVisible() // Cursor at line 20, viewport shows 10 rows $result = $viewport->computeViewport($lines, 20, 10, 80, false, 1); - $this->assertGreaterThanOrEqual($result['scrollOffset'], 20); - $this->assertLessThan($result['scrollOffset'] + $result['visibleLineCount'], 20); + $this->assertGreaterThanOrEqual($result['scroll_offset'], 20); + $this->assertLessThan($result['scroll_offset'] + $result['visible_line_count'], 20); } public function testComputeViewportScrollsUpWhenCursorAbove() @@ -45,7 +45,7 @@ public function testComputeViewportScrollsUpWhenCursorAbove() // Now cursor goes back to line 0 $result = $viewport->computeViewport($lines, 0, 10, 80, false, 1); - $this->assertSame(0, $result['scrollOffset']); + $this->assertSame(0, $result['scroll_offset']); } public function testComputeViewportExpandedMode() @@ -56,7 +56,7 @@ public function testComputeViewportExpandedMode() $result = $viewport->computeViewport($lines, 0, 20, 80, true, 1); // In expanded mode, visibleLineCount should fill available space - $this->assertSame(2, $result['visibleLineCount']); + $this->assertSame(2, $result['visible_line_count']); } public function testComputeViewportReportsLinesAboveAndBelow() @@ -69,8 +69,8 @@ public function testComputeViewportReportsLinesAboveAndBelow() $result = $viewport->computeViewport($lines, 15, 10, 80, false, 1); - $this->assertGreaterThan(0, $result['linesAbove']); - $this->assertGreaterThan(0, $result['linesBelow']); + $this->assertGreaterThan(0, $result['lines_above']); + $this->assertGreaterThan(0, $result['lines_below']); } public function testPageScrollDown() @@ -84,7 +84,7 @@ public function testPageScrollDown() $result = $viewport->pageScroll($lines, 1, 10, 0, 0); $this->assertNotNull($result); - $this->assertSame(10, $result['cursorLine']); + $this->assertSame(10, $result['cursor_line']); } public function testPageScrollUp() @@ -98,7 +98,7 @@ public function testPageScrollUp() $result = $viewport->pageScroll($lines, -1, 10, 20, 0); $this->assertNotNull($result); - $this->assertSame(10, $result['cursorLine']); + $this->assertSame(10, $result['cursor_line']); } public function testPageScrollClampsToEnd() @@ -109,7 +109,7 @@ public function testPageScrollClampsToEnd() $result = $viewport->pageScroll($lines, 1, 100, 0, 0); $this->assertNotNull($result); - $this->assertSame(2, $result['cursorLine']); + $this->assertSame(2, $result['cursor_line']); } public function testPageScrollReturnsNullWhenNoChange() diff --git a/src/Symfony/Component/Tui/Tests/Widget/Util/KillRingTest.php b/src/Symfony/Component/Tui/Tests/Widget/Util/KillRingTest.php index 2134e879c4269..02cc92c87c0be 100644 --- a/src/Symfony/Component/Tui/Tests/Widget/Util/KillRingTest.php +++ b/src/Symfony/Component/Tui/Tests/Widget/Util/KillRingTest.php @@ -76,7 +76,7 @@ public function testMaxEntries() // Rotate through: should only have b, c, d $ring->resetAction(); - $ring->recordYank(['startLine' => 0, 'startCol' => 0, 'endLine' => 0, 'endCol' => 1]); + $ring->recordYank(['start_line' => 0, 'start_col' => 0, 'end_line' => 0, 'end_col' => 1]); $this->assertTrue($ring->canYankPop()); $text = $ring->rotate(); $this->assertSame('c', $text); @@ -96,7 +96,7 @@ public function testCanYankPopRequiresMultipleEntries() { $ring = new KillRing(); $ring->add('only', false); - $ring->recordYank(['startLine' => 0, 'startCol' => 0, 'endLine' => 0, 'endCol' => 4]); + $ring->recordYank(['start_line' => 0, 'start_col' => 0, 'end_line' => 0, 'end_col' => 4]); $this->assertFalse($ring->canYankPop()); } @@ -124,7 +124,7 @@ public function testYankPopCycle() // Yank gets 'third' $this->assertSame('third', $ring->peek()); - $ring->recordYank(['startLine' => 0, 'startCol' => 0, 'endLine' => 0, 'endCol' => 5]); + $ring->recordYank(['start_line' => 0, 'start_col' => 0, 'end_line' => 0, 'end_col' => 5]); // Yank-pop rotates: third goes to front, 'second' is now on top $this->assertTrue($ring->canYankPop()); @@ -157,7 +157,7 @@ public function testResetAll() $ring->add('a', false); $ring->resetAction(); $ring->add('b', false); - $ring->recordYank(['startLine' => 0, 'startCol' => 0, 'endLine' => 0, 'endCol' => 1]); + $ring->recordYank(['start_line' => 0, 'start_col' => 0, 'end_line' => 0, 'end_col' => 1]); $ring->resetAll(); @@ -170,7 +170,7 @@ public function testGetLastYankRange() $ring = new KillRing(); $this->assertNull($ring->getLastYankRange()); - $range = ['startLine' => 1, 'startCol' => 5, 'endLine' => 2, 'endCol' => 3]; + $range = ['start_line' => 1, 'start_col' => 5, 'end_line' => 2, 'end_col' => 3]; $ring->recordYank($range); $this->assertSame($range, $ring->getLastYankRange()); diff --git a/src/Symfony/Component/Tui/Tui.php b/src/Symfony/Component/Tui/Tui.php index b4a1f9b46c52d..d137f6d8d730e 100644 --- a/src/Symfony/Component/Tui/Tui.php +++ b/src/Symfony/Component/Tui/Tui.php @@ -270,8 +270,8 @@ public function stop(): void // Move cursor to end of content $state = $this->screenWriter->getState(); - if ($state['lineCount'] > 0) { - $lineDiff = $state['lineCount'] - $state['cursorRow']; + if ($state['line_count'] > 0) { + $lineDiff = $state['line_count'] - $state['cursor_row']; if ($lineDiff > 0) { $this->terminal->write("\x1b[{$lineDiff}B"); diff --git a/src/Symfony/Component/Tui/Widget/Editor/EditorDocument.php b/src/Symfony/Component/Tui/Widget/Editor/EditorDocument.php index c108268faf38d..f6d90d6fd035e 100644 --- a/src/Symfony/Component/Tui/Widget/Editor/EditorDocument.php +++ b/src/Symfony/Component/Tui/Widget/Editor/EditorDocument.php @@ -37,9 +37,9 @@ final class EditorDocument private KillRing $killRing; // Undo/Redo - /** @var array */ + /** @var array */ private array $undoStack = []; - /** @var array */ + /** @var array */ private array $redoStack = []; // Character jump mode @@ -548,10 +548,10 @@ public function yank(): bool $this->insertTextAtCursor($text); $this->killRing->recordYank([ - 'startLine' => $startLine, - 'startCol' => $startCol, - 'endLine' => $this->cursorLine, - 'endCol' => $this->cursorCol, + 'start_line' => $startLine, + 'start_col' => $startCol, + 'end_line' => $this->cursorLine, + 'end_col' => $this->cursorCol, ]); return true; @@ -577,10 +577,10 @@ public function yankPop(): bool $this->insertTextAtCursor($text); $this->killRing->recordYank([ - 'startLine' => $startLine, - 'startCol' => $startCol, - 'endLine' => $this->cursorLine, - 'endCol' => $this->cursorCol, + 'start_line' => $startLine, + 'start_col' => $startCol, + 'end_line' => $this->cursorLine, + 'end_col' => $this->cursorCol, ]); return true; @@ -680,25 +680,25 @@ private function pushUndoSnapshot(): void } /** - * @return array{lines: string[], cursorLine: int, cursorCol: int} + * @return array{lines: string[], cursor_line: int, cursor_col: int} */ private function createSnapshot(): array { return [ 'lines' => $this->lines, - 'cursorLine' => $this->cursorLine, - 'cursorCol' => $this->cursorCol, + 'cursor_line' => $this->cursorLine, + 'cursor_col' => $this->cursorCol, ]; } /** - * @param array{lines: string[], cursorLine: int, cursorCol: int} $snapshot + * @param array{lines: string[], cursor_line: int, cursor_col: int} $snapshot */ private function restoreSnapshot(array $snapshot): void { $this->lines = $snapshot['lines']; - $this->cursorLine = $snapshot['cursorLine']; - $this->cursorCol = $snapshot['cursorCol']; + $this->cursorLine = $snapshot['cursor_line']; + $this->cursorCol = $snapshot['cursor_col']; } private function deleteYankedText(): void @@ -708,10 +708,10 @@ private function deleteYankedText(): void return; } - $startLine = $range['startLine']; - $startCol = $range['startCol']; - $endLine = $range['endLine']; - $endCol = $range['endCol']; + $startLine = $range['start_line']; + $startCol = $range['start_col']; + $endLine = $range['end_line']; + $endCol = $range['end_col']; if ($startLine === $endLine) { $line = $this->lines[$startLine]; diff --git a/src/Symfony/Component/Tui/Widget/Editor/EditorRenderer.php b/src/Symfony/Component/Tui/Widget/Editor/EditorRenderer.php index 90397d76e1c4a..66a01024dff41 100644 --- a/src/Symfony/Component/Tui/Widget/Editor/EditorRenderer.php +++ b/src/Symfony/Component/Tui/Widget/Editor/EditorRenderer.php @@ -33,16 +33,16 @@ final class EditorRenderer /** * Render the full editor output: borders + content lines + padding. * - * @param string[] $lines Document lines - * @param array{scrollOffset: int, visibleLineCount: int, linesAbove: int, linesBelow: int} $viewport Viewport parameters - * @param int $cursorLine Current cursor line - * @param int $cursorCol Current cursor column - * @param int $columns Terminal columns - * @param int $maxDisplayRows Maximum display rows - * @param bool $verticallyExpanded Whether to fill all rows - * @param bool $focused Whether the editor has focus - * @param CursorShape $cursorShape Cursor shape - * @param Style $frameStyle Style for borders + * @param string[] $lines Document lines + * @param array{scroll_offset: int, visible_line_count: int, lines_above: int, lines_below: int} $viewport Viewport parameters + * @param int $cursorLine Current cursor line + * @param int $cursorCol Current cursor column + * @param int $columns Terminal columns + * @param int $maxDisplayRows Maximum display rows + * @param bool $verticallyExpanded Whether to fill all rows + * @param bool $focused Whether the editor has focus + * @param CursorShape $cursorShape Cursor shape + * @param Style $frameStyle Style for borders * * @return string[] */ @@ -61,8 +61,8 @@ public function render( $result = []; // Top border (with scroll indicator if scrolled down) - if ($viewport['linesAbove'] > 0) { - $indicator = "─── ↑ {$viewport['linesAbove']} more "; + if ($viewport['lines_above'] > 0) { + $indicator = "─── ↑ {$viewport['lines_above']} more "; $remaining = $columns - AnsiUtils::visibleWidth($indicator); $result[] = $frameStyle->apply($indicator.str_repeat('─', max(0, $remaining))); } else { @@ -71,8 +71,8 @@ public function render( // Render visible lines $displayRowsRendered = 0; - for ($i = 0; $i < $viewport['visibleLineCount']; ++$i) { - $lineIndex = $viewport['scrollOffset'] + $i; + for ($i = 0; $i < $viewport['visible_line_count']; ++$i) { + $lineIndex = $viewport['scroll_offset'] + $i; $line = $lines[$lineIndex] ?? ''; $isCursorLine = $lineIndex === $cursorLine; @@ -92,8 +92,8 @@ public function render( } // Bottom border (with scroll indicator if more content below) - if ($viewport['linesBelow'] > 0) { - $indicator = "─── ↓ {$viewport['linesBelow']} more "; + if ($viewport['lines_below'] > 0) { + $indicator = "─── ↓ {$viewport['lines_below']} more "; $remaining = $columns - AnsiUtils::visibleWidth($indicator); $result[] = $frameStyle->apply($indicator.str_repeat('─', max(0, $remaining))); } else { @@ -126,13 +126,13 @@ private function renderLine(string $line, bool $isCursorLine, int $cursorCol, in if ($isCursorLine) { if ($isLastChunk) { - if ($cursorCol >= $chunk['startIndex']) { + if ($cursorCol >= $chunk['start_index']) { $hasCursor = true; - $cursorPosInChunk = $cursorCol - $chunk['startIndex']; + $cursorPosInChunk = $cursorCol - $chunk['start_index']; } - } elseif ($cursorCol >= $chunk['startIndex'] && $cursorCol < $chunk['endIndex']) { + } elseif ($cursorCol >= $chunk['start_index'] && $cursorCol < $chunk['end_index']) { $hasCursor = true; - $cursorPosInChunk = $cursorCol - $chunk['startIndex']; + $cursorPosInChunk = $cursorCol - $chunk['start_index']; } } diff --git a/src/Symfony/Component/Tui/Widget/Editor/EditorViewport.php b/src/Symfony/Component/Tui/Widget/Editor/EditorViewport.php index 8a8ecfd28343b..b7a3a16f2b7ec 100644 --- a/src/Symfony/Component/Tui/Widget/Editor/EditorViewport.php +++ b/src/Symfony/Component/Tui/Widget/Editor/EditorViewport.php @@ -49,7 +49,7 @@ public function reset(): void * @param int $cursorLine Current cursor line * @param int $cursorCol Current cursor column * - * @return array{cursorLine: int, cursorCol: int}|null New cursor state, or null if unchanged + * @return array{cursor_line: int, cursor_col: int}|null New cursor state, or null if unchanged */ public function pageScroll(array $lines, int $direction, int $pageSize, int $cursorLine, int $cursorCol): ?array { @@ -57,8 +57,8 @@ public function pageScroll(array $lines, int $direction, int $pageSize, int $cur if ($targetLine !== $cursorLine) { return [ - 'cursorLine' => $targetLine, - 'cursorCol' => min($cursorCol, \strlen($lines[$targetLine])), + 'cursor_line' => $targetLine, + 'cursor_col' => min($cursorCol, \strlen($lines[$targetLine])), ]; } @@ -75,7 +75,7 @@ public function pageScroll(array $lines, int $direction, int $pageSize, int $cur * @param bool $verticallyExpanded Whether to fill all available rows * @param int $minVisibleLines Minimum visible lines * - * @return array{scrollOffset: int, visibleLineCount: int, linesAbove: int, linesBelow: int} + * @return array{scroll_offset: int, visible_line_count: int, lines_above: int, lines_below: int} */ public function computeViewport(array $lines, int $cursorLine, int $maxDisplayRows, int $columns, bool $verticallyExpanded, int $minVisibleLines): array { @@ -105,10 +105,10 @@ public function computeViewport(array $lines, int $cursorLine, int $maxDisplayRo } return [ - 'scrollOffset' => $this->scrollOffset, - 'visibleLineCount' => $visibleLineCount, - 'linesAbove' => $this->scrollOffset, - 'linesBelow' => max(0, $totalLines - $this->scrollOffset - $visibleLineCount), + 'scroll_offset' => $this->scrollOffset, + 'visible_line_count' => $visibleLineCount, + 'lines_above' => $this->scrollOffset, + 'lines_below' => max(0, $totalLines - $this->scrollOffset - $visibleLineCount), ]; } diff --git a/src/Symfony/Component/Tui/Widget/EditorWidget.php b/src/Symfony/Component/Tui/Widget/EditorWidget.php index ca9c8c561ca44..f99b27dfd24e5 100644 --- a/src/Symfony/Component/Tui/Widget/EditorWidget.php +++ b/src/Symfony/Component/Tui/Widget/EditorWidget.php @@ -362,8 +362,8 @@ public function handleInput(string $data): void if ($kb->matches($data, 'page_up')) { $result = $this->viewport->pageScroll($this->document->getLines(), -1, $this->getPageSize(), $this->document->getCursorLine(), $this->document->getCursorCol()); if (null !== $result) { - $this->document->setCursorLine($result['cursorLine']); - $this->document->setCursorCol($result['cursorCol']); + $this->document->setCursorLine($result['cursor_line']); + $this->document->setCursorCol($result['cursor_col']); $this->invalidate(); } @@ -373,8 +373,8 @@ public function handleInput(string $data): void if ($kb->matches($data, 'page_down')) { $result = $this->viewport->pageScroll($this->document->getLines(), 1, $this->getPageSize(), $this->document->getCursorLine(), $this->document->getCursorCol()); if (null !== $result) { - $this->document->setCursorLine($result['cursorLine']); - $this->document->setCursorCol($result['cursorCol']); + $this->document->setCursorLine($result['cursor_line']); + $this->document->setCursorCol($result['cursor_col']); $this->invalidate(); } diff --git a/src/Symfony/Component/Tui/Widget/Util/KillRing.php b/src/Symfony/Component/Tui/Widget/Util/KillRing.php index dded6ee6cf10a..6e20e6f690c8c 100644 --- a/src/Symfony/Component/Tui/Widget/Util/KillRing.php +++ b/src/Symfony/Component/Tui/Widget/Util/KillRing.php @@ -30,7 +30,7 @@ class KillRing private ?string $lastAction = null; /** - * @var array{startLine: int, startCol: int, endLine: int, endCol: int}|null + * @var array{start_line: int, start_col: int, end_line: int, end_col: int}|null */ private ?array $lastYank = null; @@ -106,7 +106,7 @@ public function rotate(): ?string /** * Record that a yank happened (for yank-pop tracking). * - * @param array{startLine: int, startCol: int, endLine: int, endCol: int} $range + * @param array{start_line: int, start_col: int, end_line: int, end_col: int} $range */ public function recordYank(array $range): void { @@ -117,7 +117,7 @@ public function recordYank(array $range): void /** * Get the range of the last yank (for deletion before yank-pop). * - * @return array{startLine: int, startCol: int, endLine: int, endCol: int}|null + * @return array{start_line: int, start_col: int, end_line: int, end_col: int}|null */ public function getLastYankRange(): ?array { From 01418765dcd8cc09e1c1ab42b17b084fb880a936 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sun, 29 Mar 2026 11:50:35 +0200 Subject: [PATCH 16/22] [Tui] Use match expression in StdinBuffer::getUtf8CharLength() --- .../Component/Tui/Input/StdinBuffer.php | 24 ++++++------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/src/Symfony/Component/Tui/Input/StdinBuffer.php b/src/Symfony/Component/Tui/Input/StdinBuffer.php index 9a076eb82e4fa..496c814277032 100644 --- a/src/Symfony/Component/Tui/Input/StdinBuffer.php +++ b/src/Symfony/Component/Tui/Input/StdinBuffer.php @@ -369,22 +369,12 @@ private function extractApcSequence(): ?string */ private function getUtf8CharLength(int $ord): int { - if ($ord < 0x80) { - return 1; - } - if ($ord < 0xC0) { - return 1; - } // Invalid, treat as single byte - if ($ord < 0xE0) { - return 2; - } - if ($ord < 0xF0) { - return 3; - } - if ($ord < 0xF8) { - return 4; - } - - return 1; // Invalid, treat as single byte + return match (true) { + $ord < 0xC0 => 1, // ASCII or invalid continuation byte + $ord < 0xE0 => 2, + $ord < 0xF0 => 3, + $ord < 0xF8 => 4, + default => 1, // Invalid, treat as single byte + }; } } From 2b294d5a2e92c948a6ab02d7860c83f2597d179e Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sun, 29 Mar 2026 11:51:17 +0200 Subject: [PATCH 17/22] [Tui] Remove unnecessary extractApcSequence() method --- src/Symfony/Component/Tui/Input/StdinBuffer.php | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/Symfony/Component/Tui/Input/StdinBuffer.php b/src/Symfony/Component/Tui/Input/StdinBuffer.php index 496c814277032..a40d36bdd10c4 100644 --- a/src/Symfony/Component/Tui/Input/StdinBuffer.php +++ b/src/Symfony/Component/Tui/Input/StdinBuffer.php @@ -223,7 +223,7 @@ private function extractSequence(): ?string 'O' => $this->extractSs3Sequence(), ']' => $this->extractOscSequence(), 'P' => $this->extractDcsSequence(), - '_' => $this->extractApcSequence(), + '_' => $this->extractOscSequence(), // APC uses the same terminator rules default => $this->extractAltKey($second), }; } @@ -356,14 +356,6 @@ private function extractDcsSequence(): ?string return null; } - /** - * Extract APC sequence (ESC _ ... BEL or ESC _ ... ST). - */ - private function extractApcSequence(): ?string - { - return $this->extractOscSequence(); // Same terminator rules - } - /** * Get the expected length of a UTF-8 character from its first byte. */ From 6a1582997759c71fe8ed47aecee46e39e7a3ad3f Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sun, 29 Mar 2026 11:52:48 +0200 Subject: [PATCH 18/22] [Tui] Remove empty constructor from StdinBuffer --- src/Symfony/Component/Tui/Input/StdinBuffer.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Symfony/Component/Tui/Input/StdinBuffer.php b/src/Symfony/Component/Tui/Input/StdinBuffer.php index a40d36bdd10c4..4bc60332a1762 100644 --- a/src/Symfony/Component/Tui/Input/StdinBuffer.php +++ b/src/Symfony/Component/Tui/Input/StdinBuffer.php @@ -36,10 +36,6 @@ final class StdinBuffer private bool $inPaste = false; private string $pasteBuffer = ''; - public function __construct() - { - } - /** * Set callback for individual key sequences. * From 0863f1bf685364321a1f7529c65d53c488504d63 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sun, 29 Mar 2026 11:54:23 +0200 Subject: [PATCH 19/22] [Tui] Remove empty constructor from ContainerWidget --- src/Symfony/Component/Tui/Widget/ContainerWidget.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Symfony/Component/Tui/Widget/ContainerWidget.php b/src/Symfony/Component/Tui/Widget/ContainerWidget.php index 2da3d4328ee57..ea9055fbf7ce0 100644 --- a/src/Symfony/Component/Tui/Widget/ContainerWidget.php +++ b/src/Symfony/Component/Tui/Widget/ContainerWidget.php @@ -44,10 +44,6 @@ class ContainerWidget extends AbstractWidget implements ContainerInterface, Vert private array $children = []; private bool $verticallyExpanded = false; - public function __construct() - { - } - /** * @return $this */ From 9633e69474585b3a98b04c920cbc4665f1216cba Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sun, 29 Mar 2026 12:53:25 +0200 Subject: [PATCH 20/22] [Tui] Use component exception classes instead of global ones --- src/Symfony/Component/Tui/Widget/LoaderWidget.php | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/Symfony/Component/Tui/Widget/LoaderWidget.php b/src/Symfony/Component/Tui/Widget/LoaderWidget.php index e37fb2b874168..213d7733269c6 100644 --- a/src/Symfony/Component/Tui/Widget/LoaderWidget.php +++ b/src/Symfony/Component/Tui/Widget/LoaderWidget.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Tui\Widget; use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Exception\InvalidArgumentException; use Symfony\Component\Tui\Loop\PeriodicStepper; use Symfony\Component\Tui\Render\RenderContext; @@ -104,7 +105,7 @@ public function isRunning(): bool /** * @return $this */ - public function setMessage(string $message): self + public function setMessage(string $message): static { if ($this->message !== $message) { $this->message = $message; @@ -123,7 +124,7 @@ public static function addSpinner(string $name, array $frames): void $frames = array_values($frames); if (\count($frames) < 2) { - throw new \InvalidArgumentException('Must have at least 2 indicator frame characters.'); + throw new InvalidArgumentException('Must have at least 2 indicator frame characters.'); } self::$styles[$name] = $frames; @@ -132,10 +133,10 @@ public static function addSpinner(string $name, array $frames): void /** * @return $this */ - public function setSpinner(string $name): self + public function setSpinner(string $name): static { if (!isset(self::$styles[$name])) { - throw new \InvalidArgumentException(\sprintf('Unknown loader style "%s". Available styles: "%s".', $name, implode('", "', array_keys(self::$styles)))); + throw new InvalidArgumentException(\sprintf('Unknown loader style "%s". Available styles: "%s".', $name, implode('", "', array_keys(self::$styles)))); } $this->frames = self::$styles[$name]; @@ -148,7 +149,7 @@ public function setSpinner(string $name): self /** * @return $this */ - public function setIntervalMs(int $intervalMs): self + public function setIntervalMs(int $intervalMs): static { $this->frameStepper->setIntervalMs($intervalMs); @@ -162,7 +163,7 @@ public function setIntervalMs(int $intervalMs): self /** * @return $this */ - public function setFinishedIndicator(string $finishedIndicator): self + public function setFinishedIndicator(string $finishedIndicator): static { $this->finishedIndicator = $finishedIndicator; $this->invalidate(); From a5a770c178f9d4b470a600709194ca32fdb3dbc3 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sun, 29 Mar 2026 12:53:33 +0200 Subject: [PATCH 21/22] [Tui] Use static return type instead of self for fluent interfaces --- src/Symfony/Component/Tui/Focus/FocusManager.php | 8 ++++---- src/Symfony/Component/Tui/Style/StyleSheet.php | 8 ++++---- src/Symfony/Component/Tui/Tui.php | 14 +++++++------- .../Component/Tui/Widget/AbstractWidget.php | 6 +++--- .../Tui/Widget/CancellableLoaderWidget.php | 2 +- .../Component/Tui/Widget/ContainerWidget.php | 2 +- .../Component/Tui/Widget/EditorWidget.php | 16 ++++++++-------- .../Component/Tui/Widget/FocusableInterface.php | 2 +- .../Component/Tui/Widget/FocusableTrait.php | 2 +- src/Symfony/Component/Tui/Widget/InputWidget.php | 12 ++++++------ .../Component/Tui/Widget/MarkdownWidget.php | 2 +- .../Component/Tui/Widget/ProgressBarWidget.php | 14 +++++++------- .../Component/Tui/Widget/SelectListWidget.php | 12 ++++++------ .../Component/Tui/Widget/SettingsListWidget.php | 4 ++-- src/Symfony/Component/Tui/Widget/TextWidget.php | 2 +- .../Tui/Widget/VerticallyExpandableInterface.php | 2 +- 16 files changed, 54 insertions(+), 54 deletions(-) diff --git a/src/Symfony/Component/Tui/Focus/FocusManager.php b/src/Symfony/Component/Tui/Focus/FocusManager.php index ade900c821338..0ee9e2ba96dbd 100644 --- a/src/Symfony/Component/Tui/Focus/FocusManager.php +++ b/src/Symfony/Component/Tui/Focus/FocusManager.php @@ -91,7 +91,7 @@ public function getFocus(): ?AbstractWidget /** * @return $this */ - public function add(FocusableInterface&AbstractWidget $widget): self + public function add(FocusableInterface&AbstractWidget $widget): static { if (!\in_array($widget, $this->focusables, true)) { $this->focusables[] = $widget; @@ -107,7 +107,7 @@ public function add(FocusableInterface&AbstractWidget $widget): self /** * @return $this */ - public function remove(FocusableInterface&AbstractWidget $widget): self + public function remove(FocusableInterface&AbstractWidget $widget): static { $index = array_search($widget, $this->focusables, true); if (false !== $index) { @@ -125,7 +125,7 @@ public function remove(FocusableInterface&AbstractWidget $widget): self /** * @return $this */ - public function clear(): self + public function clear(): static { $this->focusables = []; @@ -147,7 +147,7 @@ public function all(): array * * @return $this */ - public function onFocusChanged(callable $callback): self + public function onFocusChanged(callable $callback): static { $this->eventDispatcher?->addListener(FocusEvent::class, $callback); diff --git a/src/Symfony/Component/Tui/Style/StyleSheet.php b/src/Symfony/Component/Tui/Style/StyleSheet.php index 494fcee7ad990..a30533efe98e7 100644 --- a/src/Symfony/Component/Tui/Style/StyleSheet.php +++ b/src/Symfony/Component/Tui/Style/StyleSheet.php @@ -105,7 +105,7 @@ public function __construct( * * @return $this */ - public function addRule(string $selector, Style $style): self + public function addRule(string $selector, Style $style): static { $this->rules[$selector] = $style; @@ -120,7 +120,7 @@ public function addRule(string $selector, Style $style): self * * @return $this */ - public function addBreakpoint(int $minColumns, string $selector, Style $style): self + public function addBreakpoint(int $minColumns, string $selector, Style $style): static { $this->breakpoints[$minColumns][$selector] = $style; @@ -136,7 +136,7 @@ public function addBreakpoint(int $minColumns, string $selector, Style $style): * * @return $this */ - public function merge(self $other): self + public function merge(self $other): static { foreach ($other->rules as $selector => $style) { $this->rules[$selector] = $style; @@ -160,7 +160,7 @@ public function merge(self $other): self * * @return $this */ - public function mergeDefaults(self $defaults): self + public function mergeDefaults(self $defaults): static { foreach ($defaults->rules as $selector => $style) { if (!isset($this->rules[$selector])) { diff --git a/src/Symfony/Component/Tui/Tui.php b/src/Symfony/Component/Tui/Tui.php index d137f6d8d730e..01dba2e2fe7fa 100644 --- a/src/Symfony/Component/Tui/Tui.php +++ b/src/Symfony/Component/Tui/Tui.php @@ -116,7 +116,7 @@ public function __construct( * * @return $this */ - public function add(AbstractWidget $widget): self + public function add(AbstractWidget $widget): static { $this->root->add($widget); @@ -128,7 +128,7 @@ public function add(AbstractWidget $widget): self * * @return $this */ - public function remove(AbstractWidget $widget): self + public function remove(AbstractWidget $widget): static { $this->root->remove($widget); @@ -140,7 +140,7 @@ public function remove(AbstractWidget $widget): self * * @return $this */ - public function clear(): self + public function clear(): static { $this->root->clear(); @@ -168,7 +168,7 @@ public function getById(string $id): AbstractWidget * * @return $this */ - public function addStyleSheet(StyleSheet $styleSheet): self + public function addStyleSheet(StyleSheet $styleSheet): static { $this->renderer->addStyleSheet($styleSheet); @@ -304,7 +304,7 @@ public function isRunning(): bool * * @return $this */ - public function onTick(?callable $onTick): self + public function onTick(?callable $onTick): static { $this->onTick = $onTick; $this->lastTickAt = null; @@ -332,7 +332,7 @@ public function onTick(?callable $onTick): self * * @return $this */ - public function on(string $eventClass, callable $listener, int $priority = 0): self + public function on(string $eventClass, callable $listener, int $priority = 0): static { $this->eventDispatcher->addListener($eventClass, $listener, $priority); @@ -360,7 +360,7 @@ public function getTerminal(): TerminalInterface * * @return $this */ - public function setFocus(?AbstractWidget $component): self + public function setFocus(?AbstractWidget $component): static { $this->focusManager->setFocus($component); diff --git a/src/Symfony/Component/Tui/Widget/AbstractWidget.php b/src/Symfony/Component/Tui/Widget/AbstractWidget.php index bb985c3965837..36e933a0efaf5 100644 --- a/src/Symfony/Component/Tui/Widget/AbstractWidget.php +++ b/src/Symfony/Component/Tui/Widget/AbstractWidget.php @@ -141,7 +141,7 @@ final public function setStyleClasses(array $classes): void /** * @return $this */ - final public function addStyleClass(string $class): self + final public function addStyleClass(string $class): static { if (!\in_array($class, $this->styleClasses, true)) { $this->styleClasses[] = $class; @@ -154,7 +154,7 @@ final public function addStyleClass(string $class): self /** * @return $this */ - final public function removeStyleClass(string $class): self + final public function removeStyleClass(string $class): static { $newClasses = array_values(array_filter( $this->styleClasses, @@ -233,7 +233,7 @@ final public function detach(): void /** * @return $this */ - final public function setStyle(?Style $style): self + final public function setStyle(?Style $style): static { if ($this->internalStyle !== $style) { $this->internalStyle = $style; diff --git a/src/Symfony/Component/Tui/Widget/CancellableLoaderWidget.php b/src/Symfony/Component/Tui/Widget/CancellableLoaderWidget.php index b65cf3da1d417..72689637e5e1c 100644 --- a/src/Symfony/Component/Tui/Widget/CancellableLoaderWidget.php +++ b/src/Symfony/Component/Tui/Widget/CancellableLoaderWidget.php @@ -61,7 +61,7 @@ public function start(): void * * @return $this */ - public function onCancel(callable $callback): self + public function onCancel(callable $callback): static { return $this->on(CancelEvent::class, $callback); } diff --git a/src/Symfony/Component/Tui/Widget/ContainerWidget.php b/src/Symfony/Component/Tui/Widget/ContainerWidget.php index ea9055fbf7ce0..d116d9d448a89 100644 --- a/src/Symfony/Component/Tui/Widget/ContainerWidget.php +++ b/src/Symfony/Component/Tui/Widget/ContainerWidget.php @@ -115,7 +115,7 @@ public function all(): array * * @return $this */ - public function expandVertically(bool $expand): self + public function expandVertically(bool $expand): static { if ($this->verticallyExpanded !== $expand) { $this->verticallyExpanded = $expand; diff --git a/src/Symfony/Component/Tui/Widget/EditorWidget.php b/src/Symfony/Component/Tui/Widget/EditorWidget.php index f99b27dfd24e5..6bde529f4af2e 100644 --- a/src/Symfony/Component/Tui/Widget/EditorWidget.php +++ b/src/Symfony/Component/Tui/Widget/EditorWidget.php @@ -90,7 +90,7 @@ public function getPasteMarkers(): array /** * @return $this */ - public function setText(string $text): self + public function setText(string $text): static { if ($this->document->setText($text)) { $this->viewport->reset(); @@ -103,7 +103,7 @@ public function setText(string $text): self /** * @return $this */ - public function setMinVisibleLines(int $minVisibleLines): self + public function setMinVisibleLines(int $minVisibleLines): static { $minVisibleLines = max(0, $minVisibleLines); if ($this->minVisibleLines !== $minVisibleLines) { @@ -117,7 +117,7 @@ public function setMinVisibleLines(int $minVisibleLines): self /** * @return $this */ - public function setMaxVisibleLines(?int $maxVisibleLines): self + public function setMaxVisibleLines(?int $maxVisibleLines): static { if (null !== $maxVisibleLines) { $maxVisibleLines = max(1, $maxVisibleLines); @@ -134,7 +134,7 @@ public function setMaxVisibleLines(?int $maxVisibleLines): self /** * @return $this */ - public function expandVertically(bool $fill): self + public function expandVertically(bool $fill): static { if ($this->verticallyExpanded !== $fill) { $this->verticallyExpanded = $fill; @@ -149,7 +149,7 @@ public function isVerticallyExpanded(): bool return $this->verticallyExpanded; } - public function setFocused(bool $focused): self + public function setFocused(bool $focused): static { if ($this->focused !== $focused) { $this->focused = $focused; @@ -165,7 +165,7 @@ public function setFocused(bool $focused): self * * @return $this */ - public function onSubmit(callable $callback): self + public function onSubmit(callable $callback): static { return $this->on(SubmitEvent::class, $callback); } @@ -175,7 +175,7 @@ public function onSubmit(callable $callback): self * * @return $this */ - public function onCancel(callable $callback): self + public function onCancel(callable $callback): static { return $this->on(CancelEvent::class, $callback); } @@ -185,7 +185,7 @@ public function onCancel(callable $callback): self * * @return $this */ - public function onChange(callable $callback): self + public function onChange(callable $callback): static { return $this->on(ChangeEvent::class, $callback); } diff --git a/src/Symfony/Component/Tui/Widget/FocusableInterface.php b/src/Symfony/Component/Tui/Widget/FocusableInterface.php index 10fb7160bb22d..7c1ea895512d7 100644 --- a/src/Symfony/Component/Tui/Widget/FocusableInterface.php +++ b/src/Symfony/Component/Tui/Widget/FocusableInterface.php @@ -41,7 +41,7 @@ public function isFocused(): bool; * * @return $this */ - public function setFocused(bool $focused): self; + public function setFocused(bool $focused): static; /** * Register a callback invoked before handleInput(). diff --git a/src/Symfony/Component/Tui/Widget/FocusableTrait.php b/src/Symfony/Component/Tui/Widget/FocusableTrait.php index 2a6955c7e4aab..e5c6973169550 100644 --- a/src/Symfony/Component/Tui/Widget/FocusableTrait.php +++ b/src/Symfony/Component/Tui/Widget/FocusableTrait.php @@ -33,7 +33,7 @@ public function isFocused(): bool /** * @return $this */ - public function setFocused(bool $focused): self + public function setFocused(bool $focused): static { if ($this->focused !== $focused) { $this->focused = $focused; diff --git a/src/Symfony/Component/Tui/Widget/InputWidget.php b/src/Symfony/Component/Tui/Widget/InputWidget.php index f9f098a28a1ea..f396eea5a98c7 100644 --- a/src/Symfony/Component/Tui/Widget/InputWidget.php +++ b/src/Symfony/Component/Tui/Widget/InputWidget.php @@ -53,7 +53,7 @@ public function __construct( * * @return $this */ - public function onSubmit(callable $callback): self + public function onSubmit(callable $callback): static { return $this->on(SubmitEvent::class, $callback); } @@ -63,7 +63,7 @@ public function onSubmit(callable $callback): self * * @return $this */ - public function onCancel(callable $callback): self + public function onCancel(callable $callback): static { return $this->on(CancelEvent::class, $callback); } @@ -73,7 +73,7 @@ public function onCancel(callable $callback): self * * @return $this */ - public function onChange(callable $callback): self + public function onChange(callable $callback): static { return $this->on(ChangeEvent::class, $callback); } @@ -94,7 +94,7 @@ public function wasSubmitted(): bool /** * @return $this */ - public function setPrompt(string $prompt): self + public function setPrompt(string $prompt): static { if ($this->prompt !== $prompt) { $this->prompt = $prompt; @@ -107,7 +107,7 @@ public function setPrompt(string $prompt): self /** * @return $this */ - public function setValue(string $value): self + public function setValue(string $value): static { // When setting a new value, move cursor to the end of the string $newCursor = \strlen($value); @@ -120,7 +120,7 @@ public function setValue(string $value): self return $this; } - public function setFocused(bool $focused): self + public function setFocused(bool $focused): static { if ($this->focused !== $focused) { $this->focused = $focused; diff --git a/src/Symfony/Component/Tui/Widget/MarkdownWidget.php b/src/Symfony/Component/Tui/Widget/MarkdownWidget.php index 0f69267d320a5..6ab1101bfbb8e 100644 --- a/src/Symfony/Component/Tui/Widget/MarkdownWidget.php +++ b/src/Symfony/Component/Tui/Widget/MarkdownWidget.php @@ -95,7 +95,7 @@ public function __construct( /** * @return $this */ - public function setText(string $text): self + public function setText(string $text): static { $this->text = StringUtils::sanitizeUtf8($text); $this->invalidate(); diff --git a/src/Symfony/Component/Tui/Widget/ProgressBarWidget.php b/src/Symfony/Component/Tui/Widget/ProgressBarWidget.php index 54c124046f3ed..951f04d7138db 100644 --- a/src/Symfony/Component/Tui/Widget/ProgressBarWidget.php +++ b/src/Symfony/Component/Tui/Widget/ProgressBarWidget.php @@ -265,7 +265,7 @@ public function getStepWidth(): int /** * @return $this */ - public function setFormat(string $format): self + public function setFormat(string $format): static { $this->format = $format; $this->invalidate(); @@ -284,7 +284,7 @@ public function getFormat(): string /** * @return $this */ - public function setBarWidth(int $width): self + public function setBarWidth(int $width): static { $this->barWidth = max(1, $width); $this->invalidate(); @@ -305,7 +305,7 @@ public function getBarWidth(): int * * @return $this */ - public function setBarCharacter(string $char): self + public function setBarCharacter(string $char): static { $this->barChar = $char; $this->invalidate(); @@ -326,7 +326,7 @@ public function getBarCharacter(): string * * @return $this */ - public function setEmptyBarCharacter(string $char): self + public function setEmptyBarCharacter(string $char): static { $this->emptyBarChar = $char; $this->invalidate(); @@ -347,7 +347,7 @@ public function getEmptyBarCharacter(): string * * @return $this */ - public function setProgressCharacter(string $char): self + public function setProgressCharacter(string $char): static { $this->progressChar = $char; $this->invalidate(); @@ -384,7 +384,7 @@ public function setMaxSteps(int $max): void * * @return $this */ - public function setMessage(string $message, string $name = 'message'): self + public function setMessage(string $message, string $name = 'message'): static { $this->messages[$name] = $message; $this->invalidate(); @@ -408,7 +408,7 @@ public function getMessage(string $name = 'message'): ?string * * @return $this */ - public function setPlaceholderFormatter(string $name, \Closure $formatter): self + public function setPlaceholderFormatter(string $name, \Closure $formatter): static { $this->placeholderFormatters[$name] = $formatter; $this->invalidate(); diff --git a/src/Symfony/Component/Tui/Widget/SelectListWidget.php b/src/Symfony/Component/Tui/Widget/SelectListWidget.php index ef8b68776ff36..756fe6ae1821a 100644 --- a/src/Symfony/Component/Tui/Widget/SelectListWidget.php +++ b/src/Symfony/Component/Tui/Widget/SelectListWidget.php @@ -56,7 +56,7 @@ public function __construct( * * @return $this */ - public function setItems(array $items): self + public function setItems(array $items): static { $this->items = $items; $this->filteredItems = $items; @@ -69,7 +69,7 @@ public function setItems(array $items): self /** * @return $this */ - public function setFilter(string $filter): self + public function setFilter(string $filter): static { $filter = strtolower($filter); @@ -90,7 +90,7 @@ public function setFilter(string $filter): self /** * @return $this */ - public function setSelectedIndex(int $index): self + public function setSelectedIndex(int $index): static { $index = max(0, min($index, \count($this->filteredItems) - 1)); if ($this->selectedIndex !== $index) { @@ -124,7 +124,7 @@ public function wasSelected(): bool * * @return $this */ - public function onSelect(callable $callback): self + public function onSelect(callable $callback): static { return $this->on(SelectEvent::class, $callback); } @@ -134,7 +134,7 @@ public function onSelect(callable $callback): self * * @return $this */ - public function onCancel(callable $callback): self + public function onCancel(callable $callback): static { return $this->on(CancelEvent::class, $callback); } @@ -144,7 +144,7 @@ public function onCancel(callable $callback): self * * @return $this */ - public function onSelectionChange(callable $callback): self + public function onSelectionChange(callable $callback): static { return $this->on(SelectionChangeEvent::class, $callback); } diff --git a/src/Symfony/Component/Tui/Widget/SettingsListWidget.php b/src/Symfony/Component/Tui/Widget/SettingsListWidget.php index a444bf3f43029..cb2b1b96dac61 100644 --- a/src/Symfony/Component/Tui/Widget/SettingsListWidget.php +++ b/src/Symfony/Component/Tui/Widget/SettingsListWidget.php @@ -87,7 +87,7 @@ public function getValue(string $id): ?string * * @return $this */ - public function onChange(callable $callback): self + public function onChange(callable $callback): static { return $this->on(SettingChangeEvent::class, $callback); } @@ -97,7 +97,7 @@ public function onChange(callable $callback): self * * @return $this */ - public function onCancel(callable $callback): self + public function onCancel(callable $callback): static { return $this->on(CancelEvent::class, $callback); } diff --git a/src/Symfony/Component/Tui/Widget/TextWidget.php b/src/Symfony/Component/Tui/Widget/TextWidget.php index a12cbb61843ef..bff007371605d 100644 --- a/src/Symfony/Component/Tui/Widget/TextWidget.php +++ b/src/Symfony/Component/Tui/Widget/TextWidget.php @@ -56,7 +56,7 @@ public function __construct( /** * @return $this */ - public function setText(string $text): self + public function setText(string $text): static { $this->text = $text; $this->invalidate(); diff --git a/src/Symfony/Component/Tui/Widget/VerticallyExpandableInterface.php b/src/Symfony/Component/Tui/Widget/VerticallyExpandableInterface.php index 4652d05b3d750..b3f37da77dede 100644 --- a/src/Symfony/Component/Tui/Widget/VerticallyExpandableInterface.php +++ b/src/Symfony/Component/Tui/Widget/VerticallyExpandableInterface.php @@ -30,7 +30,7 @@ interface VerticallyExpandableInterface * * @return $this */ - public function expandVertically(bool $expand): self; + public function expandVertically(bool $expand): static; /** * Check if the widget should expand to fill available height. From 0e2af46d90a725d9ca8bc0dcd61cbc0a0b8ec1db Mon Sep 17 00:00:00 2001 From: ruttydm Date: Mon, 30 Mar 2026 18:02:45 +0200 Subject: [PATCH 22/22] [Tui] Force full re-render on terminal resize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the terminal is resized (SIGWINCH), the ScreenWriter's cached previousLines contain lines rendered at the old terminal width. A non-forced requestRender() performs differential rendering against these stale lines, producing visual glitches — misaligned borders, wrapped lines, and leftover characters. Pass force: true to requestRender() in the resize callback so the ScreenWriter resets its cache and performs a clean full render with the new terminal dimensions. --- src/Symfony/Component/Tui/Tui.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/Tui/Tui.php b/src/Symfony/Component/Tui/Tui.php index 01dba2e2fe7fa..46184b18af3a0 100644 --- a/src/Symfony/Component/Tui/Tui.php +++ b/src/Symfony/Component/Tui/Tui.php @@ -208,7 +208,7 @@ public function start(): void $this->stopped = false; $this->lastTickAt = null; $this->lastTickBusyHint = null; - $this->terminal->start($this->handleInput(...), $this->requestRender(...), function (): void { + $this->terminal->start($this->handleInput(...), fn () => $this->requestRender(force: true), function (): void { $this->keybindings->setKittyProtocolActive(true); }); $this->terminal->hideCursor();