- 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
This commit is contained in:
Martín Lucas Golini
2026-09-10 00:57:56 -03:00
parent a294d78733
commit 95d52cdbb3
25 changed files with 1067 additions and 120 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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<char16_t> toUtf16() const;

View File

@@ -0,0 +1,23 @@
#ifndef EE_UI_TOOLS_UIFINDBARSTYLE_HPP
#define EE_UI_TOOLS_UIFINDBARSTYLE_HPP
#include <eepp/ui/base.hpp>
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

View File

@@ -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<char16_t> String::toUtf16() const {
// Prepare the output string
std::basic_string<char16_t> output;

View File

@@ -1,111 +1,13 @@
#include <eepp/scene/actions/actions.hpp>
#include <eepp/system/scopedop.hpp>
#include <eepp/ui/css/stylesheetparser.hpp>
#include <eepp/ui/tools/uidocfindreplace.hpp>
#include <eepp/ui/tools/uifindbarstyle.hpp>
#include <eepp/ui/uiscenenode.hpp>
#include <eepp/window/clipboard.hpp>
#include <eepp/window/window.hpp>
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,<svg fill='#ffffff' width='16' height='16' viewBox='0 0 16 16'><path d='M8.85352 11.7021H7.85449L7.03809 9.54297H3.77246L3.00439 11.7021H2L4.9541 4H5.88867L8.85352 11.7021ZM6.74268 8.73193L5.53418 5.4502C5.49479 5.34277 5.4554 5.1709 5.41602 4.93457H5.39453C5.35872 5.15299 5.31755 5.32487 5.271 5.4502L4.07324 8.73193H6.74268Z'/><path d='M13.756 11.7021H12.8752V10.8428H12.8537C12.4706 11.5016 11.9066 11.8311 11.1618 11.8311C10.6139 11.8311 10.1843 11.686 9.87273 11.396C9.56479 11.106 9.41082 10.721 9.41082 10.2412C9.41082 9.21354 10.016 8.61556 11.2262 8.44727L12.8752 8.21631C12.8752 7.28174 12.4974 6.81445 11.7419 6.81445C11.0794 6.81445 10.4815 7.04004 9.94793 7.49121V6.58887C10.4886 6.24512 11.1117 6.07324 11.8171 6.07324C13.1097 6.07324 13.756 6.75716 13.756 8.125V11.7021ZM12.8752 8.91992L11.5485 9.10254C11.1403 9.15983 10.8324 9.26188 10.6247 9.40869C10.417 9.55192 10.3132 9.80794 10.3132 10.1768C10.3132 10.4453 10.4081 10.6655 10.5978 10.8374C10.7912 11.0057 11.0472 11.0898 11.3659 11.0898C11.8027 11.0898 12.1626 10.9377 12.4455 10.6333C12.7319 10.3254 12.8752 9.93685 12.8752 9.46777V8.91992Z'/></svg>");
}
.ce_find_replace_box selectbutton.luapattern {
icon: url("data:image/svg,<svg width='16' height='16' fill='none'><path d='m 4.8848115,11.097205 a 3.7631475,3.7631475 0 0 0 5.3533025,0 3.840684,3.840684 0 0 0 0,-5.3992983 3.7631475,3.7631475 0 0 0 -5.3533025,0 3.840684,3.840684 0 0 0 0,5.3992983 z m -0.7300257,0.73594 c -1.8819023,-1.8970156 -1.8819023,-4.9741625 0,-6.8711778 a 4.7895202,4.7895202 0 0 1 6.8133542,0 c 1.881245,1.8976724 1.881245,4.9741622 0,6.8711778 a 4.7895202,4.7895202 0 0 1 -6.8140113,0 z M 12.954519,4.5204035 a 1.0263727,1.0263727 0 0 1 -1.460051,0 1.0473995,1.0473995 0 0 1 0,-1.4718788 1.0263727,1.0263727 0 0 1 1.460051,0 1.0473995,1.0473995 0 0 1 0,1.4718788 z' fill='#ffffff' style='stroke-width:0.657089' /><path d='m 10.525919,8.4419094 a 2.0527454,2.0527454 0 0 1 -2.9201028,0 2.0947991,2.0947991 0 0 1 0,-2.9444148 2.0527454,2.0527454 0 0 1 2.9201028,0 2.0947991,2.0947991 0 0 1 0,2.9444148 z M 8.3351847,7.70597 a 1.0263727,1.0263727 0 0 0 1.4600513,0 1.0473995,1.0473995 0 0 0 0,-1.4718789 1.0263727,1.0263727 0 0 0 -1.4600513,0 1.0473995,1.0473995 0 0 0 0,1.4718789 z' fill='#ffffff' style='stroke-width:0.657089' /></svg>");
}
.ce_find_replace_box selectbutton.regex {
icon: url("data:image/svg,<svg fill='#ffffff' width='16' height='16' viewBox='0 0 16 16'><path fill-rule='evenodd' clip-rule='evenodd' d='M10.012 2h.976v3.113l2.56-1.557.486.885L11.47 6l2.564 1.559-.485.885-2.561-1.557V10h-.976V6.887l-2.56 1.557-.486-.885L9.53 6 6.966 4.441l.485-.885 2.561 1.557V2zM2 10h4v4H2v-4z'/></svg>");
}
.ce_find_replace_box selectbutton.whole-word {
icon: url("data:image/svg,<svg fill='#ffffff' width='16' height='16' viewBox='0 0 16 16'><path fill-rule='evenodd' clip-rule='evenodd' d='M0 11H1V13H15V11H16V14H15H1H0V11Z'/><path d='M6.84048 11H5.95963V10.1406H5.93814C5.555 10.7995 4.99104 11.1289 4.24625 11.1289C3.69839 11.1289 3.26871 10.9839 2.95718 10.6938C2.64924 10.4038 2.49527 10.0189 2.49527 9.53906C2.49527 8.51139 3.10041 7.91341 4.3107 7.74512L5.95963 7.51416C5.95963 6.57959 5.58186 6.1123 4.82632 6.1123C4.16389 6.1123 3.56591 6.33789 3.03238 6.78906V5.88672C3.57307 5.54297 4.19612 5.37109 4.90152 5.37109C6.19416 5.37109 6.84048 6.05501 6.84048 7.42285V11ZM5.95963 8.21777L4.63297 8.40039C4.22476 8.45768 3.91682 8.55973 3.70914 8.70654C3.50145 8.84977 3.39761 9.10579 3.39761 9.47461C3.39761 9.74316 3.4925 9.96338 3.68228 10.1353C3.87564 10.3035 4.13166 10.3877 4.45035 10.3877C4.8872 10.3877 5.24706 10.2355 5.52994 9.93115C5.8164 9.62321 5.95963 9.2347 5.95963 8.76562V8.21777Z'/><path d='M9.3475 10.2051H9.32601V11H8.44515V2.85742H9.32601V6.4668H9.3475C9.78076 5.73633 10.4146 5.37109 11.2489 5.37109C11.9543 5.37109 12.5057 5.61816 12.9032 6.1123C13.3042 6.60286 13.5047 7.26172 13.5047 8.08887C13.5047 9.00911 13.2809 9.74674 12.8333 10.3018C12.3857 10.8532 11.7734 11.1289 10.9964 11.1289C10.2695 11.1289 9.71989 10.821 9.3475 10.2051ZM9.32601 7.98682V8.75488C9.32601 9.20964 9.47282 9.59635 9.76644 9.91504C10.0636 10.2301 10.4396 10.3877 10.8944 10.3877C11.4279 10.3877 11.8451 10.1836 12.1458 9.77539C12.4502 9.36719 12.6024 8.79964 12.6024 8.07275C12.6024 7.46045 12.4609 6.98063 12.1781 6.6333C11.8952 6.28597 11.512 6.1123 11.0286 6.1123C10.5166 6.1123 10.1048 6.29134 9.7933 6.64941C9.48177 7.00391 9.32601 7.44971 9.32601 7.98682Z'/></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,<svg width='16' height='16' viewBox='0 0 16 16' fill='#ffffff'><path fill-rule='evenodd' clip-rule='evenodd' d='M3.221 3.739l2.261 2.269L7.7 3.784l-.7-.7-1.012 1.007-.008-1.6a.523.523 0 0 1 .5-.526H8V1H6.48A1.482 1.482 0 0 0 5 2.489V4.1L3.927 3.033l-.706.706zm6.67 1.794h.01c.183.311.451.467.806.467.393 0 .706-.168.94-.503.236-.335.353-.78.353-1.333 0-.511-.1-.913-.301-1.207-.201-.295-.488-.442-.86-.442-.405 0-.718.194-.938.581h-.01V1H9v4.919h.89v-.386zm-.015-1.061v-.34c0-.248.058-.448.175-.601a.54.54 0 0 1 .445-.23.49.49 0 0 1 .436.233c.104.154.155.368.155.643 0 .33-.056.587-.169.768a.524.524 0 0 1-.47.27.495.495 0 0 1-.411-.211.853.853 0 0 1-.16-.532zM9 12.769c-.256.154-.625.231-1.108.231-.563 0-1.02-.178-1.369-.533-.349-.355-.523-.813-.523-1.374 0-.648.186-1.158.56-1.53.374-.376.875-.563 1.5-.563.433 0 .746.06.94.179v.998a1.26 1.26 0 0 0-.792-.276c-.325 0-.583.1-.774.298-.19.196-.283.468-.283.816 0 .338.09.603.272.797.182.191.431.287.749.287.282 0 .558-.092.828-.276v.946zM4 7L3 8v6l1 1h7l1-1V8l-1-1H4zm0 1h7v6H4V8z'/></svg>");
}
.ce_find_replace_box pushbutton.replace-all-button {
icon: url("data:image/svg,<svg width='16' height='16' viewBox='0 0 16 16' fill='#ffffff'><path fill-rule='evenodd' clip-rule='evenodd' d='M11.6 2.677c.147-.31.356-.465.626-.465.248 0 .44.118.573.353.134.236.201.557.201.966 0 .443-.078.798-.235 1.067-.156.268-.365.402-.627.402-.237 0-.416-.125-.537-.374h-.008v.31H11V1h.593v1.677h.008zm-.016 1.1a.78.78 0 0 0 .107.426c.071.113.163.169.274.169.136 0 .24-.072.314-.216.075-.145.113-.35.113-.615 0-.22-.035-.39-.104-.514-.067-.124-.164-.187-.29-.187-.12 0-.219.062-.297.185a.886.886 0 0 0-.117.48v.272zM4.12 7.695L2 5.568l.662-.662 1.006 1v-1.51A1.39 1.39 0 0 1 5.055 3H7.4v.905H5.055a.49.49 0 0 0-.468.493l.007 1.5.949-.944.656.656-2.08 2.085zM9.356 4.93H10V3.22C10 2.408 9.685 2 9.056 2c-.135 0-.285.024-.45.073a1.444 1.444 0 0 0-.388.167v.665c.237-.203.487-.304.75-.304.261 0 .392.156.392.469l-.6.103c-.506.086-.76.406-.76.961 0 .263.061.473.183.631A.61.61 0 0 0 8.69 5c.29 0 .509-.16.657-.48h.009v.41zm.004-1.355v.193a.75.75 0 0 1-.12.436.368.368 0 0 1-.313.17.276.276 0 0 1-.22-.095.38.38 0 0 1-.08-.248c0-.222.11-.351.332-.389l.4-.067zM7 12.93h-.644v-.41h-.009c-.148.32-.367.48-.657.48a.61.61 0 0 1-.507-.235c-.122-.158-.183-.368-.183-.63 0-.556.254-.876.76-.962l.6-.103c0-.313-.13-.47-.392-.47-.263 0-.513.102-.75.305v-.665c.095-.063.224-.119.388-.167.165-.049.315-.073.45-.073.63 0 .944.407.944 1.22v1.71zm-.64-1.162v-.193l-.4.068c-.222.037-.333.166-.333.388 0 .1.027.183.08.248a.276.276 0 0 0 .22.095.368.368 0 0 0 .312-.17c.08-.116.12-.26.12-.436zM9.262 13c.321 0 .568-.058.738-.173v-.71a.9.9 0 0 1-.552.207.619.619 0 0 1-.5-.215c-.12-.145-.181-.345-.181-.598 0-.26.063-.464.189-.612a.644.644 0 0 1 .516-.223c.194 0 .37.069.528.207v-.749c-.129-.09-.338-.134-.626-.134-.417 0-.751.14-1.001.422-.249.28-.373.662-.373 1.148 0 .42.116.764.349 1.03.232.267.537.4.913.4zM2 9l1-1h9l1 1v5l-1 1H3l-1-1V9zm1 0v5h9V9H3zm3-2l1-1h7l1 1v5l-1 1V7H6z'/></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(
<hbox class="ce_find_replace_box" layout_width="wrap_content" layout_height="wrap_content" layout_gravity="right|top" margin_right="32dp">
<Widget class="expander" layout_width="2dp" layout_height="match_parent" />
@@ -173,13 +75,7 @@ UIDocFindReplace::UIDocFindReplace( UIWidget* parent, const std::shared_ptr<Doc:
getKeyBindings().addKeybindsStringUnordered( keybindings );
if ( !parent->getUISceneNode()->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 );

View File

@@ -0,0 +1,119 @@
#include <eepp/ui/css/stylesheetparser.hpp>
#include <eepp/ui/tools/uifindbarstyle.hpp>
#include <eepp/ui/uiscenenode.hpp>
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,<svg fill='#ffffff' width='16' height='16' viewBox='0 0 16 16'><path d='M8.85352 11.7021H7.85449L7.03809 9.54297H3.77246L3.00439 11.7021H2L4.9541 4H5.88867L8.85352 11.7021ZM6.74268 8.73193L5.53418 5.4502C5.49479 5.34277 5.4554 5.1709 5.41602 4.93457H5.39453C5.35872 5.15299 5.31755 5.32487 5.271 5.4502L4.07324 8.73193H6.74268Z'/><path d='M13.756 11.7021H12.8752V10.8428H12.8537C12.4706 11.5016 11.9066 11.8311 11.1618 11.8311C10.6139 11.8311 10.1843 11.686 9.87273 11.396C9.56479 11.106 9.41082 10.721 9.41082 10.2412C9.41082 9.21354 10.016 8.61556 11.2262 8.44727L12.8752 8.21631C12.8752 7.28174 12.4974 6.81445 11.7419 6.81445C11.0794 6.81445 10.4815 7.04004 9.94793 7.49121V6.58887C10.4886 6.24512 11.1117 6.07324 11.8171 6.07324C13.1097 6.07324 13.756 6.75716 13.756 8.125V11.7021ZM12.8752 8.91992L11.5485 9.10254C11.1403 9.15983 10.8324 9.26188 10.6247 9.40869C10.417 9.55192 10.3132 9.80794 10.3132 10.1768C10.3132 10.4453 10.4081 10.6655 10.5978 10.8374C10.7912 11.0057 11.0472 11.0898 11.3659 11.0898C11.8027 11.0898 12.1626 10.9377 12.4455 10.6333C12.7319 10.3254 12.8752 9.93685 12.8752 9.46777V8.91992Z'/></svg>");
}
.ce_find_replace_box selectbutton.luapattern {
icon: url("data:image/svg,<svg width='16' height='16' fill='none'><path d='m 4.8848115,11.097205 a 3.7631475,3.7631475 0 0 0 5.3533025,0 3.840684,3.840684 0 0 0 0,-5.3992983 3.7631475,3.7631475 0 0 0 -5.3533025,0 3.840684,3.840684 0 0 0 0,5.3992983 z m -0.7300257,0.73594 c -1.8819023,-1.8970156 -1.8819023,-4.9741625 0,-6.8711778 a 4.7895202,4.7895202 0 0 1 6.8133542,0 c 1.881245,1.8976724 1.881245,4.9741622 0,6.8711778 a 4.7895202,4.7895202 0 0 1 -6.8140113,0 z M 12.954519,4.5204035 a 1.0263727,1.0263727 0 0 1 -1.460051,0 1.0473995,1.0473995 0 0 1 0,-1.4718788 1.0263727,1.0263727 0 0 1 1.460051,0 1.0473995,1.0473995 0 0 1 0,1.4718788 z' fill='#ffffff' style='stroke-width:0.657089' /><path d='m 10.525919,8.4419094 a 2.0527454,2.0527454 0 0 1 -2.9201028,0 2.0947991,2.0947991 0 0 1 0,-2.9444148 2.0527454,2.0527454 0 0 1 2.9201028,0 2.0947991,2.0947991 0 0 1 0,2.9444148 z M 8.3351847,7.70597 a 1.0263727,1.0263727 0 0 0 1.4600513,0 1.0473995,1.0473995 0 0 0 0,-1.4718789 1.0263727,1.0263727 0 0 0 -1.4600513,0 1.0473995,1.0473995 0 0 0 0,1.4718789 z' fill='#ffffff' style='stroke-width:0.657089' /></svg>");
}
.ce_find_replace_box selectbutton.regex {
icon: url("data:image/svg,<svg fill='#ffffff' width='16' height='16' viewBox='0 0 16 16'><path fill-rule='evenodd' clip-rule='evenodd' d='M10.012 2h.976v3.113l2.56-1.557.486.885L11.47 6l2.564 1.559-.485.885-2.561-1.557V10h-.976V6.887l-2.56 1.557-.486-.885L9.53 6 6.966 4.441l.485-.885 2.561 1.557V2zM2 10h4v4H2v-4z'/></svg>");
}
.ce_find_replace_box selectbutton.whole-word {
icon: url("data:image/svg,<svg fill='#ffffff' width='16' height='16' viewBox='0 0 16 16'><path fill-rule='evenodd' clip-rule='evenodd' d='M0 11H1V13H15V11H16V14H15H1H0V11Z'/><path d='M6.84048 11H5.95963V10.1406H5.93814C5.555 10.7995 4.99104 11.1289 4.24625 11.1289C3.69839 11.1289 3.26871 10.9839 2.95718 10.6938C2.64924 10.4038 2.49527 10.0189 2.49527 9.53906C2.49527 8.51139 3.10041 7.91341 4.3107 7.74512L5.95963 7.51416C5.95963 6.57959 5.58186 6.1123 4.82632 6.1123C4.16389 6.1123 3.56591 6.33789 3.03238 6.78906V5.88672C3.57307 5.54297 4.19612 5.37109 4.90152 5.37109C6.19416 5.37109 6.84048 6.05501 6.84048 7.42285V11ZM5.95963 8.21777L4.63297 8.40039C4.22476 8.45768 3.91682 8.55973 3.70914 8.70654C3.50145 8.84977 3.39761 9.10579 3.39761 9.47461C3.39761 9.74316 3.4925 9.96338 3.68228 10.1353C3.87564 10.3035 4.13166 10.3877 4.45035 10.3877C4.8872 10.3877 5.24706 10.2355 5.52994 9.93115C5.8164 9.62321 5.95963 9.2347 5.95963 8.76562V8.21777Z'/><path d='M9.3475 10.2051H9.32601V11H8.44515V2.85742H9.32601V6.4668H9.3475C9.78076 5.73633 10.4146 5.37109 11.2489 5.37109C11.9543 5.37109 12.5057 5.61816 12.9032 6.1123C13.3042 6.60286 13.5047 7.26172 13.5047 8.08887C13.5047 9.00911 13.2809 9.74674 12.8333 10.3018C12.3857 10.8532 11.7734 11.1289 10.9964 11.1289C10.2695 11.1289 9.71989 10.821 9.3475 10.2051ZM9.32601 7.98682V8.75488C9.32601 9.20964 9.47282 9.59635 9.76644 9.91504C10.0636 10.2301 10.4396 10.3877 10.8944 10.3877C11.4279 10.3877 11.8451 10.1836 12.1458 9.77539C12.4502 9.36719 12.6024 8.79964 12.6024 8.07275C12.6024 7.46045 12.4609 6.98063 12.1781 6.6333C11.8952 6.28597 11.512 6.1123 11.0286 6.1123C10.5166 6.1123 10.1048 6.29134 9.7933 6.64941C9.48177 7.00391 9.32601 7.44971 9.32601 7.98682Z'/></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,<svg width='16' height='16' viewBox='0 0 16 16' fill='#ffffff'><path fill-rule='evenodd' clip-rule='evenodd' d='M3.221 3.739l2.261 2.269L7.7 3.784l-.7-.7-1.012 1.007-.008-1.6a.523.523 0 0 1 .5-.526H8V1H6.48A1.482 1.482 0 0 0 5 2.489V4.1L3.927 3.033l-.706.706zm6.67 1.794h.01c.183.311.451.467.806.467.393 0 .706-.168.94-.503.236-.335.353-.78.353-1.333 0-.511-.1-.913-.301-1.207-.201-.295-.488-.442-.86-.442-.405 0-.718.194-.938.581h-.01V1H9v4.919h.89v-.386zm-.015-1.061v-.34c0-.248.058-.448.175-.601a.54.54 0 0 1 .445-.23.49.49 0 0 1 .436.233c.104.154.155.368.155.643 0 .33-.056.587-.169.768a.524.524 0 0 1-.47.27.495.495 0 0 1-.411-.211.853.853 0 0 1-.16-.532zM9 12.769c-.256.154-.625.231-1.108.231-.563 0-1.02-.178-1.369-.533-.349-.355-.523-.813-.523-1.374 0-.648.186-1.158.56-1.53.374-.376.875-.563 1.5-.563.433 0 .746.06.94.179v.998a1.26 1.26 0 0 0-.792-.276c-.325 0-.583.1-.774.298-.19.196-.283.468-.283.816 0 .338.09.603.272.797.182.191.431.287.749.287.282 0 .558-.092.828-.276v.946zM4 7L3 8v6l1 1h7l1-1V8l-1-1H4zm0 1h7v6H4V8z'/></svg>");
}
.ce_find_replace_box pushbutton.replace-all-button {
icon: url("data:image/svg,<svg width='16' height='16' viewBox='0 0 16 16' fill='#ffffff'><path fill-rule='evenodd' clip-rule='evenodd' d='M11.6 2.677c.147-.31.356-.465.626-.465.248 0 .44.118.573.353.134.236.201.557.201.966 0 .443-.078.798-.235 1.067-.156.268-.365.402-.627.402-.237 0-.416-.125-.537-.374h-.008v.31H11V1h.593v1.677h.008zm-.016 1.1a.78.78 0 0 0 .107.426c.071.113.163.169.274.169.136 0 .24-.072.314-.216.075-.145.113-.35.113-.615 0-.22-.035-.39-.104-.514-.067-.124-.164-.187-.29-.187-.12 0-.219.062-.297.185a.886.886 0 0 0-.117.48v.272zM4.12 7.695L2 5.568l.662-.662 1.006 1v-1.51A1.39 1.39 0 0 1 5.055 3H7.4v.905H5.055a.49.49 0 0 0-.468.493l.007 1.5.949-.944.656.656-2.08 2.085zM9.356 4.93H10V3.22C10 2.408 9.685 2 9.056 2c-.135 0-.285.024-.45.073a1.444 1.444 0 0 0-.388.167v.665c.237-.203.487-.304.75-.304.261 0 .392.156.392.469l-.6.103c-.506.086-.76.406-.76.961 0 .263.061.473.183.631A.61.61 0 0 0 8.69 5c.29 0 .509-.16.657-.48h.009v.41zm.004-1.355v.193a.75.75 0 0 1-.12.436.368.368 0 0 1-.313.17.276.276 0 0 1-.22-.095.38.38 0 0 1-.08-.248c0-.222.11-.351.332-.389l.4-.067zM7 12.93h-.644v-.41h-.009c-.148.32-.367.48-.657.48a.61.61 0 0 1-.507-.235c-.122-.158-.183-.368-.183-.63 0-.556.254-.876.76-.962l.6-.103c0-.313-.13-.47-.392-.47-.263 0-.513.102-.75.305v-.665c.095-.063.224-.119.388-.167.165-.049.315-.073.45-.073.63 0 .944.407.944 1.22v1.71zm-.64-1.162v-.193l-.4.068c-.222.037-.333.166-.333.388 0 .1.027.183.08.248a.276.276 0 0 0 .22.095.368.368 0 0 0 .312-.17c.08-.116.12-.26.12-.436zM9.262 13c.321 0 .568-.058.738-.173v-.71a.9.9 0 0 1-.552.207.619.619 0 0 1-.5-.215c-.12-.145-.181-.345-.181-.598 0-.26.063-.464.189-.612a.644.644 0 0 1 .516-.223c.194 0 .37.069.528.207v-.749c-.129-.09-.338-.134-.626-.134-.417 0-.751.14-1.001.422-.249.28-.373.662-.373 1.148 0 .42.116.764.349 1.03.232.267.537.4.913.4zM2 9l1-1h9l1 1v5l-1 1H3l-1-1V9zm1 0v5h9V9H3zm3-2l1-1h7l1 1v5l-1 1V7H6z'/></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<const Uint8*>( 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

View File

@@ -258,6 +258,18 @@ class TerminalDisplay {
const std::shared_ptr<TerminalSession>& 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;

View File

@@ -43,6 +43,7 @@
#include <eterm/terminal/iterminaldisplay.hpp>
#include <eterm/terminal/kittygraphicsprotocol.hpp>
#include <eterm/terminal/kittykeyboardprotocol.hpp>
#include <eterm/terminal/terminalsearch.hpp>
#include <eterm/terminal/terminaltypes.hpp>
#include <memory>
#include <stdint.h>
@@ -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<TerminalSearchMatch>& 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<const TerminalGlyph*, KittyPlaceholderMetadata> mKittyPlaceholderMetadata;
Uint32 mKittyUnderlineColor{ 0 };
TerminalSearch mSearch;
TerminalSearchQuery mSearchQuery;
std::vector<TerminalSearchRowView> mSearchRows;
Int32 mCurrentSearchMatch{ -1 };
Clock mSearchRefreshClock;
bool mSearchDirty{ false };
void setClipboard( const char* str );

View File

@@ -0,0 +1,82 @@
#ifndef ETERM_TERMINALSEARCH_HPP
#define ETERM_TERMINALSEARCH_HPP
#include <eepp/core/string.hpp>
#include <eterm/terminal/terminaltypes.hpp>
#include <vector>
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<TerminalSearchMatch>& search( const std::vector<TerminalSearchRowView>& rows,
const TerminalSearchQuery& query );
const std::vector<TerminalSearchMatch>& matches() const { return mMatches; }
private:
struct CellSpan {
TerminalBufferPosition start;
TerminalBufferPosition end;
};
void searchLogicalLine( const TerminalSearchQuery& query, PatternMatcher* pattern,
const String& needle );
std::vector<TerminalSearchMatch> mMatches;
String mText;
String mComparableText;
std::string mUtf8Text;
std::vector<CellSpan> mPositions;
};
}} // namespace eterm::Terminal
#endif

View File

@@ -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<const TerminalGraphicsPresentation> graphics;
std::vector<TerminalGlyph> cells;
std::vector<Uint8> dirtyRows;
std::vector<SearchCellRange> 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<TerminalSessio
std::vector<Event> drainEvents();
std::vector<TerminalGraphicsUpdate> 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<std::string>
@@ -220,6 +233,13 @@ class TerminalSession final : public std::enable_shared_from_this<TerminalSessio
};
struct SelectionClearCommand {};
struct GraphicsResyncCommand {};
struct SearchQueryCommand {
TerminalSearchQuery query;
};
struct SearchNavigateCommand {
int direction{ 1 };
};
struct SearchClearCommand {};
struct ResetCommand {};
struct TerminateCommand {};
struct AllowTrimCommand : BoolCommand {};
@@ -233,7 +253,8 @@ class TerminalSession final : public std::enable_shared_from_this<TerminalSessio
SelectionClearCommand, MouseCommand, FocusCommand, CursorModeCommand,
PaletteCommand, PresentationRateCommand, AllowTrimCommand, DataEventsCommand,
PromptEventsCommand, TerminateCommand, RestartCommand, ResetCommand,
SelectionRequestCommand, GraphicsResyncCommand>;
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<TerminalSessio
std::shared_ptr<const TerminalSnapshot> mPublishedSnapshot;
std::atomic<bool> mShutdownRequested{ false };
std::atomic<Uint64> mNextScrollCommand{ 0 };
std::atomic<Uint64> mLatestSearchRequest{ 0 };
};
}} // namespace eterm::Terminal

