From 95d52cdbb3a92f38bcf428141bd339d7dc86cf35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Thu, 10 Sep 2026 00:57:56 -0300 Subject: [PATCH] eepp: - add reusable-buffer String::toUtf8() conversion - extract shared find-bar CSS into UIFindBarStyle - reuse the shared style from UIDocFindReplace ecode: - enable terminal find, next, and previous shortcuts - install terminal bindings after conflicting editor bindings are removed eterm: - add searchable terminal history with plain text, RegEx, and LuaPattern modes - support case-sensitive and whole-word matching across wrapped rows - debounce searches and avoid unnecessary string allocations - add an animated find bar with status and wrapped result navigation - expose find commands through configurable keybindings and the context menu - render and navigate search matches in terminal history - forward right-button releases when applications capture the mouse - fix UITabWidgetSplitter ownership during shutdown tests: - cover UTF-8 buffer reuse and terminal search behavior - verify wrapped lines, Unicode, pattern modes, invalid expressions, and navigation - verify SGR right-button release encoding tooling: - clarify the mandatory optimized build workflow for performance work - require direct release test execution before falling back to Xvfb --- .agent/rules/build-project.md | 8 + .agent/rules/unit-tests.md | 18 +- include/eepp/core/string.hpp | 3 + include/eepp/ui/tools/uifindbarstyle.hpp | 23 ++ src/eepp/core/string.cpp | 13 +- src/eepp/ui/tools/uidocfindreplace.cpp | 108 +-------- src/eepp/ui/tools/uifindbarstyle.cpp | 119 ++++++++++ .../eterm/terminal/terminaldisplay.hpp | 12 + .../eterm/terminal/terminalemulator.hpp | 19 ++ .../include/eterm/terminal/terminalsearch.hpp | 82 +++++++ .../eterm/terminal/terminalsession.hpp | 24 +- .../include/eterm/terminal/terminaltypes.hpp | 2 + .../eterm/include/eterm/ui/uiterminal.hpp | 3 + .../eterm/include/eterm/ui/uiterminalfind.hpp | 59 +++++ .../src/eterm/terminal/terminaldisplay.cpp | 42 ++++ .../src/eterm/terminal/terminalemulator.cpp | 102 +++++++++ .../src/eterm/terminal/terminalsearch.cpp | 154 +++++++++++++ .../src/eterm/terminal/terminalsession.cpp | 52 +++++ src/modules/eterm/src/eterm/ui/uiterminal.cpp | 15 +- .../eterm/src/eterm/ui/uiterminalfind.cpp | 209 ++++++++++++++++++ src/tests/unit_tests/eterm_tests.cpp | 93 +++++++- .../unit_tests/stringsoperations_tests.cpp | 7 + src/tools/ecode/terminalmanager.cpp | 8 +- src/tools/eterm/eterm.cpp | 8 +- src/tools/eterm/keybindings.cpp | 4 + 25 files changed, 1067 insertions(+), 120 deletions(-) create mode 100644 include/eepp/ui/tools/uifindbarstyle.hpp create mode 100644 src/eepp/ui/tools/uifindbarstyle.cpp create mode 100644 src/modules/eterm/include/eterm/terminal/terminalsearch.hpp create mode 100644 src/modules/eterm/include/eterm/ui/uiterminalfind.hpp create mode 100644 src/modules/eterm/src/eterm/terminal/terminalsearch.cpp create mode 100644 src/modules/eterm/src/eterm/ui/uiterminalfind.cpp diff --git a/.agent/rules/build-project.md b/.agent/rules/build-project.md index 90c3d29db..5e8a8901d 100644 --- a/.agent/rules/build-project.md +++ b/.agent/rules/build-project.md @@ -17,6 +17,14 @@ This produces an optimized release build with debug symbols and without AddressS release executable (for example, `bin/eepp-ui-html`) when measuring performance. Recheck `.ecode/project_build.json` before use because the local configuration may change. +This workflow is mandatory for any task whose purpose includes performance investigation, +optimization, benchmarking, or validating runtime speed. Do not reuse a gmake tree generated with +`--address-sanitizer`, and do not substitute `make config=release`: regenerate with the current +`eepp-linux-ninja` command and build the `release` Ninja target exactly as configured above. + +The debug/unit-test workflow below is additional correctness validation. It does not replace the +release Ninja build required for performance work. + ## Debug and Unit-Test Builds All build commands must be executed from the **root project directory**. Follow these steps to build the project: diff --git a/.agent/rules/unit-tests.md b/.agent/rules/unit-tests.md index 80279eda7..24823eac5 100644 --- a/.agent/rules/unit-tests.md +++ b/.agent/rules/unit-tests.md @@ -5,8 +5,20 @@ This project relies on a comprehensive suite of unit tests to prevent regression ## Running Tests The test binary manages its own current working directory, so you can execute it from anywhere. -* **Prefer the release test binary during normal development:** - When AddressSanitizer or other debug-only diagnostics are not required, build and run `bin/unit_tests/eepp-unit_tests`. The optimized release suite is substantially faster and should be the default for iterative testing. Use `bin/unit_tests/eepp-unit_tests-debug` when investigating memory safety, assertions, or other behavior that specifically requires the debug configuration. +The required default workflow is: + +1. Build the release unit-test target. +2. Run `bin/unit_tests/eepp-unit_tests` directly, without Xvfb. +3. If sandbox restrictions prevent access to the host display, retry that same direct command with + elevated permissions. +4. Use Xvfb only after direct execution has failed outside the sandbox because no usable graphical + display is available. Xvfb is the last fallback, not the default headless convenience path. + +Do not infer that a session is headless merely because its first sandboxed command cannot connect to +the display. A sandbox can hide or deny access to an otherwise usable host display. + +* **Use the release test binary during normal development:** + When AddressSanitizer or other debug-only diagnostics are not required, build and run `bin/unit_tests/eepp-unit_tests`. The optimized release suite is substantially faster and is the required default for iterative testing. Use `bin/unit_tests/eepp-unit_tests-debug` only when investigating memory safety, assertions, or other behavior that specifically requires the debug configuration. * **Default Execution on a Graphical Linux Desktop:** Unit-test windows are created hidden, so run the release suite directly against the desktop: `bin/unit_tests/eepp-unit_tests` @@ -16,7 +28,7 @@ The test binary manages its own current working directory, so you can execute it * **Filtered Tests on a Graphical Linux Desktop:** Use the same direct hardware-backed command for focused runs: `bin/unit_tests/eepp-unit_tests --filter="FontRendering.*Offset*"` -* **Headless CI and Systems Without a Usable Desktop Display:** +* **Last Fallback for Headless CI and Systems Confirmed to Lack a Usable Desktop Display:** Keep `projects/scripts/xvfb-run-eepp` as the fallback when no desktop display is available: `projects/scripts/xvfb-run-eepp bin/unit_tests/eepp-unit_tests` The wrapper provides a race-safe isolated display at `1280x1024x24` and injects diff --git a/include/eepp/core/string.hpp b/include/eepp/core/string.hpp index 68251e0cd..ec5f26bb5 100644 --- a/include/eepp/core/string.hpp +++ b/include/eepp/core/string.hpp @@ -779,6 +779,9 @@ class EE_API String { /** Convert the string to a UTF-8 string */ std::string toUtf8() const; + /** Convert the string to UTF-8, reusing the output buffer capacity. */ + void toUtf8( std::string& output ) const; + /** Convert the string to a UTF-16 string */ std::basic_string toUtf16() const; diff --git a/include/eepp/ui/tools/uifindbarstyle.hpp b/include/eepp/ui/tools/uifindbarstyle.hpp new file mode 100644 index 000000000..7271c62fa --- /dev/null +++ b/include/eepp/ui/tools/uifindbarstyle.hpp @@ -0,0 +1,23 @@ +#ifndef EE_UI_TOOLS_UIFINDBARSTYLE_HPP +#define EE_UI_TOOLS_UIFINDBARSTYLE_HPP + +#include + +namespace EE { namespace UI { + +class UISceneNode; + +namespace Tools { + +class EE_API UIFindBarStyle { + public: + static const char* getStyleSheet(); + + /** Installs the shared find-bar stylesheet into a scene once. */ + static void ensure( UISceneNode* scene ); +}; + +} // namespace Tools +}} // namespace EE::UI + +#endif diff --git a/src/eepp/core/string.cpp b/src/eepp/core/string.cpp index 788fe635c..d29519981 100644 --- a/src/eepp/core/string.cpp +++ b/src/eepp/core/string.cpp @@ -2112,16 +2112,17 @@ std::wstring String::toWideString() const { #endif std::string String::toUtf8() const { - // Prepare the output string std::string output; - output.reserve( mString.length() + 1 ); - - // Convert - Utf32::toUtf8( mString.begin(), mString.end(), std::back_inserter( output ) ); - + toUtf8( output ); return output; } +void String::toUtf8( std::string& output ) const { + output.clear(); + output.reserve( mString.length() + 1 ); + Utf32::toUtf8( mString.begin(), mString.end(), std::back_inserter( output ) ); +} + std::basic_string String::toUtf16() const { // Prepare the output string std::basic_string output; diff --git a/src/eepp/ui/tools/uidocfindreplace.cpp b/src/eepp/ui/tools/uidocfindreplace.cpp index 33f54ab12..d80d6997c 100644 --- a/src/eepp/ui/tools/uidocfindreplace.cpp +++ b/src/eepp/ui/tools/uidocfindreplace.cpp @@ -1,111 +1,13 @@ #include #include -#include #include +#include #include #include #include namespace EE { namespace UI { namespace Tools { -const char* DOC_FIND_REPLACE_CSS_MARKER = "ce_find_replace_box_marker"; - -const char DOC_FIND_REPLACE_CSS[] = R"css( -.ce_find_replace_box { - background-color: var(--list-back); -} -.ce_find_replace_box > .find_replace_toggle, -.ce_find_replace_box pushbutton { - background-color: var(--list-back); - border-width: 1dp; - border-radius: 4dp; - border-color: transparent; - tint: var(--font); - text-as-fallback: true; -} -.ce_find_replace_box pushbutton:hover { - background-color: var(--list-back); - border-color: var(--primary); -} -.ce_find_replace_box > .find_replace_toggle:hover { - background-color: var(--list-back); - tint: var(--primary); -} -.ce_find_replace_box > linearlayout { - padding-top: 2dp; - padding-bottom: 2dp; - padding-right: 2dp; -} -.ce_find_replace_box > .expander { - background-color: var(--back); -} -.ce_find_replace_box .replace_box { - visible: false; - margin-top: 2dp; -} -.ce_find_replace_box .replace_box { - visible: false; -} -.ce_find_replace_box .replace_box.enabled { - visible: true; -} -.ce_find_replace_box > .find_replace_toggle { - icon: icon(arrow-right-s, 16dp); - padding: 2dp; -} -.ce_find_replace_box > .find_replace_toggle.enabled { - icon: icon(arrow-down-s, 16dp); -} -.ce_find_replace_box .input-find { - padding-right: 88dp; - clip: padding-box; -} -.ce_find_replace_box selectbutton { - background-color: transparent; - border-color: transparent; - padding: 2dp; - select-on-click: true; - transition: tint 0.2s; - border-radius: 2dp; -} -.ce_find_replace_box selectbutton:hover { - border-color: transparent; - tint: var(--primary); - color: var(--primary); -} -.ce_find_replace_box selectbutton:selected { - border-color: var(--primary); - background-color: rgba(var(--primary), 0.25); - color: var(--font); -} -.ce_find_replace_box selectbutton.match-case { - icon: url("data:image/svg,"); -} -.ce_find_replace_box selectbutton.luapattern { - icon: url("data:image/svg,"); -} -.ce_find_replace_box selectbutton.regex { - icon: url("data:image/svg,"); -} -.ce_find_replace_box selectbutton.whole-word { - icon: url("data:image/svg,"); -} -.ce_find_replace_box selectbutton.escape-sequences { - font-style: bold; - min-width: 20dp; -} -.ce_find_replace_box pushbutton.replace-button { - icon: url("data:image/svg,"); -} -.ce_find_replace_box pushbutton.replace-all-button { - icon: url("data:image/svg,"); -} -.ce_find_replace_box .input-find.error, -.ce_find_replace_box .input-replace.error { - border-color: var(--theme-error); -} -)css"; - const char DOC_FIND_REPLACE_XML[] = R"xml( @@ -173,13 +75,7 @@ UIDocFindReplace::UIDocFindReplace( UIWidget* parent, const std::shared_ptrgetUISceneNode()->getStyleSheet().markerExists( - String::hash( DOC_FIND_REPLACE_CSS_MARKER ) ) ) { - CSS::StyleSheetParser parser; - parser.loadFromMemory( (const Uint8*)DOC_FIND_REPLACE_CSS, - eeARRAY_SIZE( DOC_FIND_REPLACE_CSS ) ); - parent->getUISceneNode()->getStyleSheet().combineStyleSheet( parser.getStyleSheet() ); - } + UIFindBarStyle::ensure( parent->getUISceneNode() ); parent->getUISceneNode()->loadLayoutFromMemory( DOC_FIND_REPLACE_XML, eeARRAY_SIZE( DOC_FIND_REPLACE_XML ), this ); diff --git a/src/eepp/ui/tools/uifindbarstyle.cpp b/src/eepp/ui/tools/uifindbarstyle.cpp new file mode 100644 index 000000000..0085ae341 --- /dev/null +++ b/src/eepp/ui/tools/uifindbarstyle.cpp @@ -0,0 +1,119 @@ +#include +#include +#include + +namespace EE { namespace UI { namespace Tools { + +static constexpr auto FIND_BAR_STYLE_MARKER = String::hash( "ce_find_replace_box_marker" ); + +const char DOC_FIND_REPLACE_CSS[] = R"css( +.ce_find_replace_box { + background-color: var(--list-back); +} +.ce_find_replace_box > .find_replace_toggle, +.ce_find_replace_box pushbutton { + background-color: var(--list-back); + border-width: 1dp; + border-radius: 4dp; + border-color: transparent; + tint: var(--font); + text-as-fallback: true; +} +.ce_find_replace_box pushbutton:hover { + background-color: var(--list-back); + border-color: var(--primary); +} +.ce_find_replace_box > .find_replace_toggle:hover { + background-color: var(--list-back); + tint: var(--primary); +} +.ce_find_replace_box > linearlayout { + padding-top: 2dp; + padding-bottom: 2dp; + padding-right: 2dp; +} +.ce_find_replace_box > .expander { + background-color: var(--back); +} +.ce_find_replace_box .replace_box { + visible: false; + margin-top: 2dp; +} +.ce_find_replace_box .replace_box { + visible: false; +} +.ce_find_replace_box .replace_box.enabled { + visible: true; +} +.ce_find_replace_box > .find_replace_toggle { + icon: icon(arrow-right-s, 16dp); + padding: 2dp; +} +.ce_find_replace_box > .find_replace_toggle.enabled { + icon: icon(arrow-down-s, 16dp); +} +.ce_find_replace_box .input-find { + padding-right: 88dp; + clip: padding-box; +} +.ce_find_replace_box selectbutton { + background-color: transparent; + border-color: transparent; + padding: 2dp; + select-on-click: true; + transition: tint 0.2s; + border-radius: 2dp; +} +.ce_find_replace_box selectbutton:hover { + border-color: transparent; + tint: var(--primary); + color: var(--primary); +} +.ce_find_replace_box selectbutton:selected { + border-color: var(--primary); + background-color: rgba(var(--primary), 0.25); + color: var(--font); +} +.ce_find_replace_box selectbutton.match-case { + icon: url("data:image/svg,"); +} +.ce_find_replace_box selectbutton.luapattern { + icon: url("data:image/svg,"); +} +.ce_find_replace_box selectbutton.regex { + icon: url("data:image/svg,"); +} +.ce_find_replace_box selectbutton.whole-word { + icon: url("data:image/svg,"); +} +.ce_find_replace_box selectbutton.escape-sequences { + font-style: bold; + min-width: 20dp; +} +.ce_find_replace_box pushbutton.replace-button { + icon: url("data:image/svg,"); +} +.ce_find_replace_box pushbutton.replace-all-button { + icon: url("data:image/svg,"); +} +.ce_find_replace_box .input-find.error, +.ce_find_replace_box .input-replace.error { + border-color: var(--theme-error); +} +)css"; + +const char* UIFindBarStyle::getStyleSheet() { + return DOC_FIND_REPLACE_CSS; +} + +void UIFindBarStyle::ensure( UISceneNode* scene ) { + if ( scene->getStyleSheet().markerExists( FIND_BAR_STYLE_MARKER ) ) + return; + CSS::StyleSheetParser parser; + parser.loadFromMemory( reinterpret_cast( DOC_FIND_REPLACE_CSS ), + sizeof( DOC_FIND_REPLACE_CSS ) ); + parser.getStyleSheet().setMarker( FIND_BAR_STYLE_MARKER ); + scene->getStyleSheet().combineStyleSheet( parser.getStyleSheet() ); +} + +}}} // namespace EE::UI::Tools diff --git a/src/modules/eterm/include/eterm/terminal/terminaldisplay.hpp b/src/modules/eterm/include/eterm/terminal/terminaldisplay.hpp index 88526de0b..cca266a9c 100644 --- a/src/modules/eterm/include/eterm/terminal/terminaldisplay.hpp +++ b/src/modules/eterm/include/eterm/terminal/terminaldisplay.hpp @@ -258,6 +258,18 @@ class TerminalDisplay { const std::shared_ptr& getSession() const; + void setSearchQuery( TerminalSearchQuery query ); + + void navigateSearch( int direction ); + + void clearSearch(); + + Uint32 getSearchMatchCount() const; + + Int32 getCurrentSearchMatch() const; + + Uint64 getSearchRequestId() const; + std::string getSelection(); bool hasSelection() const; diff --git a/src/modules/eterm/include/eterm/terminal/terminalemulator.hpp b/src/modules/eterm/include/eterm/terminal/terminalemulator.hpp index 676db2590..68eaa6232 100644 --- a/src/modules/eterm/include/eterm/terminal/terminalemulator.hpp +++ b/src/modules/eterm/include/eterm/terminal/terminalemulator.hpp @@ -43,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -297,6 +298,18 @@ class TerminalEmulator final { int getTerminalMode() const { return mTerm.mode; } + void setSearchQuery( TerminalSearchQuery query ); + + void navigateSearch( int direction ); + + void clearSearch(); + + const std::vector& getSearchMatches() const; + + Int32 getCurrentSearchMatch() const; + + Uint64 getSearchRequestId() const { return mSearchQuery.requestId; } + private: DpyPtr mDpy; PtyPtr mPty; @@ -361,6 +374,12 @@ class TerminalEmulator final { }; std::unordered_map mKittyPlaceholderMetadata; Uint32 mKittyUnderlineColor{ 0 }; + TerminalSearch mSearch; + TerminalSearchQuery mSearchQuery; + std::vector mSearchRows; + Int32 mCurrentSearchMatch{ -1 }; + Clock mSearchRefreshClock; + bool mSearchDirty{ false }; void setClipboard( const char* str ); diff --git a/src/modules/eterm/include/eterm/terminal/terminalsearch.hpp b/src/modules/eterm/include/eterm/terminal/terminalsearch.hpp new file mode 100644 index 000000000..7d9f26b04 --- /dev/null +++ b/src/modules/eterm/include/eterm/terminal/terminalsearch.hpp @@ -0,0 +1,82 @@ +#ifndef ETERM_TERMINALSEARCH_HPP +#define ETERM_TERMINALSEARCH_HPP + +#include +#include + +#include + +namespace EE { namespace System { +class PatternMatcher; +}} // namespace EE::System + +using namespace EE; +using namespace EE::System; + +namespace eterm { namespace Terminal { + +enum class TerminalBufferSource : Uint8 { MainHistory, MainScreen, AlternateScreen }; + +enum class TerminalSearchType : Uint8 { Normal, RegEx, LuaPattern }; + +struct TerminalBufferPosition { + TerminalBufferSource source{ TerminalBufferSource::MainScreen }; + Int64 row{ 0 }; + Int32 column{ 0 }; +}; + +struct TerminalSearchRowView { + Line cells{ nullptr }; + TerminalBufferSource source{ TerminalBufferSource::MainScreen }; + Int64 row{ 0 }; + Int32 width{ 0 }; + Int32 length{ 0 }; + bool wrapped{ false }; +}; + +struct TerminalSearchQuery { + String text; + Uint64 requestId{ 0 }; + bool caseSensitive{ false }; + bool wholeWord{ false }; + TerminalSearchType type{ TerminalSearchType::Normal }; +}; + +struct TerminalSearchMatch { + TerminalBufferPosition start; + TerminalBufferPosition end; +}; + +/** Worker-side search engine over chronological, non-owning terminal rows. */ +class TerminalSearch { + public: + static constexpr size_t MinimumQueryLength = 2; + + static bool isQuerySearchable( const TerminalSearchQuery& query ) { + return query.text.size() >= MinimumQueryLength; + } + + const std::vector& search( const std::vector& rows, + const TerminalSearchQuery& query ); + + const std::vector& matches() const { return mMatches; } + + private: + struct CellSpan { + TerminalBufferPosition start; + TerminalBufferPosition end; + }; + + void searchLogicalLine( const TerminalSearchQuery& query, PatternMatcher* pattern, + const String& needle ); + + std::vector mMatches; + String mText; + String mComparableText; + std::string mUtf8Text; + std::vector mPositions; +}; + +}} // namespace eterm::Terminal + +#endif diff --git a/src/modules/eterm/include/eterm/terminal/terminalsession.hpp b/src/modules/eterm/include/eterm/terminal/terminalsession.hpp index a1efd0786..02a8bba47 100644 --- a/src/modules/eterm/include/eterm/terminal/terminalsession.hpp +++ b/src/modules/eterm/include/eterm/terminal/terminalsession.hpp @@ -30,9 +30,16 @@ namespace eterm { namespace Terminal { /** Immutable worker-to-UI presentation state. Cell selection is already applied as ATTR_REVERSE. */ struct TerminalSnapshot { + struct SearchCellRange { + Vector2i start; + Vector2i end; + bool active{ false }; + }; + std::shared_ptr graphics; std::vector cells; std::vector dirtyRows; + std::vector visibleSearchMatches; std::string title; std::string currentWorkingDirectory; std::string selection; @@ -48,6 +55,9 @@ struct TerminalSnapshot { int processId{ 0 }; int exitCode{ 0 }; Uint32 presentationRate{ 60 }; + Uint32 searchMatchCount{ 0 }; + Int32 currentSearchMatch{ -1 }; + Uint64 searchRequestId{ 0 }; TerminalCursorMode cursorMode{ SteadyUnderline }; TerminalSelectionMode selectionMode{ SEL_IDLE }; PromptState promptState{ PromptState::Unknown }; @@ -148,6 +158,9 @@ class TerminalSession final : public std::enable_shared_from_this drainEvents(); std::vector drainGraphicsUpdates(); void requestGraphicsResync(); + void setSearchQuery( TerminalSearchQuery query ); + void navigateSearch( int direction ); + void clearSearch(); /** Bounded exact-selection request. Returns no value on timeout or during shutdown. */ std::optional @@ -220,6 +233,13 @@ class TerminalSession final : public std::enable_shared_from_this; + SelectionRequestCommand, GraphicsResyncCommand, SearchQueryCommand, + SearchNavigateCommand, SearchClearCommand>; TerminalSession( PtyPtr&& pty, ProcPtr&& process, size_t historySize, TerminalColorPalette palette ); @@ -261,6 +282,7 @@ class TerminalSession final : public std::enable_shared_from_this mPublishedSnapshot; std::atomic mShutdownRequested{ false }; std::atomic mNextScrollCommand{ 0 }; + std::atomic mLatestSearchRequest{ 0 }; }; }} // namespace eterm::Terminal diff --git a/src/modules/eterm/include/eterm/terminal/terminaltypes.hpp b/src/modules/eterm/include/eterm/terminal/terminaltypes.hpp index 24be27514..1a81a3278 100644 --- a/src/modules/eterm/include/eterm/terminal/terminaltypes.hpp +++ b/src/modules/eterm/include/eterm/terminal/terminaltypes.hpp @@ -135,6 +135,8 @@ enum TerminalGlyphAttribute { ATTR_WDUMMY = 1 << 10, ATTR_BOXDRAW = 1 << 11, ATTR_EMOJI = 1 << 12, + ATTR_SEARCH_MATCH = 1 << 13, + ATTR_SEARCH_ACTIVE = 1 << 14, ATTR_BOLD_FAINT = ATTR_BOLD | ATTR_FAINT, }; diff --git a/src/modules/eterm/include/eterm/ui/uiterminal.hpp b/src/modules/eterm/include/eterm/ui/uiterminal.hpp index c43f28e4b..ec30aeec1 100644 --- a/src/modules/eterm/include/eterm/ui/uiterminal.hpp +++ b/src/modules/eterm/include/eterm/ui/uiterminal.hpp @@ -12,6 +12,8 @@ using namespace eterm::Terminal; namespace eterm { namespace UI { +class UITerminalFind; + class UITerminal : public UIWidget { public: static UITerminal* New( Font* font, const Float& fontSize, const Sizef& pixelsSize, @@ -135,6 +137,7 @@ class UITerminal : public UIWidget { Clock mMouseClock; std::shared_ptr mTerm; Uint32 mTerminalEventCallbackId{ 0 }; + UITerminalFind* mFindBar{ nullptr }; UITerminal( const std::shared_ptr& terminalDisplay ); diff --git a/src/modules/eterm/include/eterm/ui/uiterminalfind.hpp b/src/modules/eterm/include/eterm/ui/uiterminalfind.hpp new file mode 100644 index 000000000..c1c6b8eab --- /dev/null +++ b/src/modules/eterm/include/eterm/ui/uiterminalfind.hpp @@ -0,0 +1,59 @@ +#ifndef ETERM_UI_UITERMINALFIND_HPP +#define ETERM_UI_UITERMINALFIND_HPP + +#include +#include +#include +#include + +using namespace EE; +using namespace EE::UI; + +namespace eterm { namespace UI { + +class UITerminal; + +class UITerminalFind : public UILinearLayout { + public: + static UITerminalFind* New( UITerminal* terminal ); + + void show(); + + void hide(); + + void refreshStatus(); + + protected: + explicit UITerminalFind( UITerminal* terminal ); + + Uint32 onKeyDown( const KeyEvent& event ); + + Uint32 onKeyUp( const KeyEvent& event ); + + Uint32 onTextInput( const TextInputEvent& event ); + + Uint32 onTextEditing( const TextEditingEvent& event ); + + private: + void updateQuery(); + + void submitQuery(); + + void navigateSearch( int direction ); + + UITerminal* mTerminal{ nullptr }; + UITextInput* mInput{ nullptr }; + UISelectButton* mMatchCase{ nullptr }; + UISelectButton* mWholeWord{ nullptr }; + UISelectButton* mRegEx{ nullptr }; + UISelectButton* mLuaPattern{ nullptr }; + UITextView* mStatus{ nullptr }; + Uint64 mRequestId{ 0 }; + bool mReady{ false }; + bool mChangingPattern{ false }; + bool mQueryPending{ false }; +}; + +}} // namespace eterm::UI + +#endif diff --git a/src/modules/eterm/src/eterm/terminal/terminaldisplay.cpp b/src/modules/eterm/src/eterm/terminal/terminaldisplay.cpp index 40bc5fc7e..58fe6554e 100644 --- a/src/modules/eterm/src/eterm/terminal/terminaldisplay.cpp +++ b/src/modules/eterm/src/eterm/terminal/terminaldisplay.cpp @@ -629,6 +629,33 @@ const std::shared_ptr& TerminalDisplay::getSession() const { return mSession; } +void TerminalDisplay::setSearchQuery( TerminalSearchQuery query ) { + if ( mSession ) + mSession->setSearchQuery( std::move( query ) ); +} + +void TerminalDisplay::navigateSearch( int direction ) { + if ( mSession ) + mSession->navigateSearch( direction ); +} + +void TerminalDisplay::clearSearch() { + if ( mSession ) + mSession->clearSearch(); +} + +Uint32 TerminalDisplay::getSearchMatchCount() const { + return mSnapshot ? mSnapshot->searchMatchCount : 0; +} + +Int32 TerminalDisplay::getCurrentSearchMatch() const { + return mSnapshot ? mSnapshot->currentSearchMatch : -1; +} + +Uint64 TerminalDisplay::getSearchRequestId() const { + return mSnapshot ? mSnapshot->searchRequestId : 0; +} + int TerminalDisplay::scrollSize() const { return mSnapshot ? mSnapshot->historyLength : 0; } @@ -1444,6 +1471,11 @@ void TerminalDisplay::drawGrid( const Vector2f& pos ) { if ( glyph.mode & ATTR_REVERSE ) bg = fg; + if ( glyph.mode & ( ATTR_SEARCH_MATCH | ATTR_SEARCH_ACTIVE ) ) { + Color highlight = mColorScheme.getCursor(); + highlight.a = glyph.mode & ATTR_SEARCH_ACTIVE ? 190 : 90; + bg = Color::blend( highlight, bg ); + } bool isWide = glyph.mode & ATTR_WIDE; @@ -1498,6 +1530,11 @@ void TerminalDisplay::drawGrid( const Vector2f& pos ) { } if ( glyph.mode & ATTR_REVERSE ) background = foreground; + if ( glyph.mode & ( ATTR_SEARCH_MATCH | ATTR_SEARCH_ACTIVE ) ) { + Color highlight = mColorScheme.getCursor(); + highlight.a = glyph.mode & ATTR_SEARCH_ACTIVE ? 190 : 90; + background = Color::blend( highlight, background ); + } const bool wide = glyph.mode & ATTR_WIDE; const Float advance = spaceCharAdvanceX * ( wide ? 2.0f : 1.0f ); if ( background != defaultBg ) { @@ -1555,6 +1592,11 @@ void TerminalDisplay::drawGrid( const Vector2f& pos ) { fg = bg; bg = temp; } + if ( glyph.mode & ( ATTR_SEARCH_MATCH | ATTR_SEARCH_ACTIVE ) ) { + Color highlight = mColorScheme.getCursor(); + highlight.a = glyph.mode & ATTR_SEARCH_ACTIVE ? 190 : 90; + bg = Color::blend( highlight, bg ); + } if ( glyph.mode & ATTR_BLINK && ( mMode & MODE_BLINK ) ) fg = bg; diff --git a/src/modules/eterm/src/eterm/terminal/terminalemulator.cpp b/src/modules/eterm/src/eterm/terminal/terminalemulator.cpp index 7025a81f5..a4b8bb6fa 100644 --- a/src/modules/eterm/src/eterm/terminal/terminalemulator.cpp +++ b/src/modules/eterm/src/eterm/terminal/terminalemulator.cpp @@ -586,6 +586,99 @@ int TerminalEmulator::tlinelen( Line line, int col ) const { return i; } +void TerminalEmulator::setSearchQuery( TerminalSearchQuery query ) { + TerminalSearchMatch previousMatch; + const bool preserveCurrent = + query.requestId == mSearchQuery.requestId && mCurrentSearchMatch >= 0 && + mCurrentSearchMatch < static_cast( mSearch.matches().size() ); + if ( preserveCurrent ) + previousMatch = mSearch.matches()[mCurrentSearchMatch]; + mSearchQuery = std::move( query ); + mSearchDirty = false; + mSearchRefreshClock.restart(); + mSearchRows.clear(); + if ( !TerminalSearch::isQuerySearchable( mSearchQuery ) ) { + mSearch.search( mSearchRows, mSearchQuery ); + mCurrentSearchMatch = -1; + redraw(); + return; + } + if ( tisaltscr() ) { + mSearchRows.reserve( mTerm.row ); + for ( int row = 0; row < mTerm.row; ++row ) { + const int length = tlinelen( mTerm.line[row], mTerm.col ); + mSearchRows.push_back( + { mTerm.line[row], TerminalBufferSource::AlternateScreen, row, mTerm.col, length, + length > 0 && ( mTerm.line[row][length - 1].mode & ATTR_WRAP ) } ); + } + } else { + mSearchRows.reserve( mTerm.histlen + mTerm.row ); + for ( int row = -mTerm.histlen; row < mTerm.row; ++row ) { + Line line = + row < 0 ? mTerm.hist[( row + mTerm.histi + mTerm.histsize + 1 ) % mTerm.histsize] + : mTerm.line[row]; + const int length = tlinelen( line, mTerm.col ); + mSearchRows.push_back( + { line, + row < 0 ? TerminalBufferSource::MainHistory : TerminalBufferSource::MainScreen, + row, mTerm.col, length, length > 0 && ( line[length - 1].mode & ATTR_WRAP ) } ); + } + } + mSearch.search( mSearchRows, mSearchQuery ); + mCurrentSearchMatch = mSearch.matches().empty() ? -1 : 0; + if ( preserveCurrent ) { + const auto& matches = mSearch.matches(); + for ( size_t index = 0; index < matches.size(); ++index ) { + if ( matches[index].start.source == previousMatch.start.source && + matches[index].start.row == previousMatch.start.row && + matches[index].start.column == previousMatch.start.column ) { + mCurrentSearchMatch = static_cast( index ); + break; + } + } + } + if ( mCurrentSearchMatch >= 0 ) + navigateSearch( 0 ); + else + redraw(); +} + +void TerminalEmulator::navigateSearch( int direction ) { + const auto& matches = mSearch.matches(); + if ( matches.empty() ) + return; + if ( direction != 0 ) { + mCurrentSearchMatch = + ( mCurrentSearchMatch + direction + static_cast( matches.size() ) ) % + static_cast( matches.size() ); + } + const auto& match = matches[mCurrentSearchMatch]; + if ( match.start.source == TerminalBufferSource::MainHistory ) { + TerminalArg scroll( eeclamp( static_cast( -match.start.row ), 0, mTerm.histlen ) ); + kscrollto( &scroll ); + } else if ( match.start.source == TerminalBufferSource::MainScreen && mTerm.scr != 0 ) { + TerminalArg scroll( 0 ); + kscrollto( &scroll ); + } + redraw(); +} + +void TerminalEmulator::clearSearch() { + mSearchQuery = {}; + mSearch.search( {}, mSearchQuery ); + mCurrentSearchMatch = -1; + mSearchDirty = false; + redraw(); +} + +const std::vector& TerminalEmulator::getSearchMatches() const { + return mSearch.matches(); +} + +Int32 TerminalEmulator::getCurrentSearchMatch() const { + return mCurrentSearchMatch; +} + int TerminalEmulator::tiswrapped( int y ) { int len = tlinelen( y ); @@ -4233,11 +4326,15 @@ void TerminalEmulator::resize( int columns, int rows, int pixelWidth, int pixelH return; } tresize( columns, rows ); + if ( !mSearchQuery.text.empty() ) + setSearchQuery( mSearchQuery ); redraw(); return; } tresize( columns, rows ); + if ( !mSearchQuery.text.empty() ) + setSearchQuery( mSearchQuery ); redraw(); mPendingPtyColumns = columns; @@ -4294,6 +4391,11 @@ bool TerminalEmulator::update() { bool readBudgetSaturated = reads == MAX_TTY_READS || presentationDeadlineReached || ( reads > 0 && readBudgetClock.getElapsedTime() >= Milliseconds( 4 ) ); + if ( reads > 0 && TerminalSearch::isQuerySearchable( mSearchQuery ) ) + mSearchDirty = true; + if ( mSearchDirty && + ( !readBudgetSaturated || mSearchRefreshClock.getElapsedTime() >= Milliseconds( 100 ) ) ) + setSearchQuery( mSearchQuery ); /* Keep presentation decoupled from every PTY read batch. Sustained output publishes on the * host frame deadline, while a drained/idle burst still publishes immediately. */ diff --git a/src/modules/eterm/src/eterm/terminal/terminalsearch.cpp b/src/modules/eterm/src/eterm/terminal/terminalsearch.cpp new file mode 100644 index 000000000..69e833f25 --- /dev/null +++ b/src/modules/eterm/src/eterm/terminal/terminalsearch.cpp @@ -0,0 +1,154 @@ +#include + +#include +#include + +#include + +using namespace EE::System; + +namespace eterm { namespace Terminal { + +static bool isWordCharacter( Rune rune ) { + return rune == '_' || String::isAlphaNum( rune ); +} + +template +static void searchPattern( PatternMatcher& pattern, const std::string& text, + const String& logicalLine, const Positions& positions, bool wholeWord, + std::vector& matches ) { + if ( !pattern.isValid() ) + return; + + PatternMatcher::Range ranges[12]; + int offset = 0; + size_t byteOffset = 0; + size_t codepointOffset = 0; + auto codepointPosition = [&]( size_t targetByteOffset ) { + while ( byteOffset < targetByteOffset ) { + if ( ( static_cast( text[byteOffset] ) & 0xC0 ) != 0x80 ) + ++codepointOffset; + ++byteOffset; + } + return codepointOffset; + }; + while ( offset <= static_cast( text.size() ) && pattern.matches( text, ranges, offset ) ) { + const int start = ranges[0].start; + const int end = ranges[0].end; + if ( start < 0 || end < start || end > static_cast( text.size() ) ) + break; + + const size_t codepointStart = codepointPosition( start ); + const size_t codepointEnd = codepointPosition( end ); + if ( codepointStart < codepointEnd && codepointEnd <= positions.size() && + ( !wholeWord || + ( ( codepointStart == 0 || !isWordCharacter( logicalLine[codepointStart - 1] ) ) && + ( codepointEnd == logicalLine.size() || + !isWordCharacter( logicalLine[codepointEnd] ) ) ) ) ) { + matches.push_back( + { positions[codepointStart].start, positions[codepointEnd - 1].end } ); + } + + if ( end > offset ) { + offset = end; + } else if ( offset < static_cast( text.size() ) ) { + do { + ++offset; + } while ( offset < static_cast( text.size() ) && + ( static_cast( text[offset] ) & 0xC0 ) == 0x80 ); + } else { + break; + } + } +} + +void TerminalSearch::searchLogicalLine( const TerminalSearchQuery& query, PatternMatcher* pattern, + const String& needle ) { + if ( mText.empty() ) + return; + + if ( pattern ) { + mText.toUtf8( mUtf8Text ); + searchPattern( *pattern, mUtf8Text, mText, mPositions, query.wholeWord, mMatches ); + return; + } + + const String* comparableText = &mText; + if ( !query.caseSensitive ) { + mComparableText = mText; + mComparableText.toLower(); + comparableText = &mComparableText; + } + if ( needle.empty() || needle.size() > comparableText->size() ) + return; + + size_t offset = 0; + while ( ( offset = comparableText->find( needle, offset ) ) != String::InvalidPos ) { + if ( query.wholeWord && ( ( offset > 0 && isWordCharacter( mText[offset - 1] ) ) || + ( offset + needle.size() < mText.size() && + isWordCharacter( mText[offset + needle.size()] ) ) ) ) { + ++offset; + continue; + } + mMatches.push_back( + { mPositions[offset].start, mPositions[offset + needle.size() - 1].end } ); + ++offset; + } +} + +const std::vector& +TerminalSearch::search( const std::vector& rows, + const TerminalSearchQuery& query ) { + mMatches.clear(); + mText.clear(); + mPositions.clear(); + if ( !isQuerySearchable( query ) ) + return mMatches; + + String needle; + if ( query.type == TerminalSearchType::Normal ) { + needle = query.text; + if ( !query.caseSensitive ) + needle.toLower(); + } + std::string patternText; + if ( query.type != TerminalSearchType::Normal ) + query.text.toUtf8( patternText ); + std::optional regex; + std::optional luaPattern; + PatternMatcher* pattern = nullptr; + if ( query.type == TerminalSearchType::RegEx ) { + regex.emplace( patternText, static_cast( + RegEx::Options::Utf | RegEx::Options::AllowFallback | + ( query.caseSensitive ? RegEx::Options::None + : RegEx::Options::Caseless ) ) ); + pattern = &*regex; + } else if ( query.type == TerminalSearchType::LuaPattern ) { + luaPattern.emplace( std::move( patternText ) ); + pattern = &*luaPattern; + } + + for ( const auto& row : rows ) { + if ( !row.cells || row.length < 0 || row.width < row.length ) + continue; + for ( Int32 column = 0; column < row.length; ++column ) { + const auto& glyph = row.cells[column]; + if ( glyph.mode & ATTR_WDUMMY ) + continue; + mText += glyph.u; + mPositions.push_back( + { { row.source, row.row, column }, + { row.source, row.row, column + ( ( glyph.mode & ATTR_WIDE ) ? 1 : 0 ) } } ); + } + if ( !row.wrapped ) { + searchLogicalLine( query, pattern, needle ); + mText.clear(); + mPositions.clear(); + } + } + if ( !mText.empty() ) + searchLogicalLine( query, pattern, needle ); + return mMatches; +} + +}} // namespace eterm::Terminal diff --git a/src/modules/eterm/src/eterm/terminal/terminalsession.cpp b/src/modules/eterm/src/eterm/terminal/terminalsession.cpp index f68079a53..7a01899f4 100644 --- a/src/modules/eterm/src/eterm/terminal/terminalsession.cpp +++ b/src/modules/eterm/src/eterm/terminal/terminalsession.cpp @@ -102,6 +102,36 @@ class TerminalSession::WorkerDisplay final : public ITerminalDisplay { snapshot->exitCode = mEmulator->getExitCode(); snapshot->currentWorkingDirectory = mEmulator->getCurrentWorkingDirectory(); snapshot->promptState = mEmulator->getPromptState(); + const auto& searchMatches = mEmulator->getSearchMatches(); + snapshot->searchMatchCount = static_cast( searchMatches.size() ); + snapshot->currentSearchMatch = mEmulator->getCurrentSearchMatch(); + snapshot->searchRequestId = mEmulator->getSearchRequestId(); + for ( size_t index = 0; index < searchMatches.size(); ++index ) { + const auto& match = searchMatches[index]; + for ( Int64 row = match.start.row; row <= match.end.row; ++row ) { + const int visibleRow = + static_cast( row ) + + ( match.start.source == TerminalBufferSource::AlternateScreen + ? 0 + : snapshot->scrollPosition ); + if ( visibleRow < 0 || visibleRow >= snapshot->rows ) + continue; + const int startColumn = row == match.start.row ? match.start.column : 0; + const int endColumn = + row == match.end.row ? match.end.column : snapshot->columns - 1; + snapshot->visibleSearchMatches.push_back( + { { startColumn, visibleRow }, + { endColumn, visibleRow }, + static_cast( index ) == snapshot->currentSearchMatch } ); + for ( int column = std::max( 0, startColumn ); + column <= std::min( snapshot->columns - 1, endColumn ); ++column ) { + auto& glyph = snapshot->cells[visibleRow * snapshot->columns + column]; + glyph.mode |= static_cast( index ) == snapshot->currentSearchMatch + ? ATTR_SEARCH_ACTIVE + : ATTR_SEARCH_MATCH; + } + } + } if ( auto* process = mEmulator->getProcess() ) snapshot->processId = process->pid(); } @@ -415,6 +445,20 @@ void TerminalSession::requestGraphicsResync() { enqueue( GraphicsResyncCommand{} ); } +void TerminalSession::setSearchQuery( TerminalSearchQuery query ) { + mLatestSearchRequest.store( query.requestId, std::memory_order_release ); + enqueue( SearchQueryCommand{ std::move( query ) } ); +} + +void TerminalSession::navigateSearch( int direction ) { + enqueue( SearchNavigateCommand{ direction < 0 ? -1 : 1 } ); +} + +void TerminalSession::clearSearch() { + mLatestSearchRequest.store( 0, std::memory_order_release ); + enqueue( SearchClearCommand{} ); +} + std::shared_ptr TerminalSession::snapshot() const { std::lock_guard lock( mPublishedSnapshotMutex ); return mPublishedSnapshot; @@ -606,6 +650,14 @@ void TerminalSession::processCommand( Command&& command ) { } else if constexpr ( std::is_same_v ) { mGraphicsUpdates.resetResync(); mEmulator->requestGraphicsResync(); + } else if constexpr ( std::is_same_v ) { + if ( value.query.requestId == + mLatestSearchRequest.load( std::memory_order_acquire ) ) + mEmulator->setSearchQuery( std::move( value.query ) ); + } else if constexpr ( std::is_same_v ) { + mEmulator->navigateSearch( value.direction ); + } else if constexpr ( std::is_same_v ) { + mEmulator->clearSearch(); } }, std::move( command ) ); diff --git a/src/modules/eterm/src/eterm/ui/uiterminal.cpp b/src/modules/eterm/src/eterm/ui/uiterminal.cpp index 95854f9b9..4ca7309f8 100644 --- a/src/modules/eterm/src/eterm/ui/uiterminal.cpp +++ b/src/modules/eterm/src/eterm/ui/uiterminal.cpp @@ -9,6 +9,7 @@ #include #include #include +#include using namespace EE::Scene; @@ -112,6 +113,11 @@ UITerminal::UITerminal( const std::shared_ptr& terminalDisplay setCommand( "terminal-paste-selection", [this] { mTerm->action( TerminalShortcutAction::PASTE_SELECTION ); } ); setCommand( "terminal-copy", [this] { mTerm->action( TerminalShortcutAction::COPY ); } ); + mFindBar = UITerminalFind::New( this ); + setCommand( "terminal-find", [this] { mFindBar->show(); } ); + setCommand( "terminal-find-next", [this] { mTerm->navigateSearch( 1 ); } ); + setCommand( "terminal-find-previous", [this] { mTerm->navigateSearch( -1 ); } ); + setCommand( "terminal-find-close", [this] { mFindBar->hide(); } ); mKeyBindings.addKeybind( { KEY_V, KEYMOD_CTRL | KEYMOD_SHIFT }, "terminal-paste" ); mKeyBindings.addKeybind( { KEY_C, KEYMOD_CTRL | KEYMOD_SHIFT }, "terminal-copy" ); setCommand( "terminal-open-link", @@ -345,6 +351,8 @@ void UITerminal::scheduledUpdate( const Time& ) { bool mouseOutsideBounds = mousePos.y < 0 || mousePos.y > getUISceneNode()->getWindow()->getSize().getHeight(); terminal->update( isMouseOverMeOrChildren() && !mouseOutsideBounds ); + if ( mFindBar && mFindBar->isVisible() ) + mFindBar->refreshStatus(); if ( !mVScroll->isDragging() && ( mScrollByBar || mPendingContentSizeChange ) ) { updateScrollPosition(); if ( !mScrollByBar && mPendingContentSizeChange ) @@ -546,7 +554,7 @@ Uint32 UITerminal::onMouseDoubleClick( const Vector2i& position, const Uint32& f } Uint32 UITerminal::onMouseUp( const Vector2i& position, const Uint32& flags ) { - if ( flags & EE_BUTTON_RMASK ) { + if ( ( flags & EE_BUTTON_RMASK ) && !mTerm->isAppCapturingMouse() ) { onCreateContextMenu( position, flags ); return 1; } @@ -567,6 +575,9 @@ void UITerminal::onSizeChange() { ( mViewType == ScrollViewType::Outside ? mVScroll->getPixelsSize().getWidth() : 0.f ), mPaddingPx.Bottom } ); onContentSizeChange(); + if ( mFindBar && mFindBar->isVisible() ) + mFindBar->setPosition( eemax( 0.f, getSize().getWidth() - mFindBar->getSize().getWidth() ), + 0 ); UIWidget::onSizeChange(); } @@ -604,6 +615,8 @@ void UITerminal::createDefaultContextMenuOptions( UIPopUpMenu* menu ) { ->setEnabled( mTerm->hasSelection() ); menuAdd( menu, i18n( "uiterminal_paste", "Paste" ), "paste", "terminal-paste" ) ->setEnabled( !getUISceneNode()->getWindow()->getClipboard()->getText().empty() ); + menu->addSeparator(); + menuAdd( menu, i18n( "uiterminal_find", "Find..." ), "search", "terminal-find" ); } DrawablePtr UITerminal::findIcon( const std::string& name ) { diff --git a/src/modules/eterm/src/eterm/ui/uiterminalfind.cpp b/src/modules/eterm/src/eterm/ui/uiterminalfind.cpp new file mode 100644 index 000000000..56a3a035d --- /dev/null +++ b/src/modules/eterm/src/eterm/ui/uiterminalfind.cpp @@ -0,0 +1,209 @@ +#include + +#include +#include +#include +#include +#include + +using namespace EE::Scene; +using namespace EE::UI::Tools; +using namespace eterm::Terminal; + +namespace eterm { namespace UI { + +static constexpr auto SEARCH_DEBOUNCE_TAG = String::hash( "UITerminalFind::search" ); + +static constexpr char FIND_LAYOUT[] = R"xml( + + + + + + + + + + + + + + + + + + + + +)xml"; + +UITerminalFind* UITerminalFind::New( UITerminal* terminal ) { + return eeNew( UITerminalFind, ( terminal ) ); +} + +UITerminalFind::UITerminalFind( UITerminal* terminal ) : + UILinearLayout( "terminalfind", UIOrientation::Horizontal ), mTerminal( terminal ) { + mFlags |= UI_OWNS_CHILDREN_POSITION; + UIFindBarStyle::ensure( terminal->getUISceneNode() ); + terminal->getUISceneNode()->loadLayoutFromMemory( FIND_LAYOUT, sizeof( FIND_LAYOUT ), this ); + setParent( terminal ); + mInput = querySelector( ".ce_find_replace_box .input-find" ); + mMatchCase = querySelector( ".ce_find_replace_box .match-case" ); + mWholeWord = querySelector( ".ce_find_replace_box .whole-word" ); + mRegEx = querySelector( ".ce_find_replace_box .regex" ); + mLuaPattern = querySelector( ".ce_find_replace_box .luapattern" ); + mStatus = querySelector( ".ce_find_replace_box .status" ); + mInput->on( Event::OnTextChanged, [this]( const Event* ) { updateQuery(); } ); + mInput->on( Event::OnPressEnter, [this]( const Event* ) { navigateSearch( 1 ); } ); + mMatchCase->on( Event::OnValueChange, [this]( const Event* ) { updateQuery(); } ); + mWholeWord->on( Event::OnValueChange, [this]( const Event* ) { updateQuery(); } ); + mRegEx->on( Event::OnValueChange, [this]( const Event* ) { + if ( mChangingPattern ) + return; + mChangingPattern = true; + mLuaPattern->setSelected( false ); + mChangingPattern = false; + updateQuery(); + } ); + mLuaPattern->on( Event::OnValueChange, [this]( const Event* ) { + if ( mChangingPattern ) + return; + mChangingPattern = true; + mRegEx->setSelected( false ); + mChangingPattern = false; + updateQuery(); + } ); + querySelector( ".ce_find_replace_box .prev-button" ) + ->on( Event::MouseClick, [this]( const Event* event ) { + if ( event->asMouseEvent()->getFlags() & EE_BUTTON_LMASK ) + navigateSearch( -1 ); + } ); + querySelector( ".ce_find_replace_box .next-button" ) + ->on( Event::MouseClick, [this]( const Event* event ) { + if ( event->asMouseEvent()->getFlags() & EE_BUTTON_LMASK ) + navigateSearch( 1 ); + } ); + querySelector( ".ce_find_replace_box .exit-button" ) + ->on( Event::MouseClick, [this]( const Event* event ) { + if ( event->asMouseEvent()->getFlags() & EE_BUTTON_LMASK ) + hide(); + } ); + setVisible( false ); + runOnMainThread( [this] { mReady = true; } ); +} + +void UITerminalFind::show() { + if ( !mReady ) { + runOnMainThread( [this] { show(); } ); + return; + } + if ( !isVisible() ) { + setVisible( true ); + const Float startX = eemax( 0.f, mTerminal->getSize().getWidth() - getSize().getWidth() ); + setPosition( startX, -getSize().getHeight() ); + runAction( Actions::Move::New( { startX, getPosition().y }, { startX, 0 }, Seconds( 0.2f ), + Ease::QuadraticIn ) ); + } + mInput->getDocument().selectAll(); + mInput->setFocus(); + if ( !mInput->getText().empty() ) + updateQuery(); +} + +void UITerminalFind::hide() { + mInput->removeActionsByTag( SEARCH_DEBOUNCE_TAG ); + mQueryPending = false; + runAction( Actions::Sequence::New( + Actions::Move::New( getPosition(), { getPosition().x, -getSize().getHeight() }, + Seconds( 0.2f ), Ease::QuadraticOut ), + Actions::Visible::New( false ) ) ); + mTerminal->getTerm()->clearSearch(); + mTerminal->setFocus(); +} + +void UITerminalFind::updateQuery() { + mInput->removeActionsByTag( SEARCH_DEBOUNCE_TAG ); + if ( mInput->getText().size() < TerminalSearch::MinimumQueryLength ) { + submitQuery(); + return; + } + mQueryPending = true; + mInput->debounce( [this] { submitQuery(); }, Milliseconds( 150 ), SEARCH_DEBOUNCE_TAG ); +} + +void UITerminalFind::submitQuery() { + mInput->removeActionsByTag( SEARCH_DEBOUNCE_TAG ); + mQueryPending = false; + TerminalSearchType type = TerminalSearchType::Normal; + if ( mRegEx->isSelected() ) + type = TerminalSearchType::RegEx; + else if ( mLuaPattern->isSelected() ) + type = TerminalSearchType::LuaPattern; + mTerminal->getTerm()->setSearchQuery( { mInput->getText(), ++mRequestId, + mMatchCase->isSelected(), mWholeWord->isSelected(), + type } ); +} + +void UITerminalFind::navigateSearch( int direction ) { + if ( mQueryPending ) { + submitQuery(); + return; + } + mTerminal->getTerm()->navigateSearch( direction ); +} + +void UITerminalFind::refreshStatus() { + if ( mTerminal->getTerm()->getSearchRequestId() != mRequestId ) + return; + const Uint32 count = mTerminal->getTerm()->getSearchMatchCount(); + const Int32 current = mTerminal->getTerm()->getCurrentSearchMatch(); + mStatus->setText( String::format( "%d/%u", current >= 0 ? current + 1 : 0, count ) ); + if ( count == 0 && !mInput->getText().empty() ) + mInput->addClass( "error" ); + else + mInput->removeClass( "error" ); +} + +Uint32 UITerminalFind::onKeyDown( const KeyEvent& event ) { + if ( event.getKeyCode() == KEY_ESCAPE ) { + hide(); + return 1; + } + if ( event.getKeyCode() == KEY_RETURN || event.getKeyCode() == KEY_KP_ENTER ) { + navigateSearch( event.getMod() & KEYMOD_SHIFT ? -1 : 1 ); + return 1; + } + const std::string command = + mTerminal->getKeyBindings().getCommandFromKeyBind( { event.getKeyCode(), event.getMod() } ); + if ( command == "terminal-find-next" ) + navigateSearch( 1 ); + else if ( command == "terminal-find-previous" ) + navigateSearch( -1 ); + else if ( command == "terminal-find-close" ) + hide(); + else if ( command == "terminal-find" ) + show(); + return 1; +} + +Uint32 UITerminalFind::onKeyUp( const KeyEvent& ) { + return 1; +} + +Uint32 UITerminalFind::onTextInput( const TextInputEvent& ) { + return 1; +} + +Uint32 UITerminalFind::onTextEditing( const TextEditingEvent& ) { + return 1; +} + +}} // namespace eterm::UI diff --git a/src/tests/unit_tests/eterm_tests.cpp b/src/tests/unit_tests/eterm_tests.cpp index e4926bb05..546d89649 100644 --- a/src/tests/unit_tests/eterm_tests.cpp +++ b/src/tests/unit_tests/eterm_tests.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -1343,8 +1344,12 @@ UTEST( eterm, sgr_pixel_mouse_mode_uses_grid_relative_pixels ) { term->update(); term->mousereport( TerminalMouseEventType::MouseButtonDown, { 2, 3 }, { 20, 30 }, EE_BUTTON_LMASK, 0 ); + term->mousereport( TerminalMouseEventType::MouseButtonDown, { 2, 3 }, { 20, 30 }, + EE_BUTTON_RMASK, 0 ); + term->mousereport( TerminalMouseEventType::MouseButtonRelease, { 2, 3 }, { 20, 30 }, + EE_BUTTON_RMASK, 0 ); - EXPECT_STDSTREQ( "\033[<0;21;31M", ptyPtr->mWrites ); + EXPECT_STDSTREQ( "\033[<0;21;31M\033[<2;21;31M\033[<2;21;31m", ptyPtr->mWrites ); } UTEST( eterm, cursor_style_zero_uses_blinking_configured_shape ) { @@ -2327,3 +2332,89 @@ UTEST( eterm, history_corruption_on_resize ) { EXPECT_STDSTREQ( expected_lines[expected_idx], sel ); } } + +UTEST( eterm_search, logical_lines_options_and_cell_mapping ) { + TerminalGlyph first[] = { { 'H' }, { 'e' }, { 'l' }, { 'l' }, { 'o', ATTR_WRAP } }; + TerminalGlyph second[] = { { 'W' }, { 'o' }, { 'r' }, { 'l' }, { 'd' } }; + TerminalGlyph wide[] = { { 0x754C, ATTR_WIDE }, { 0, ATTR_WDUMMY }, { '!' } }; + std::vector rows{ + { first, TerminalBufferSource::MainHistory, -1, 5, 5, true }, + { second, TerminalBufferSource::MainScreen, 0, 5, 5, false }, + { wide, TerminalBufferSource::MainScreen, 1, 3, 3, false }, + }; + TerminalSearch search; + EXPECT_TRUE( search.search( rows, { "o", 0, true, false } ).empty() ); + + auto matches = search.search( rows, { "lowo", 1, false, false } ); + ASSERT_EQ( static_cast( 1 ), matches.size() ); + EXPECT_EQ( static_cast( -1 ), matches[0].start.row ); + EXPECT_EQ( 3, matches[0].start.column ); + EXPECT_EQ( static_cast( 0 ), matches[0].end.row ); + EXPECT_EQ( 1, matches[0].end.column ); + + EXPECT_TRUE( search.search( rows, { "hello", 2, true, true } ).empty() ); + EXPECT_EQ( static_cast( 1 ), + search.search( rows, { "HELLOWORLD", 3, false, true } ).size() ); + + String unicodeQuery; + unicodeQuery += static_cast( 0x754C ); + unicodeQuery += '!'; + matches = search.search( rows, { unicodeQuery, 4, true, false } ); + ASSERT_EQ( static_cast( 1 ), matches.size() ); + EXPECT_EQ( 0, matches[0].start.column ); + EXPECT_EQ( 2, matches[0].end.column ); + + matches = search.search( rows, { "l+oW.rld", 5, true, false, TerminalSearchType::RegEx } ); + ASSERT_EQ( static_cast( 1 ), matches.size() ); + EXPECT_EQ( 2, matches[0].start.column ); + EXPECT_EQ( static_cast( 0 ), matches[0].end.row ); + EXPECT_EQ( 4, matches[0].end.column ); + + matches = search.search( rows, { "界!", 6, true, false, TerminalSearchType::RegEx } ); + ASSERT_EQ( static_cast( 1 ), matches.size() ); + EXPECT_EQ( 0, matches[0].start.column ); + EXPECT_EQ( 2, matches[0].end.column ); + + matches = search.search( rows, { "[Ww]%a+d", 7, true, false, TerminalSearchType::LuaPattern } ); + ASSERT_EQ( static_cast( 1 ), matches.size() ); + EXPECT_EQ( 0, matches[0].start.column ); + EXPECT_EQ( 4, matches[0].end.column ); + + EXPECT_TRUE( + search.search( rows, { "[", 8, true, false, TerminalSearchType::RegEx } ).empty() ); + EXPECT_EQ( static_cast( 1 ), + search.search( rows, { "helloworld", 9, false, false, TerminalSearchType::RegEx } ) + .size() ); +} + +UTEST( eterm_search, emulator_history_navigation_and_clear ) { + auto pty = std::make_unique(); + auto process = std::make_unique(); + auto display = std::make_shared(); + auto term = TerminalEmulator::create( std::move( pty ), std::move( process ), display, 100 ); + ASSERT_TRUE( term != nullptr ); + + for ( int line = 0; line < 40; ++line ) { + const std::string text = "line " + std::to_string( line ) + " needle\r\n"; + term->write( text.data(), text.size() ); + term->update(); + } + term->setSearchQuery( { "n", 41, true, false } ); + EXPECT_TRUE( term->getSearchMatches().empty() ); + EXPECT_EQ( -1, term->getCurrentSearchMatch() ); + EXPECT_EQ( static_cast( 41 ), term->getSearchRequestId() ); + + term->setSearchQuery( { "needle", 42, true, true } ); + ASSERT_EQ( static_cast( 40 ), term->getSearchMatches().size() ); + EXPECT_EQ( 0, term->getCurrentSearchMatch() ); + EXPECT_TRUE( term->scrollPos() > 0 ); + + term->navigateSearch( -1 ); + EXPECT_EQ( 39, term->getCurrentSearchMatch() ); + EXPECT_EQ( 0, term->scrollPos() ); + term->navigateSearch( 1 ); + EXPECT_EQ( 0, term->getCurrentSearchMatch() ); + term->clearSearch(); + EXPECT_TRUE( term->getSearchMatches().empty() ); + EXPECT_EQ( -1, term->getCurrentSearchMatch() ); +} diff --git a/src/tests/unit_tests/stringsoperations_tests.cpp b/src/tests/unit_tests/stringsoperations_tests.cpp index ff60e6802..1c0f10770 100644 --- a/src/tests/unit_tests/stringsoperations_tests.cpp +++ b/src/tests/unit_tests/stringsoperations_tests.cpp @@ -100,6 +100,13 @@ UTEST( String, reusableFormattingAndUtf8Assignment ) { const std::string utf8Text = text.toUtf8(); EXPECT_STREQ( "áβ中", utf8Text.c_str() ); EXPECT_EQ( textStorage, text.getString().data() ); + + std::string reusableUtf8; + reusableUtf8.reserve( 128 ); + const char* utf8Storage = reusableUtf8.data(); + text.toUtf8( reusableUtf8 ); + EXPECT_STREQ( "áβ中", reusableUtf8.c_str() ); + EXPECT_EQ( utf8Storage, reusableUtf8.data() ); } UTEST( FileSystem, fileCountLines ) { diff --git a/src/tools/ecode/terminalmanager.cpp b/src/tools/ecode/terminalmanager.cpp index e3a501bd1..f9d6d9fab 100644 --- a/src/tools/ecode/terminalmanager.cpp +++ b/src/tools/ecode/terminalmanager.cpp @@ -133,6 +133,10 @@ void TerminalManager::loadTerminalColorSchemes() { KeyBindings::ShortcutMap TerminalManager::getTerminalKeybindings() { return { + { { KEY_F, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "terminal-find" }, + { { KEY_G, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "terminal-find-next" }, + { { KEY_G, KeyMod::getDefaultModifier() | KEYMOD_SHIFT | KEYMOD_ALT }, + "terminal-find-previous" }, { { KEY_T, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "create-new-terminal" }, { { KEY_E, KeyMod::getDefaultModifier() | KeyMod::getDefaultSecondaryModifier() | KEYMOD_SHIFT }, @@ -741,7 +745,6 @@ void TerminalManager::setKeybindings( UITerminal* term ) { term->getKeyBindings().reset(); term->addKeyBinds( mApp->getRealLocalKeybindings() ); term->addKeyBinds( mApp->getRealSplitterKeybindings() ); - term->addKeyBinds( mApp->getRealTerminalKeybindings() ); // Remove the keybinds that are problematic for a terminal term->getKeyBindings().removeCommandsKeybind( { "open-file", "download-file-web", "open-folder", "debug-draw-highlight-toggle", @@ -749,6 +752,9 @@ void TerminalManager::setKeybindings( UITerminal* term ) { "open-locatebar", "open-command-palette", "open-global-search", "menu-toggle", "console-toggle", "go-to-line", "editor-go-back", "editor-go-forward", "project-run-executable", "project-build-and-run" } ); + // Terminal bindings must be installed last so they can intentionally reuse shortcuts removed + // from the editor/global context (for example mod+shift+f). + term->addKeyBinds( mApp->getRealTerminalKeybindings() ); } } // namespace ecode diff --git a/src/tools/eterm/eterm.cpp b/src/tools/eterm/eterm.cpp index 3a20f79a3..7d5f342e3 100644 --- a/src/tools/eterm/eterm.cpp +++ b/src/tools/eterm/eterm.cpp @@ -3,6 +3,10 @@ namespace eterm { +struct TabWidgetSplitterDeleter { + void operator()( UITabWidgetSplitter* splitter ) const { eeDelete( splitter ); } +}; + void App::TerminalSplitterClient::onTabCreated( UITab* tab, UIWidget* widget ) { if ( mApp.terminalIcon && widget && widget->isType( UI_TYPE_TERMINAL ) ) tab->setIcon( mApp.terminalIcon->createDrawable( PixelDensity::dpToPxI( 12 ) ) ); @@ -825,7 +829,9 @@ int App::run( int argc, char* argv[] ) { mainLayout->setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::MatchParent ); mainLayout->setPixelsSize( appWindow->getSize().asFloat() ); - tabSplitter = UITabWidgetSplitter::New( &splitterClient, scene ); + std::unique_ptr tabSplitterOwner( + UITabWidgetSplitter::New( &splitterClient, scene ) ); + tabSplitter = tabSplitterOwner.get(); tabSplitter->setHideTabBarOnSingleTab( !config->window.alwaysShowTabBar ); tabSplitter->setCanCreateSplitFn( [this]( SplitDirection, UIWidget* ) { restoreMaximizedTabWidget(); diff --git a/src/tools/eterm/keybindings.cpp b/src/tools/eterm/keybindings.cpp index 4cee8313c..8f7c8c4f5 100644 --- a/src/tools/eterm/keybindings.cpp +++ b/src/tools/eterm/keybindings.cpp @@ -10,6 +10,10 @@ KeyBindings::ShortcutMap App::getDefaultKeybindings() const { return { { { KEY_C, KEYMOD_CTRL | KEYMOD_SHIFT }, "terminal-copy" }, { { KEY_V, KEYMOD_CTRL | KEYMOD_SHIFT }, "terminal-paste" }, + { { KEY_F, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "terminal-find" }, + { { KEY_G, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "terminal-find-next" }, + { { KEY_G, KeyMod::getDefaultModifier() | KEYMOD_SHIFT | KEYMOD_ALT }, + "terminal-find-previous" }, { { KEY_T, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "create-new-terminal" }, { { KEY_E, KeyMod::getDefaultModifier() | KeyMod::getDefaultSecondaryModifier() | KEYMOD_SHIFT },