View File

@@ -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,
};

View File

@@ -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<TerminalDisplay> mTerm;
Uint32 mTerminalEventCallbackId{ 0 };
UITerminalFind* mFindBar{ nullptr };
UITerminal( const std::shared_ptr<TerminalDisplay>& terminalDisplay );

View File

@@ -0,0 +1,59 @@
#ifndef ETERM_UI_UITERMINALFIND_HPP
#define ETERM_UI_UITERMINALFIND_HPP
#include <eepp/ui/uilinearlayout.hpp>
#include <eepp/ui/uiselectbutton.hpp>
#include <eepp/ui/uitextinput.hpp>
#include <eepp/ui/uitextview.hpp>
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

View File

@@ -629,6 +629,33 @@ const std::shared_ptr<TerminalSession>& 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;

View File

@@ -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<Int32>( 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<Int32>( 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<Int32>( matches.size() ) ) %
static_cast<Int32>( matches.size() );
}
const auto& match = matches[mCurrentSearchMatch];
if ( match.start.source == TerminalBufferSource::MainHistory ) {
TerminalArg scroll( eeclamp( static_cast<int>( -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<TerminalSearchMatch>& 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. */

View File

@@ -0,0 +1,154 @@
#include <eterm/terminal/terminalsearch.hpp>
#include <eepp/system/luapattern.hpp>
#include <eepp/system/regex.hpp>
#include <optional>
using namespace EE::System;
namespace eterm { namespace Terminal {
static bool isWordCharacter( Rune rune ) {
return rune == '_' || String::isAlphaNum( rune );
}
template <typename Positions>
static void searchPattern( PatternMatcher& pattern, const std::string& text,
const String& logicalLine, const Positions& positions, bool wholeWord,
std::vector<TerminalSearchMatch>& 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<unsigned char>( text[byteOffset] ) & 0xC0 ) != 0x80 )
++codepointOffset;
++byteOffset;
}
return codepointOffset;
};
while ( offset <= static_cast<int>( 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<int>( 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<int>( text.size() ) ) {
do {
++offset;
} while ( offset < static_cast<int>( text.size() ) &&
( static_cast<unsigned char>( 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<TerminalSearchMatch>&
TerminalSearch::search( const std::vector<TerminalSearchRowView>& 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> regex;
std::optional<LuaPatternStorage> luaPattern;
PatternMatcher* pattern = nullptr;
if ( query.type == TerminalSearchType::RegEx ) {
regex.emplace( patternText, static_cast<RegEx::Options>(
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

View File

@@ -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<Uint32>( 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<int>( 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<Int32>( 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<Int32>( 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<const TerminalSnapshot> TerminalSession::snapshot() const {
std::lock_guard<std::mutex> lock( mPublishedSnapshotMutex );
return mPublishedSnapshot;
@@ -606,6 +650,14 @@ void TerminalSession::processCommand( Command&& command ) {
} else if constexpr ( std::is_same_v<T, GraphicsResyncCommand> ) {
mGraphicsUpdates.resetResync();
mEmulator->requestGraphicsResync();
} else if constexpr ( std::is_same_v<T, SearchQueryCommand> ) {
if ( value.query.requestId ==
mLatestSearchRequest.load( std::memory_order_acquire ) )
mEmulator->setSearchQuery( std::move( value.query ) );
} else if constexpr ( std::is_same_v<T, SearchNavigateCommand> ) {
mEmulator->navigateSearch( value.direction );
} else if constexpr ( std::is_same_v<T, SearchClearCommand> ) {
mEmulator->clearSearch();
}
},
std::move( command ) );

View File

@@ -9,6 +9,7 @@
#include <eepp/window/engine.hpp>
#include <eepp/window/input.hpp>
#include <eterm/ui/uiterminal.hpp>
#include <eterm/ui/uiterminalfind.hpp>
using namespace EE::Scene;
@@ -112,6 +113,11 @@ UITerminal::UITerminal( const std::shared_ptr<TerminalDisplay>& 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 ) {

View File

@@ -0,0 +1,209 @@
#include <eterm/ui/uiterminalfind.hpp>
#include <eepp/scene/actions/actions.hpp>
#include <eepp/ui/tools/uifindbarstyle.hpp>
#include <eepp/ui/uipushbutton.hpp>
#include <eepp/ui/uiscenenode.hpp>
#include <eterm/ui/uiterminal.hpp>
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(
<hbox class="ce_find_replace_box" layout_width="wrap_content" lh="wc" layout_gravity="right|top" margin_right="32dp">
<Widget class="expander" layout_width="2dp" layout_height="match_parent" />
<vbox layout_width="wrap_content" lh="wc">
<hbox layout_width="340dp" lh="wc" paddingRight="1dp" min-width="200dp" clip="border-box">
<RelativeLayout layout_width="0dp" layout_weight="1" layout_height="25dp" marginRight="1dp">
<TextInput class="input-find" layout_width="match_parent" lh="wc" hint="@string(find, Find)" />
<hbox lw="wc" lh="wc" layout_gravity="right|center_vertical">
<TextView class="status" text="0/0" lw="wc" min-width="32dp" lh="wc" layout_gravity="right|center_vertical"
gravity="right" marginRight="2dp" font-family="monospace" />
<selectbutton id="terminal-find-luapattern" class="luapattern" lw="wc" lh="wc"
layout_gravity="right|center_vertical" tooltip="@string(lua_pattern_match, Lua Pattern Match)" marginRight="2dp" />
<selectbutton id="terminal-find-regex" class="regex" lw="wc" lh="wc"
layout_gravity="right|center_vertical" tooltip="@string(regex_match, Regular Expression Match)" marginRight="2dp" />
<selectbutton id="terminal-find-whole-word" class="whole-word" lw="wc" lh="wc"
layout_gravity="right|center_vertical" tooltip="@string(whole_word, Whole Word)" marginRight="2dp" />
<selectbutton id="terminal-find-match-case" class="match-case" lw="wc" lh="wc"
layout_gravity="right|center_vertical" tooltip="@string(match_case, Match Case)" marginRight="2dp" />
</hbox>
</RelativeLayout>
<PushButton class="prev-button" lw="wc" layout_height="24dp" text="@string(prev, Prev.)"
icon="icon(arrow-up, 16dp)" />
<PushButton class="next-button" lw="wc" layout_height="24dp" text="@string(next, Next)"
icon="icon(arrow-down, 16dp)" />
<PushButton class="exit-button" lw="wc" layout_height="24dp" text="@string(close, Close)"
icon="icon(cancel, 16dp)" />
</hbox>
</vbox>
</hbox>
)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<UITextInput>( ".ce_find_replace_box .input-find" );
mMatchCase = querySelector<UISelectButton>( ".ce_find_replace_box .match-case" );
mWholeWord = querySelector<UISelectButton>( ".ce_find_replace_box .whole-word" );
mRegEx = querySelector<UISelectButton>( ".ce_find_replace_box .regex" );
mLuaPattern = querySelector<UISelectButton>( ".ce_find_replace_box .luapattern" );
mStatus = querySelector<UITextView>( ".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

View File

@@ -10,6 +10,7 @@
#include <eterm/terminal/iterminaldisplay.hpp>
#include <eterm/terminal/terminalemulator.hpp>
#include <eterm/terminal/terminalgraphics.hpp>
#include <eterm/terminal/terminalsearch.hpp>
#include <eterm/terminal/terminalsession.hpp>
#include <limits>
#include <thread>
@@ -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<TerminalSearchRowView> 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<size_t>( 1 ), matches.size() );
EXPECT_EQ( static_cast<Int64>( -1 ), matches[0].start.row );
EXPECT_EQ( 3, matches[0].start.column );
EXPECT_EQ( static_cast<Int64>( 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<size_t>( 1 ),
search.search( rows, { "HELLOWORLD", 3, false, true } ).size() );
String unicodeQuery;
unicodeQuery += static_cast<String::StringBaseType>( 0x754C );
unicodeQuery += '!';
matches = search.search( rows, { unicodeQuery, 4, true, false } );
ASSERT_EQ( static_cast<size_t>( 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<size_t>( 1 ), matches.size() );
EXPECT_EQ( 2, matches[0].start.column );
EXPECT_EQ( static_cast<Int64>( 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<size_t>( 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<size_t>( 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<size_t>( 1 ),
search.search( rows, { "helloworld", 9, false, false, TerminalSearchType::RegEx } )
.size() );
}
UTEST( eterm_search, emulator_history_navigation_and_clear ) {
auto pty = std::make_unique<MockPty>();
auto process = std::make_unique<MockProcess>();
auto display = std::make_shared<MockDisplay>();
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<Uint64>( 41 ), term->getSearchRequestId() );
term->setSearchQuery( { "needle", 42, true, true } );
ASSERT_EQ( static_cast<size_t>( 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() );
}

View File

@@ -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 ) {

View File

@@ -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

View File

@@ -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<UITabWidgetSplitter, TabWidgetSplitterDeleter> tabSplitterOwner(
UITabWidgetSplitter::New( &splitterClient, scene ) );
tabSplitter = tabSplitterOwner.get();
tabSplitter->setHideTabBarOnSingleTab( !config->window.alwaysShowTabBar );
tabSplitter->setCanCreateSplitFn( [this]( SplitDirection, UIWidget* ) {
restoreMaximizedTabWidget();

View File

@@ -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 },