From 3d4aea6df4d022c7766e13e4f8b5c7577e4e7b37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Sun, 6 Sep 2026 21:02:58 -0300 Subject: [PATCH 1/6] Reduce heap-allocations on possible hot-paths. --- include/eepp/ui/css/stylesheetselector.hpp | 2 +- include/eepp/ui/databinding/uiproperty.hpp | 2 +- include/eepp/ui/uinode.hpp | 6 ++++++ include/eepp/ui/uiscenenode.hpp | 3 ++- include/eepp/ui/uiwidget.hpp | 11 +++++++---- src/eepp/ui/css/stylesheetselector.cpp | 8 ++++---- src/eepp/ui/uinode.cpp | 6 ++++++ src/eepp/ui/uiscenenode.cpp | 6 +++--- src/eepp/ui/uistyle.cpp | 3 +-- src/eepp/ui/uiwidget.cpp | 21 ++++++++++----------- src/tests/unit_tests/uihtml_tests.cpp | 2 +- src/tools/ecode/plugins/git/gitplugin.hpp | 2 +- 12 files changed, 43 insertions(+), 29 deletions(-) diff --git a/include/eepp/ui/css/stylesheetselector.hpp b/include/eepp/ui/css/stylesheetselector.hpp index 17443351a..8638a6817 100644 --- a/include/eepp/ui/css/stylesheetselector.hpp +++ b/include/eepp/ui/css/stylesheetselector.hpp @@ -27,7 +27,7 @@ class EE_API StyleSheetSelector { bool hasPseudoClasses() const; - std::vector getRelatedElements( UIWidget* element, bool applyPseudo = true ) const; + SmallVector getRelatedElements( UIWidget* element, bool applyPseudo = true ) const; bool isStructurallyVolatile() const; diff --git a/include/eepp/ui/databinding/uiproperty.hpp b/include/eepp/ui/databinding/uiproperty.hpp index f03dd8e27..c5af9fd39 100644 --- a/include/eepp/ui/databinding/uiproperty.hpp +++ b/include/eepp/ui/databinding/uiproperty.hpp @@ -123,7 +123,7 @@ template class UIProperty { } /** @return All currently connected widgets, or an empty vector after expiration. */ - std::vector widgets() const { + SmallVector widgets() const { auto state = mState.lock(); if ( !state || !state->property || !state->property->databind().isInitialized() ) return {}; diff --git a/include/eepp/ui/uinode.hpp b/include/eepp/ui/uinode.hpp index ee3eef0a8..1cf64a17b 100644 --- a/include/eepp/ui/uinode.hpp +++ b/include/eepp/ui/uinode.hpp @@ -1516,8 +1516,14 @@ class EE_API UINode : public Node { /** @brief Get a widget's computed absolute font size in pixels. */ Float getAbsoluteFontSize( const UIWidget* widget ) const; + /** Returns true if the node is currently being created, this state is not used by all node + * types. + */ bool isCreatingNode() const; + /** Forces a left mouse click event over the node */ + void click(); + protected: Vector2f mDpPos; Sizef mDpSize; diff --git a/include/eepp/ui/uiscenenode.hpp b/include/eepp/ui/uiscenenode.hpp index 0e360ee46..d023eaf20 100644 --- a/include/eepp/ui/uiscenenode.hpp +++ b/include/eepp/ui/uiscenenode.hpp @@ -858,7 +858,8 @@ class EE_API UISceneNode : public SceneNode { * @param marker Marker for style association. * @return Vector of root widgets created. */ - std::vector loadNode( pugi::xml_node node, Node* parent, const Uint32& marker = 0 ); + SmallVector loadNode( pugi::xml_node node, Node* parent, + const Uint32& marker = 0 ); /** Sets the document / scene URI used to resolve paths of inner elements */ void setURI( const URI& uri ); diff --git a/include/eepp/ui/uiwidget.hpp b/include/eepp/ui/uiwidget.hpp index 1fabedec0..671586ad4 100644 --- a/include/eepp/ui/uiwidget.hpp +++ b/include/eepp/ui/uiwidget.hpp @@ -23,6 +23,7 @@ namespace EE { namespace UI { class UITooltip; class UIStyle; +class UIWidget; struct MarginAuto { static constexpr auto Left = ( 1 << 0 ); @@ -31,6 +32,8 @@ struct MarginAuto { static constexpr auto Bottom = ( 1 << 3 ); }; +using WidgetQueryResult = SmallVector; + /** * @brief Base class for all UI widgets in the eepp framework. * @@ -1118,7 +1121,7 @@ class EE_API UIWidget : public UINode { * @param className The CSS class to search for. * @return Vector of matching UIWidget pointers. */ - std::vector findAllByClass( const std::string& className ); + WidgetQueryResult findAllByClass( const std::string& className ); /** * @brief Finds all widgets by CSS tag. @@ -1128,7 +1131,7 @@ class EE_API UIWidget : public UINode { * @param tag The CSS tag to search for. * @return Vector of matching UIWidget pointers. */ - std::vector findAllByTag( const std::string& tag ); + WidgetQueryResult findAllByTag( const std::string& tag ); /** * @brief Finds a widget by CSS class. @@ -1190,7 +1193,7 @@ class EE_API UIWidget : public UINode { * @param selector The CSS selector to use. * @return Vector of all matching UIWidget pointers. */ - std::vector querySelectorAll( const CSS::StyleSheetSelector& selector ); + WidgetQueryResult querySelectorAll( const CSS::StyleSheetSelector& selector ); /** * @brief Queries all widgets using a CSS selector string. @@ -1200,7 +1203,7 @@ class EE_API UIWidget : public UINode { * @param selector The CSS selector string to use. * @return Vector of all matching UIWidget pointers. */ - std::vector querySelectorAll( const std::string& selector ); + WidgetQueryResult querySelectorAll( const std::string& selector ); /** * @brief Gets a property value as a string. diff --git a/src/eepp/ui/css/stylesheetselector.cpp b/src/eepp/ui/css/stylesheetselector.cpp index a673cd37a..1ee064166 100644 --- a/src/eepp/ui/css/stylesheetselector.cpp +++ b/src/eepp/ui/css/stylesheetselector.cpp @@ -237,10 +237,10 @@ bool StyleSheetSelector::selectComplex( UIWidget* element, const bool& applyPseu return true; } -std::vector StyleSheetSelector::getRelatedElements( UIWidget* element, - bool applyPseudo ) const { - static std::vector EMPTY_ELEMENTS; - std::vector elements; +SmallVector StyleSheetSelector::getRelatedElements( UIWidget* element, + bool applyPseudo ) const { + static SmallVector EMPTY_ELEMENTS; + SmallVector elements; if ( mSelectorRules.empty() ) return elements; diff --git a/src/eepp/ui/uinode.cpp b/src/eepp/ui/uinode.cpp index 35b379ea4..894d5d0dd 100644 --- a/src/eepp/ui/uinode.cpp +++ b/src/eepp/ui/uinode.cpp @@ -1905,4 +1905,10 @@ bool UINode::isCreatingNode() const { return mFlags & UI_CREATING_NODE; } +void UINode::click() { + Vector2f pos; + nodeToWorld( pos ); + getEventDispatcher()->sendMouseClick( this, pos.ceil().asInt(), EE_BUTTON_LMASK ); +} + }} // namespace EE::UI diff --git a/src/eepp/ui/uiscenenode.cpp b/src/eepp/ui/uiscenenode.cpp index 5f13fb277..ffe6eeb45 100644 --- a/src/eepp/ui/uiscenenode.cpp +++ b/src/eepp/ui/uiscenenode.cpp @@ -546,12 +546,12 @@ bool UISceneNode::windowExists( UIWindow* win ) { return mWindowsList.end() != std::find( mWindowsList.begin(), mWindowsList.end(), win ); } -std::vector UISceneNode::loadNode( pugi::xml_node node, Node* parent, +SmallVector UISceneNode::loadNode( pugi::xml_node node, Node* parent, const Uint32& marker ) { Uint32 oldMarker = mCurrentMarker; mCurrentMarker = marker; - std::vector rootWidgets; + SmallVector rootWidgets; if ( NULL == parent ) parent = this; @@ -646,7 +646,7 @@ UIWidget* UISceneNode::loadLayoutNodes( pugi::xml_node node, Node* parent, const std::string id( node.attribute( "id" ).as_string() ); mIsLoading = true; Clock innerClock; - std::vector widgets = loadNode( node, parent, marker ); + SmallVector widgets = loadNode( node, parent, marker ); if ( mVerbose ) { std::sort( diff --git a/src/eepp/ui/uistyle.cpp b/src/eepp/ui/uistyle.cpp index 5d253e63c..1b3a31bee 100644 --- a/src/eepp/ui/uistyle.cpp +++ b/src/eepp/ui/uistyle.cpp @@ -581,8 +581,7 @@ void UIStyle::subscribeNonCacheableStyles() { return; for ( auto& style : mGlobalDefinition->getStyles() ) { if ( !style->getSelector().isCacheable() ) { - std::vector elements = - style->getSelector().getRelatedElements( mWidget, false ); + auto elements = style->getSelector().getRelatedElements( mWidget, false ); if ( !elements.empty() ) { for ( auto& element : elements ) { diff --git a/src/eepp/ui/uiwidget.cpp b/src/eepp/ui/uiwidget.cpp index 06d362a4d..54db7b793 100644 --- a/src/eepp/ui/uiwidget.cpp +++ b/src/eepp/ui/uiwidget.cpp @@ -1557,8 +1557,8 @@ const Uint32& UIWidget::getStylePreviousState() const { return NULL != mStyle ? mStyle->getPreviousState() : mState; } -std::vector UIWidget::findAllByClass( const std::string& className ) { - std::vector widgets; +WidgetQueryResult UIWidget::findAllByClass( const std::string& className ) { + WidgetQueryResult widgets; if ( !isClosing() && hasClass( className ) && !inClosingTree() ) { widgets.push_back( this ); @@ -1568,8 +1568,7 @@ std::vector UIWidget::findAllByClass( const std::string& className ) while ( NULL != child ) { if ( child->isWidget() ) { - std::vector foundWidgets = - child->asType()->findAllByClass( className ); + WidgetQueryResult foundWidgets = child->asType()->findAllByClass( className ); if ( !foundWidgets.empty() ) widgets.insert( widgets.end(), foundWidgets.begin(), foundWidgets.end() ); @@ -1581,8 +1580,8 @@ std::vector UIWidget::findAllByClass( const std::string& className ) return widgets; } -std::vector UIWidget::findAllByTag( const std::string& tag ) { - std::vector widgets; +WidgetQueryResult UIWidget::findAllByTag( const std::string& tag ) { + WidgetQueryResult widgets; if ( !isClosing() && getElementTag() == tag && !inClosingTree() ) { widgets.push_back( this ); @@ -1592,7 +1591,7 @@ std::vector UIWidget::findAllByTag( const std::string& tag ) { while ( NULL != child ) { if ( child->isWidget() ) { - std::vector foundWidgets = child->asType()->findAllByTag( tag ); + WidgetQueryResult foundWidgets = child->asType()->findAllByTag( tag ); if ( !foundWidgets.empty() ) widgets.insert( widgets.end(), foundWidgets.begin(), foundWidgets.end() ); @@ -1667,8 +1666,8 @@ UIWidget* UIWidget::querySelector( const CSS::StyleSheetSelector& selector ) { return NULL; } -std::vector UIWidget::querySelectorAll( const CSS::StyleSheetSelector& selector ) { - std::vector widgets; +WidgetQueryResult UIWidget::querySelectorAll( const CSS::StyleSheetSelector& selector ) { + WidgetQueryResult widgets; if ( !isClosing() && !inClosingTree() && selector.select( this ) ) { widgets.push_back( this ); @@ -1678,7 +1677,7 @@ std::vector UIWidget::querySelectorAll( const CSS::StyleSheetSelector while ( NULL != child ) { if ( child->isWidget() ) { - std::vector foundWidgets = + WidgetQueryResult foundWidgets = child->asType()->querySelectorAll( selector ); if ( !foundWidgets.empty() ) @@ -1714,7 +1713,7 @@ UIWidget* UIWidget::querySelector( const std::string& selector ) { return querySelector( CSS::StyleSheetSelector( selector ) ); } -std::vector UIWidget::querySelectorAll( const std::string& selector ) { +WidgetQueryResult UIWidget::querySelectorAll( const std::string& selector ) { return querySelectorAll( CSS::StyleSheetSelector( selector ) ); } diff --git a/src/tests/unit_tests/uihtml_tests.cpp b/src/tests/unit_tests/uihtml_tests.cpp index 3f58f51e5..76abdbce6 100644 --- a/src/tests/unit_tests/uihtml_tests.cpp +++ b/src/tests/unit_tests/uihtml_tests.cpp @@ -5837,7 +5837,7 @@ UTEST( UIHTML, RonStonerDeferredImagesUpdateDocumentHeight ) { ASSERT_TRUE( documentScene != nullptr ); UIWidget* body = nullptr; - std::vector images; + WidgetQueryResult images; for ( int i = 0; i < 300; ++i ) { pump(); body = documentScene->getRoot()->findByType( UI_TYPE_HTML_BODY )->asType(); diff --git a/src/tools/ecode/plugins/git/gitplugin.hpp b/src/tools/ecode/plugins/git/gitplugin.hpp index dda3cf11e..078880554 100644 --- a/src/tools/ecode/plugins/git/gitplugin.hpp +++ b/src/tools/ecode/plugins/git/gitplugin.hpp @@ -157,7 +157,7 @@ class GitPlugin : public PluginBase { UIDropDownList* mPanelSwicher{ nullptr }; UIDropDownList* mRepoDropDown{ nullptr }; UIStackWidget* mStackWidget{ nullptr }; - std::vector mStackMap; + SmallVector mStackMap; UIWidget* mGitContentView{ nullptr }; UIWidget* mGitNoContentView{ nullptr }; UIWidget* mConflictStateBar{ nullptr }; From 90499462ea70fb0c747d1e5b144a19ff9a543b9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Sun, 6 Sep 2026 21:18:06 -0300 Subject: [PATCH 2/6] Fix Reset zoom in new eeiv port. --- src/tools/eeiv/eeiv.cpp | 19 ++++++++++++++++++- src/tools/eeiv/eeiv.hpp | 1 + 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/tools/eeiv/eeiv.cpp b/src/tools/eeiv/eeiv.cpp index 4c59f86f8..dd959796d 100644 --- a/src/tools/eeiv/eeiv.cpp +++ b/src/tools/eeiv/eeiv.cpp @@ -398,7 +398,7 @@ void App::registerKeyBindings() { } else if ( key == KEY_KP_MULTIPLY || key == KEY_Z ) { fitImage(); } else if ( key == KEY_KP_DIVIDE ) { - setImgScale( mConfig.DefaultImageZoom ); + resetImageZoom(); } else if ( key == KEY_N && mImageViewer->getImage()->getDrawable() ) { const Sizef size( mImageViewer->getImage()->getDrawable()->getPixelsSize() ); getWindow()->setSize( size.getWidth(), size.getHeight() ); @@ -470,6 +470,23 @@ void App::fitImage() { mImageViewer->resetImageView(); } +void App::resetImageZoom() { + if ( !mImageViewer || !mImageViewer->getImage() || !mImageViewer->getImage()->getDrawable() ) + return; + + UIImage* image = mImageViewer->getImage(); + const Sizef displayedSize( image->getPixelsSize() ); + const Sizef nativeSize( image->getDrawable()->getPixelsSize() ); + if ( displayedSize.getWidth() == 0.f || displayedSize.getHeight() == 0.f ) + return; + + const Float pixelDensity = PixelDensity::getPixelDensity(); + image->setScale( { mConfig.DefaultImageZoom * nativeSize.getWidth() / + ( displayedSize.getWidth() * pixelDensity ), + mConfig.DefaultImageZoom * nativeSize.getHeight() / + ( displayedSize.getHeight() * pixelDensity ) } ); +} + Sprite* App::getImageSprite() const { if ( !mImageViewer || !mImageViewer->getImage() || !mImageViewer->getImage()->getDrawable() ) return nullptr; diff --git a/src/tools/eeiv/eeiv.hpp b/src/tools/eeiv/eeiv.hpp index c53e48dcb..5dedabc44 100644 --- a/src/tools/eeiv/eeiv.hpp +++ b/src/tools/eeiv/eeiv.hpp @@ -69,6 +69,7 @@ class App { void updateSlideShow(); void toggleHelp(); void fitImage(); + void resetImageZoom(); Sprite* getImageSprite() const; void processDroppedFiles(); From 827f5d7524fe0c9617a6fdbd3b7490520ce8c44f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Sun, 6 Sep 2026 21:39:11 -0300 Subject: [PATCH 3/6] ecode: fix stale and symbolic remote branch entries Exclude symbolic remote refs such as origin/HEAD from the branch list. Remove the corresponding local remote-tracking ref after deleting a remote branch, and prune stale refs when pulling. --- src/tools/ecode/plugins/git/git.cpp | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/tools/ecode/plugins/git/git.cpp b/src/tools/ecode/plugins/git/git.cpp index 29871d938..1c75c5105 100644 --- a/src/tools/ecode/plugins/git/git.cpp +++ b/src/tools/ecode/plugins/git/git.cpp @@ -774,7 +774,7 @@ std::string Git::setSafeDirectory( const std::string& projectDir ) const { } Git::Result Git::pull( const std::string& projectDir ) { - return gitSimple( "pull", projectDir ); + return gitSimple( "pull --prune", projectDir ); } Git::Result Git::push( const std::string& projectDir ) { @@ -979,6 +979,12 @@ Git::Result Git::deleteRemoteBranch( const std::string& remote, const std::strin const std::string& projectDir ) { Result result; result.returnCode = git( { "push", remote, "--delete", branch }, projectDir, result.result ); + if ( result.success() ) { + std::string cleanupResult; + result.returnCode = git( { "update-ref", "-d", "refs/remotes/" + remote + "/" + branch }, + projectDir, cleanupResult ); + result.result.append( cleanupResult ); + } return result; } @@ -1148,7 +1154,7 @@ Git::Branch parseLocalBranch( const std::string_view& raw ) { static Git::Branch parseRemoteBranch( std::string_view raw ) { auto split = String::split( raw, '\t', true ); - if ( split.size() < 4 ) + if ( split.size() < 4 || ( split.size() > 5 && !split[5].empty() ) ) return {}; std::string name( std::string{ split[1] } ); std::string remote( std::string{ split[1] } ); @@ -1176,7 +1182,7 @@ static Git::Branch parseTag( std::string_view raw ) { std::vector Git::getAllBranchesAndTags( RefType ref, std::string_view filterBranch, const std::string& projectDir ) { // clang-format off - std::string args( "for-each-ref --format '%(refname) %(refname:short) %(upstream:short) %(objectname) %(upstream:track,nobracket)' --sort=v:refname" ); + std::string args( "for-each-ref --format '%(refname) %(refname:short) %(upstream:short) %(objectname) %(upstream:track,nobracket) %(symref)' --sort=v:refname" ); // clang-format on if ( filterBranch.empty() ) { From a24eb12182174e18c44ab45ce24ad14a376d00b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Mon, 7 Sep 2026 00:31:27 -0300 Subject: [PATCH 4/6] Add SDL3 non-text clipboard data support Expose MIME-based clipboard data operations through the Clipboard interface, with unsupported defaults for SDL2. Implement lazy data providers, cleanup callbacks, MIME type enumeration, data retrieval, availability checks, and clipboard clearing for SDL3. --- include/eepp/window/clipboard.hpp | 27 +++++++ .../window/backend/SDL3/clipboardsdl3.cpp | 76 +++++++++++++++++++ .../window/backend/SDL3/clipboardsdl3.hpp | 11 +++ 3 files changed, 114 insertions(+) diff --git a/include/eepp/window/clipboard.hpp b/include/eepp/window/clipboard.hpp index 6302794bd..b9184a725 100644 --- a/include/eepp/window/clipboard.hpp +++ b/include/eepp/window/clipboard.hpp @@ -7,6 +7,10 @@ namespace EE { namespace Window { class EE_API Clipboard { public: + using Data = std::vector; + using DataCallback = std::function; + using CleanupCallback = std::function; + virtual ~Clipboard(); /** @return The Clipboard Text if available */ @@ -30,6 +34,29 @@ class EE_API Clipboard { /** Set the current clipboard primary selection text */ virtual void setPrimarySelectionText( const std::string& text ) {} + /** + * Offer non-text clipboard data in one or more MIME types. The data callback is invoked lazily + * when the operating system requests a format, and with a null MIME type when the offer is + * cleared or replaced. The returned data must remain valid until the cleanup callback is + * invoked. Only implemented by the SDL3 backend. + */ + virtual bool setData( DataCallback callback, CleanupCallback cleanup, + const std::vector& mimeTypes ) { + return false; + } + + /** Clear all clipboard data. Only implemented by the SDL3 backend. */ + virtual bool clearData() { return false; } + + /** Get clipboard data for a MIME type. Only implemented by the SDL3 backend. */ + virtual Data getData( const std::string& mimeType ) { return {}; } + + /** @return The MIME types currently offered by the clipboard. SDL3 backend only. */ + virtual std::vector getMimeTypes() { return {}; } + + /** @return Whether clipboard data is available for a MIME type. SDL3 backend only. */ + virtual bool hasData( const std::string& mimeType ) const { return false; } + protected: friend class Window; diff --git a/src/eepp/window/backend/SDL3/clipboardsdl3.cpp b/src/eepp/window/backend/SDL3/clipboardsdl3.cpp index e93d996d6..93086084e 100644 --- a/src/eepp/window/backend/SDL3/clipboardsdl3.cpp +++ b/src/eepp/window/backend/SDL3/clipboardsdl3.cpp @@ -2,10 +2,32 @@ #include #include +#include + #ifdef EE_BACKEND_SDL3 namespace EE { namespace Window { namespace Backend { namespace SDL3 { +namespace { + +struct ClipboardDataContext { + Clipboard::DataCallback callback; + Clipboard::CleanupCallback cleanup; +}; + +const void* SDLCALL clipboardDataCallback( void* userdata, const char* mimeType, size_t* size ) { + auto context = static_cast( userdata ); + return context->callback( mimeType, size ); +} + +void SDLCALL clipboardCleanupCallback( void* userdata ) { + std::unique_ptr context( static_cast( userdata ) ); + if ( context->cleanup ) + context->cleanup(); +} + +} // namespace + ClipboardSDL::ClipboardSDL( EE::Window::Window* window ) : Clipboard( window ) {} ClipboardSDL::~ClipboardSDL() {} @@ -40,6 +62,60 @@ String ClipboardSDL::getWideText() { return String::fromUtf8( getText() ); } +bool ClipboardSDL::setData( DataCallback callback, CleanupCallback cleanup, + const std::vector& mimeTypes ) { + if ( !callback || mimeTypes.empty() ) + return false; + + std::vector mimeTypePointers; + mimeTypePointers.reserve( mimeTypes.size() ); + for ( const auto& mimeType : mimeTypes ) + mimeTypePointers.emplace_back( mimeType.c_str() ); + + auto context = std::make_unique( + ClipboardDataContext{ std::move( callback ), std::move( cleanup ) } ); + if ( !SDL_SetClipboardData( clipboardDataCallback, clipboardCleanupCallback, context.get(), + mimeTypePointers.data(), mimeTypePointers.size() ) ) + return false; + + context.release(); + return true; +} + +bool ClipboardSDL::clearData() { + return SDL_ClearClipboardData(); +} + +Clipboard::Data ClipboardSDL::getData( const std::string& mimeType ) { + size_t size = 0; + void* data = SDL_GetClipboardData( mimeType.c_str(), &size ); + if ( !data ) + return {}; + + const auto bytes = static_cast( data ); + Data result( bytes, bytes + size ); + SDL_free( data ); + return result; +} + +std::vector ClipboardSDL::getMimeTypes() { + size_t count = 0; + char** mimeTypes = SDL_GetClipboardMimeTypes( &count ); + if ( !mimeTypes ) + return {}; + + std::vector result; + result.reserve( count ); + for ( size_t i = 0; i < count; ++i ) + result.emplace_back( mimeTypes[i] ); + SDL_free( mimeTypes ); + return result; +} + +bool ClipboardSDL::hasData( const std::string& mimeType ) const { + return SDL_HasClipboardData( mimeType.c_str() ); +} + }}}} // namespace EE::Window::Backend::SDL3 #endif diff --git a/src/eepp/window/backend/SDL3/clipboardsdl3.hpp b/src/eepp/window/backend/SDL3/clipboardsdl3.hpp index b2f3cbedf..a38cb8f7c 100644 --- a/src/eepp/window/backend/SDL3/clipboardsdl3.hpp +++ b/src/eepp/window/backend/SDL3/clipboardsdl3.hpp @@ -27,6 +27,17 @@ class EE_API ClipboardSDL : public Clipboard { void setPrimarySelectionText( const std::string& text ); + bool setData( DataCallback callback, CleanupCallback cleanup, + const std::vector& mimeTypes ); + + bool clearData(); + + Data getData( const std::string& mimeType ); + + std::vector getMimeTypes(); + + bool hasData( const std::string& mimeType ) const; + protected: friend class WindowSDL; From 64512ab44de6f6702e577493f647473738fbe1fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Tue, 8 Sep 2026 01:57:35 -0300 Subject: [PATCH 5/6] eterm: expand tab controls and fix maximized tab lifecycle - add ellipsized tabs and ecode-style context actions to eterm - support session rename, exclusive mode, tab movement, and maximize/restore - restore maximized tab widgets before close or split operations - track detached tab state directly to prevent stale references in ecode - keep dirty rows valid during asynchronous terminal resize - expose eterm's widget inspector and disable default UI effects - localize the new exclusive-mode action --- bin/assets/i18n/de.xml | 1 + bin/assets/i18n/en.xml | 1 + bin/assets/i18n/fr.xml | 1 + bin/assets/i18n/zh.xml | 1 + .../src/eterm/terminal/terminaldisplay.cpp | 5 +- src/tools/ecode/ecode.cpp | 43 +- src/tools/ecode/ecode.hpp | 3 + src/tools/eterm/eterm.cpp | 461 ++++++++++++++---- 8 files changed, 409 insertions(+), 107 deletions(-) diff --git a/bin/assets/i18n/de.xml b/bin/assets/i18n/de.xml index 04ba49cad..02ddbf361 100644 --- a/bin/assets/i18n/de.xml +++ b/bin/assets/i18n/de.xml @@ -307,6 +307,7 @@ Text selbst. Unicode-Zeichen \, \t, \n, \r und ( ersetzen Escape-Sequenzen Exklusivmodus + Exklusivmodus aktivieren Auszuführende Programmdatei Verzeichnis in Konsole öffnen In Konsole ausführen diff --git a/bin/assets/i18n/en.xml b/bin/assets/i18n/en.xml index 36be4506e..2ee79ecb0 100644 --- a/bin/assets/i18n/en.xml +++ b/bin/assets/i18n/en.xml @@ -291,6 +291,7 @@ is hover a word that represents a color. Replace \, \t, \n, \r and (Unicode characters Escape Sequences Exclusive Mode + Enable Exclusive Mode Executable to Run Open directory in terminal Execute in terminal diff --git a/bin/assets/i18n/fr.xml b/bin/assets/i18n/fr.xml index b1abe9313..de1ce5a2d 100644 --- a/bin/assets/i18n/fr.xml +++ b/bin/assets/i18n/fr.xml @@ -290,6 +290,7 @@ texte lui-même. Remplacer \, \t, \n, \r et (caractères Unicode) Séquences d'échappement Mode exclusif + Activer le mode exclusif Exécutable à exécuter Ouvrir le répertoire dans le terminal Exécuter dans le terminal diff --git a/bin/assets/i18n/zh.xml b/bin/assets/i18n/zh.xml index d347ecc64..575b64cff 100644 --- a/bin/assets/i18n/zh.xml +++ b/bin/assets/i18n/zh.xml @@ -223,6 +223,7 @@ , 和 (Unicode字符 Exclusive Mode + 启用独占模式 在终端打开文件夹 在终端执行 Expand All diff --git a/src/modules/eterm/src/eterm/terminal/terminaldisplay.cpp b/src/modules/eterm/src/eterm/terminal/terminaldisplay.cpp index 26f93222f..40bc5fc7e 100644 --- a/src/modules/eterm/src/eterm/terminal/terminaldisplay.cpp +++ b/src/modules/eterm/src/eterm/terminal/terminaldisplay.cpp @@ -1876,7 +1876,10 @@ void TerminalDisplay::onSizeChange() { mSession->resize( gridSize.getWidth(), gridSize.getHeight(), gridSize.getWidth() * cellSize.getWidth(), gridSize.getHeight() * cellSize.getHeight() ); - mDirtyLines.resize( gridSize.getHeight(), 1 ); + // Rendering must continue using the currently published snapshot until the worker + // publishes the resized grid. consumeSnapshot() updates mRows and mDirtyLines together; + // shrinking the dirty vector here leaves drawGrid() indexing it with the old mRows. + mDirtyLines.resize( std::max( mRows, gridSize.getHeight() ), true ); } if ( mFrameBuffer && ( mFrameBuffer->getWidth() < mSize.getWidth() || diff --git a/src/tools/ecode/ecode.cpp b/src/tools/ecode/ecode.cpp index d9fd15896..8a79ddb2e 100644 --- a/src/tools/ecode/ecode.cpp +++ b/src/tools/ecode/ecode.cpp @@ -556,6 +556,8 @@ void App::maximizeTabWidget() { UIWindow::StyleConfig winCfg; winCfg.WinFlags = UI_WIN_SHADOW | UI_WIN_MODAL | UI_WIN_EPHEMERAL | UI_WIN_NO_DECORATION; UIWindow* win = UIWindow::NewOpt( UIWindow::SIMPLE_LAYOUT, winCfg ); + mMaximizedTabWidgetWindow = win; + mMaximizedTabWidget = curTabWidget; win->setPixelsSize( getUISceneNode()->getPixelsSize() - PixelDensity::dpToPx( 64 ) ); win->setId( "detached_tab_widget_win" ); win->addClass( "tab_widget_cont" ); @@ -587,6 +589,7 @@ void App::maximizeTabWidget() { bool wasFirstSplit = tabWidgetParent->isType( UI_TYPE_SPLITTER ) && tabWidgetParent->asType()->getFirstWidget() == curTabWidget; auto nodeLink = UINodeLink::NewLink( curTabWidget ); + mMaximizedTabWidgetLink = nodeLink; if ( wasFirstSplit ) nodeLink->setClass( "was_first_split" ); curTabWidget->setParent( win ); @@ -626,27 +629,24 @@ void App::maximizeTabWidget() { void App::restoreMaximizedTabWidget() { if ( !App::instance() || !SceneManager::isActive() ) return; - auto sceneNode = appInstance->getUISceneNode(); - if ( !sceneNode ) - return; - auto nodeLink = sceneNode->getRoot()->find( "nodelink_tab_widget" ); - if ( !nodeLink ) - return; - auto splitterParent = nodeLink->getParent(); - nodeLink->setParent( sceneNode ); - auto curTabWidget = sceneNode->getRoot()->find( "detached_tab_widget" ); - if ( !curTabWidget ) + if ( !mMaximizedTabWidgetLink || !mMaximizedTabWidget ) return; + auto* nodeLink = mMaximizedTabWidgetLink; + auto* curTabWidget = mMaximizedTabWidget; + auto* win = mMaximizedTabWidgetWindow; + auto* splitterParent = nodeLink->getParent(); + nodeLink->setParent( mUISceneNode ); curTabWidget->setParent( splitterParent ); curTabWidget->setAnchors( 0 ); curTabWidget->setId( "" ); - if ( nodeLink->asType()->hasClass( "was_first_split" ) && - splitterParent->isType( UI_TYPE_SPLITTER ) ) { + if ( nodeLink->hasClass( "was_first_split" ) && splitterParent->isType( UI_TYPE_SPLITTER ) ) { splitterParent->asType()->swap(); } nodeLink->close(); - auto win = mUISceneNode->find( "detached_tab_widget_win" ); - if ( win ) + mMaximizedTabWidgetLink = nullptr; + mMaximizedTabWidget = nullptr; + mMaximizedTabWidgetWindow = nullptr; + if ( win && !win->isClosing() ) win->close(); } @@ -1734,6 +1734,16 @@ void App::onTabCreated( UITab* tab, UIWidget* ) { if ( tab->getOwnedWidget()->isType( UI_TYPE_CODEEDITOR ) || tab->getOwnedWidget()->isType( UI_TYPE_TERMINAL ) ) { + if ( tab->getOwnedWidget()->isType( UI_TYPE_TERMINAL ) ) { + menu->addSeparator(); + auto* terminal = tab->getOwnedWidget()->asType(); + menu->addCheckBox( i18n( "enable_exclusive_mode", "Enable Exclusive Mode" ), + terminal->getExclusiveMode(), + getKeybind( UITerminal::getExclusiveModeToggleCommandName() ) ) + ->setId( UITerminal::getExclusiveModeToggleCommandName() ); + menuAdd( "rename_session", "Rename Session", "", "terminal-rename" ); + } + menu->addSeparator(); bool enabled = tab->getTabWidget()->getTabCount() > 1; @@ -5073,6 +5083,11 @@ void App::init( InitParameters& params ) { std::function onMsgBoxCloseCb ) -> bool { if ( widget == nullptr || widget->getData() == 0 ) return true; + if ( auto* detachedTabWidget = + mUISceneNode->getRoot()->find( "detached_tab_widget" ); + detachedTabWidget && detachedTabWidget->inParentTreeOf( widget ) ) { + restoreMaximizedTabWidget(); + } if ( widget->isType( UI_TYPE_CODEEDITOR ) ) { return mSplitter->tryCodeEditorClose( widget->asType(), focusTabBehavior, onMsgBoxCloseCb ); diff --git a/src/tools/ecode/ecode.hpp b/src/tools/ecode/ecode.hpp index a1e563675..9bc45dd2b 100644 --- a/src/tools/ecode/ecode.hpp +++ b/src/tools/ecode/ecode.hpp @@ -680,6 +680,9 @@ class App : public UICodeEditorSplitter::Client, public PluginContextProvider { UIMainLayout* mMainLayout{ nullptr }; UILayout* mBaseLayout{ nullptr }; UILayout* mImageLayout{ nullptr }; + UIWindow* mMaximizedTabWidgetWindow{ nullptr }; + UITabWidget* mMaximizedTabWidget{ nullptr }; + UINodeLink* mMaximizedTabWidgetLink{ nullptr }; UITextView* mDocInfo{ nullptr }; std::string mDocInfoLineAbbr; std::string mDocInfoColAbbr; diff --git a/src/tools/eterm/eterm.cpp b/src/tools/eterm/eterm.cpp index 014c4c1dc..3bd8b7e5f 100644 --- a/src/tools/eterm/eterm.cpp +++ b/src/tools/eterm/eterm.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -40,39 +41,76 @@ struct TerminalLaunchConfig { bool closeOnExit{ false }; }; -EE::Window::Window* appWindow{ nullptr }; -UISceneNode* scene{ nullptr }; -UILinearLayout* mainLayout{ nullptr }; -UITabWidgetSplitter* tabSplitter{ nullptr }; -FontTrueType* terminalFont{ nullptr }; -UIIcon* terminalIcon{ nullptr }; -UIMessageBox* closeDialog{ nullptr }; -UIWidget* closeDialogWidget{ nullptr }; -TerminalLaunchConfig terminalConfig; -std::map terminalColorSchemes; -const TerminalColorScheme* selectedColorScheme{ nullptr }; -Float terminalFontSize{ 12 }; -bool warnBeforeClose{ false }; -bool closeApproved{ false }; -bool benchmarkMode{ false }; -Clock secondsCounter; -SmallVector pendingExitCloseTabs; - -void updateWindowTitle(); - -class TerminalSplitterClient : public UITabWidgetSplitter::Client { +class EtermApp { public: - void onTabCreated( UITab* tab, UIWidget* ) override { - if ( terminalIcon ) - tab->setIcon( terminalIcon->createDrawable( PixelDensity::dpToPxI( 12 ) ) ); - } + int run( int argc, char* argv[] ); - void onWidgetFocusChange( UIWidget* ) override { updateWindowTitle(); } + class TerminalSplitterClient : public UITabWidgetSplitter::Client { + public: + explicit TerminalSplitterClient( EtermApp& app ) : mApp( app ) {} + + void onTabCreated( UITab* tab, UIWidget* ) override; + void onWidgetFocusChange( UIWidget* ) override; + + private: + EtermApp& mApp; + }; + + private: + std::string getResourcePath() const; + String i18n( const std::string& key, const String& defaultValue ) const; + void loadColorSchemes( const std::string& resPath ); + static UITerminal* terminalFromTab( UITab* tab ); + void updateWindowTitle(); + bool hasTerminals() const; + static bool hasRunningChildren( UITab* tab ); + void closeTab( UITab* tab ); + void queueExitCloseTab( UITab* tab ); + void queueExitedTabs(); + void requestCloseTab( UITab* tab ); + void renameSession( UITerminal* terminal ); + void maximizeTabWidget( UITabWidget* tabWidget ); + void restoreMaximizedTabWidget(); + void configureTab( UITab* tab ); + UITerminal* createTerminal( UITabWidget* target = nullptr ); + UITerminal* createTerminalSplit( SplitDirection direction, UITerminal* terminal ); + void addTabKeyBindings( UITerminal* terminal ); + bool closeWindow( EE::Window::Window* ); + + EE::Window::Window* appWindow{ nullptr }; + UISceneNode* scene{ nullptr }; + UILinearLayout* mainLayout{ nullptr }; + UITabWidgetSplitter* tabSplitter{ nullptr }; + FontTrueType* terminalFont{ nullptr }; + UIIcon* terminalIcon{ nullptr }; + UIMessageBox* closeDialog{ nullptr }; + UIWidget* closeDialogWidget{ nullptr }; + UIWindow* maximizedTabWidgetWindow{ nullptr }; + UITabWidget* maximizedTabWidget{ nullptr }; + UINodeLink* maximizedTabWidgetLink{ nullptr }; + TerminalLaunchConfig terminalConfig; + std::map terminalColorSchemes; + const TerminalColorScheme* selectedColorScheme{ nullptr }; + Float terminalFontSize{ 12 }; + bool warnBeforeClose{ false }; + bool closeApproved{ false }; + bool benchmarkMode{ false }; + Clock secondsCounter; + SmallVector pendingExitCloseTabs; + TerminalSplitterClient splitterClient{ *this }; }; -TerminalSplitterClient splitterClient; +void EtermApp::TerminalSplitterClient::onTabCreated( UITab* tab, UIWidget* ) { + if ( mApp.terminalIcon ) + tab->setIcon( mApp.terminalIcon->createDrawable( PixelDensity::dpToPxI( 12 ) ) ); + mApp.configureTab( tab ); +} -std::string getResourcePath() { +void EtermApp::TerminalSplitterClient::onWidgetFocusChange( UIWidget* ) { + mApp.updateWindowTitle(); +} + +std::string EtermApp::getResourcePath() const { std::string resPath = Sys::getProcessPath(); #if EE_PLATFORM == EE_PLATFORM_MACOS if ( String::contains( resPath, "ecode.app" ) ) { @@ -92,7 +130,11 @@ std::string getResourcePath() { return resPath; } -void loadColorSchemes( const std::string& resPath ) { +String EtermApp::i18n( const std::string& key, const String& defaultValue ) const { + return scene ? scene->i18n( key, defaultValue ) : defaultValue; +} + +void EtermApp::loadColorSchemes( const std::string& resPath ) { auto colorSchemes = TerminalColorScheme::loadFromFile( resPath + "colorschemes/terminalcolorschemes.conf" ); const std::string configColorSchemesPath = @@ -111,16 +153,16 @@ void loadColorSchemes( const std::string& resPath ) { } } -UITerminal* terminalFromTab( UITab* tab ) { +UITerminal* EtermApp::terminalFromTab( UITab* tab ) { return tab && tab->getOwnedWidget() && tab->getOwnedWidget()->isType( UI_TYPE_TERMINAL ) ? tab->getOwnedWidget()->asType() : nullptr; } -void updateWindowTitle() { +void EtermApp::updateWindowTitle() { if ( !appWindow ) return; - std::string title{ "eterm" }; + String title{ "eterm" }; if ( tabSplitter ) { if ( auto* terminal = tabSplitter->getCurWidget() && tabSplitter->getCurWidget()->isType( UI_TYPE_TERMINAL ) @@ -134,12 +176,12 @@ void updateWindowTitle() { if ( benchmarkMode ) { title += " - "; title += String::toString( appWindow->getFPS() ); - title += " FPS"; + title += " " + i18n( "fps", "FPS" ); } appWindow->setTitle( title ); } -bool hasTerminals() { +bool EtermApp::hasTerminals() const { bool found = false; if ( tabSplitter ) tabSplitter->forEachWidgetStoppable( [&found]( UIWidget* ) { @@ -149,30 +191,30 @@ bool hasTerminals() { return found; } -bool hasRunningChildren( UITab* tab ) { +bool EtermApp::hasRunningChildren( UITab* tab ) { auto* terminal = terminalFromTab( tab ); return terminal && terminal->getTerm() && Sys::processHasChildren( terminal->getTerm()->getProcessId() ); } -void closeTab( UITab* tab ) { +void EtermApp::closeTab( UITab* tab ) { if ( !tabSplitter || !tab || !tab->getOwnedWidget() ) return; tabSplitter->closeTab( tab->getOwnedWidget()->asType(), UITabWidget::FocusTabBehavior::Default ); } -void queueExitCloseTab( UITab* tab ) { +void EtermApp::queueExitCloseTab( UITab* tab ) { if ( tab && std::find( pendingExitCloseTabs.begin(), pendingExitCloseTabs.end(), tab ) == pendingExitCloseTabs.end() ) { pendingExitCloseTabs.emplace_back( tab ); } } -void queueExitedTabs() { +void EtermApp::queueExitedTabs() { if ( !terminalConfig.closeOnExit ) return; - tabSplitter->forEachTab( []( UITab* tab ) { + tabSplitter->forEachTab( [this]( UITab* tab ) { auto* terminal = terminalFromTab( tab ); if ( !terminal || !terminal->getTerm() ) return; @@ -183,7 +225,7 @@ void queueExitedTabs() { } ); } -void requestCloseTab( UITab* tab ) { +void EtermApp::requestCloseTab( UITab* tab ) { if ( !warnBeforeClose || !hasRunningChildren( tab ) ) { closeTab( tab ); return; @@ -192,14 +234,15 @@ void requestCloseTab( UITab* tab ) { return; closeDialog = UIMessageBox::New( UIMessageBox::OK_CANCEL, - "Are you sure you want to close this terminal?\nIt is still running a process." ); + i18n( "close_terminal_running_process_confirm", + "Are you sure you want to close this terminal?\nIt is still running a process." ) ); closeDialogWidget = tab->getOwnedWidget()->asType(); closeDialog->setTitle( "eterm" ); - closeDialog->on( Event::OnConfirm, []( const Event* ) { + closeDialog->on( Event::OnConfirm, [this]( const Event* ) { if ( closeDialogWidget && tabSplitter->ownedWidgetExists( closeDialogWidget ) ) tabSplitter->closeTab( closeDialogWidget, UITabWidget::FocusTabBehavior::Default ); } ); - closeDialog->on( Event::OnClose, []( const Event* ) { + closeDialog->on( Event::OnClose, [this]( const Event* ) { closeDialog = nullptr; closeDialogWidget = nullptr; } ); @@ -207,9 +250,194 @@ void requestCloseTab( UITab* tab ) { closeDialog->showWhenReady(); } -void addTabKeyBindings( UITerminal* terminal, UITab* tab ); +void EtermApp::renameSession( UITerminal* terminal ) { + if ( !terminal ) + return; + auto* msgBox = + UIMessageBox::New( UIMessageBox::INPUT, i18n( "new_terminal_name", "New terminal name:" ) ); + msgBox->setTitle( "eterm" ); + msgBox->getTextInput()->setHint( i18n( "any_name_ellipsis", "Any name..." ) ); + msgBox->setCloseShortcut( { KEY_ESCAPE, KEYMOD_NONE } ); + msgBox->on( Event::OnConfirm, [msgBox, terminal]( const Event* ) { + terminal->setTitle( msgBox->getTextInput()->getText().toUtf8() ); + msgBox->close(); + terminal->setFocus(); + } ); + msgBox->showWhenReady(); +} -UITerminal* createTerminal( UITabWidget* target = nullptr ) { +void EtermApp::maximizeTabWidget( UITabWidget* tabWidget ) { + if ( !tabWidget || scene->getRoot()->hasChild( "detached_tab_widget_win" ) ) + return; + + UIWindow::StyleConfig winCfg; + winCfg.WinFlags = UI_WIN_SHADOW | UI_WIN_MODAL | UI_WIN_EPHEMERAL | UI_WIN_NO_DECORATION; + auto* win = UIWindow::NewOpt( UIWindow::SIMPLE_LAYOUT, winCfg ); + maximizedTabWidgetWindow = win; + maximizedTabWidget = tabWidget; + win->setPixelsSize( scene->getPixelsSize() - PixelDensity::dpToPx( 64 ) ); + win->setId( "detached_tab_widget_win" ); + win->getModalWidget()->onClick( [this]( auto ) { restoreMaximizedTabWidget(); } ); + win->setAnchors( UI_ANCHOR_TOP | UI_ANCHOR_LEFT | UI_ANCHOR_BOTTOM | UI_ANCHOR_RIGHT ); + win->toFront(); + win->center(); + win->setKeyBindingCommand( "close-maximized-tab-widget", + [this] { restoreMaximizedTabWidget(); } ); + win->getKeyBindings().addKeybind( { KEY_ESCAPE }, "close-maximized-tab-widget" ); + win->setCheckEphemeralCloseFn( [this]( Node* focusNode ) { + if ( focusNode->isType( UI_TYPE_POPUPMENU ) ) + return false; + if ( focusNode->getSceneNode()->isUISceneNode() ) { + auto* sceneNode = static_cast( focusNode->getSceneNode() ); + auto* widgetTreeView = sceneNode->getRoot()->hasChild( "widget-tree-view" ); + if ( widgetTreeView && + ( focusNode == widgetTreeView || widgetTreeView->inParentTreeOf( focusNode ) ) ) + return false; + } + return true; + } ); + auto* tabWidgetParent = tabWidget->getParent(); + const bool wasFirstSplit = tabWidgetParent->isType( UI_TYPE_SPLITTER ) && + tabWidgetParent->asType()->getFirstWidget() == tabWidget; + auto* nodeLink = UINodeLink::NewLink( tabWidget ); + maximizedTabWidgetLink = nodeLink; + if ( wasFirstSplit ) + nodeLink->setClass( "was_first_split" ); + tabWidget->setParent( win ); + tabWidget->setId( "detached_tab_widget" ); + tabWidget->setPixelsPosition( Vector2f::Zero ); + tabWidget->setPixelsSize( win->getContainer()->getPixelsSize() ); + tabWidget->setAnchors( UI_ANCHOR_TOP | UI_ANCHOR_LEFT | UI_ANCHOR_BOTTOM | UI_ANCHOR_RIGHT ); + if ( !tabWidget->inParentTreeOf( scene->getEventDispatcher()->getFocusNode() ) ) + tabWidget->getTabSelected()->getOwnedWidget()->setFocus(); + win->on( Event::OnWindowClose, [this]( auto ) { restoreMaximizedTabWidget(); } ); + nodeLink->setParent( tabWidgetParent ); + nodeLink->setId( "nodelink_tab_widget" ); + if ( wasFirstSplit && tabWidgetParent->isType( UI_TYPE_SPLITTER ) ) + tabWidgetParent->asType()->swap(); + + auto* fullscreenImg = UIImage::New(); + fullscreenImg->unsetFlags( UI_AUTO_SIZE ); + fullscreenImg->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + fullscreenImg->setParent( win ); + fullscreenImg->setSize( { 24, tabWidget->getTabBar()->getSize().getHeight() } ); + fullscreenImg->setPosition( { tabWidget->getSize().getWidth() - 24, 0 } ); + fullscreenImg->setAnchors( UI_ANCHOR_TOP | UI_ANCHOR_RIGHT ); + if ( auto* icon = scene->findIcon( "fullscreen" ) ) + fullscreenImg->setDrawable( icon->createDrawable( PixelDensity::dpToPxI( 16 ) ) ); + fullscreenImg->setVerticalAlign( UI_VALIGN_CENTER ); + fullscreenImg->setHorizontalAlign( UI_HALIGN_CENTER ); + fullscreenImg->addClass( "pseudo_anchor" ); + fullscreenImg->toFront(); + fullscreenImg->onClick( [this]( const Event* ) { + restoreMaximizedTabWidget(); + appWindow->getCursorManager()->set( Cursor::SysType::SysArrow ); + } ); +} + +void EtermApp::restoreMaximizedTabWidget() { + if ( !scene || !SceneManager::isActive() ) + return; + if ( !maximizedTabWidgetLink || !maximizedTabWidget ) + return; + auto* nodeLink = maximizedTabWidgetLink; + auto* curTabWidget = maximizedTabWidget; + auto* win = maximizedTabWidgetWindow; + auto* splitterParent = nodeLink->getParent(); + nodeLink->setParent( scene ); + curTabWidget->setParent( splitterParent ); + curTabWidget->setAnchors( 0 ); + curTabWidget->setId( "" ); + if ( nodeLink->hasClass( "was_first_split" ) && splitterParent->isType( UI_TYPE_SPLITTER ) ) { + splitterParent->asType()->swap(); + } + nodeLink->close(); + maximizedTabWidgetLink = nullptr; + maximizedTabWidget = nullptr; + maximizedTabWidgetWindow = nullptr; + if ( win && !win->isClosing() ) + win->close(); +} + +void EtermApp::configureTab( UITab* tab ) { + tab->on( Event::OnCreateContextMenu, [this]( const Event* event ) { + const auto* menuEvent = static_cast( event ); + auto* menu = menuEvent->getMenu(); + auto* clickedTab = event->getNode()->asType(); + auto* terminal = terminalFromTab( clickedTab ); + if ( !menu || !terminal || !clickedTab->getTabWidget() ) + return; + + const auto addItem = [this, menu]( const String& text, const std::string& icon, + const std::string& command ) { + DrawablePtr drawable; + if ( auto* menuIcon = scene->findIcon( icon ) ) + drawable = menuIcon->createDrawable( PixelDensity::dpToPxI( 12 ) ); + auto* item = menu->add( text, std::move( drawable ) ); + item->setId( command ); + return item; + }; + addItem( i18n( "close_tab", "Close Tab" ), "document-close", "close-tab" ); + addItem( i18n( "close_other_tabs", "Close Other Tabs" ), "", "close-other-tabs" ); + addItem( i18n( "close_clean_tabs", "Close Clean Tabs" ), "", "close-clean-tabs" ); + addItem( i18n( "close_all_tabs", "Close All Tabs" ), "", "close-all-tabs" ); + addItem( i18n( "close_tabs_to_the_left", "Close Tabs To The Left" ), "", + "close-tabs-to-the-left" ); + addItem( i18n( "close_tabs_to_the_right", "Close Tabs To The Right" ), "", + "close-tabs-to-the-right" ); + + menu->addSeparator(); + auto* exclusiveMode = + menu->addCheckBox( i18n( "enable_exclusive_mode", "Enable Exclusive Mode" ), + terminal->getExclusiveMode() ); + exclusiveMode->setId( UITerminal::getExclusiveModeToggleCommandName() ); + addItem( i18n( "rename_session", "Rename Session" ), "", "terminal-rename" ); + + menu->addSeparator(); + const bool canMove = clickedTab->getTabWidget()->getTabCount() > 1; + addItem( i18n( "move_tab_to_start", "Move Tab To Start" ), "window", "move-tab-to-start" ) + ->setEnabled( canMove ); + addItem( i18n( "move_tab_to_end", "Move Tab To End" ), "window", "move-tab-to-end" ) + ->setEnabled( canMove ); + + menu->addSeparator(); + addItem( scene->getRoot()->hasChild( "detached_tab_widget_win" ) + ? i18n( "restore_maximized_tab_widget", "Restore Maximized Tab Widget" ) + : i18n( "maximize_tab_widget", "Maximize Tab Widget" ), + "fullscreen", + scene->getRoot()->hasChild( "detached_tab_widget_win" ) + ? "restore-maximized-tab-widget" + : "maximize-tab-widget" ); + + menu->on( Event::OnItemClicked, [this, clickedTab, terminal]( const Event* itemEvent ) { + if ( !itemEvent->getNode()->isType( UI_TYPE_MENUITEM ) ) + return; + const auto& command = itemEvent->getNode()->getId(); + auto* previous = tabSplitter->getCurWidget(); + tabSplitter->setCurrentWidget( terminal ); + + if ( command == "close-clean-tabs" ) { + tabSplitter->tryCloseAllTabs( terminal, UITabWidget::FocusTabBehavior::Default ); + } else if ( command == "move-tab-to-start" ) { + clickedTab->getTabWidget()->moveTab( clickedTab, 0 ); + } else if ( command == "move-tab-to-end" ) { + clickedTab->getTabWidget()->moveTab( clickedTab, + clickedTab->getTabWidget()->getTabCount() ); + } else if ( command == "maximize-tab-widget" ) { + maximizeTabWidget( clickedTab->getTabWidget() ); + } else if ( command == "restore-maximized-tab-widget" ) { + restoreMaximizedTabWidget(); + } else { + terminal->execute( command ); + } + + if ( previous && tabSplitter->ownedWidgetExists( previous ) ) + tabSplitter->setCurrentWidget( previous ); + } ); + } ); +} + +UITerminal* EtermApp::createTerminal( UITabWidget* target ) { if ( !target && tabSplitter ) { auto* current = tabSplitter->getCurWidget(); target = current ? tabSplitter->tabWidgetFromWidget( current ) @@ -239,14 +467,17 @@ UITerminal* createTerminal( UITabWidget* target = nullptr ) { if ( selectedColorScheme ) terminal->setColorScheme( *selectedColorScheme ); - auto* tab = tabSplitter->createWidgetInTabWidget( target, terminal, "Terminal" ).first; - addTabKeyBindings( terminal, tab ); - terminal->on( Event::OnTitleChange, [tab, terminal]( const Event* ) { - tab->setText( terminal->getTitle().empty() ? "Terminal" : terminal->getTitle() ); + auto* tab = + tabSplitter->createWidgetInTabWidget( target, terminal, i18n( "terminal", "Terminal" ) ) + .first; + addTabKeyBindings( terminal ); + terminal->on( Event::OnTitleChange, [this, tab, terminal]( const Event* ) { + tab->setText( terminal->getTitle().empty() ? i18n( "terminal", "Terminal" ) + : String( terminal->getTitle() ) ); if ( tabSplitter->getCurWidget() == terminal ) updateWindowTitle(); } ); - terminal->getTerm()->pushEventCallback( [tab]( const TerminalDisplay::Event& event ) { + terminal->getTerm()->pushEventCallback( [this, tab]( const TerminalDisplay::Event& event ) { if ( terminalConfig.closeOnExit && event.type == TerminalDisplay::EventType::PROCESS_EXIT ) queueExitCloseTab( tab ); } ); @@ -258,38 +489,48 @@ UITerminal* createTerminal( UITabWidget* target = nullptr ) { return terminal; } -UITerminal* createTerminalSplit( SplitDirection direction, UITerminal* terminal ) { +UITerminal* EtermApp::createTerminalSplit( SplitDirection direction, UITerminal* terminal ) { auto* source = terminal ? tabSplitter->tabWidgetFromWidget( terminal ) : nullptr; auto* target = source ? tabSplitter->splitTabWidget( direction, source ) : nullptr; return target ? createTerminal( target ) : nullptr; } -void addTabKeyBindings( UITerminal* terminal, UITab* tab ) { - terminal->setCommand( "create-new-terminal", [] { createTerminal(); } ); - terminal->setCommand( "close-tab", [tab] { requestCloseTab( tab ); } ); - terminal->setCommand( "next-tab", [terminal] { +void EtermApp::addTabKeyBindings( UITerminal* terminal ) { + tabSplitter->registerSplitterCommands( *terminal ); + terminal->setCommand( "create-new-terminal", [this] { createTerminal(); } ); + terminal->setCommand( "debug-widget-tree-view", + [this] { UIWidgetInspector::create( scene ); } ); + terminal->setCommand( "terminal-rename", [this, terminal] { renameSession( terminal ); } ); + terminal->setCommand( UITerminal::getExclusiveModeToggleCommandName(), [terminal] { + terminal->setExclusiveMode( !terminal->getExclusiveMode() ); + } ); + terminal->setCommand( "next-tab", [this, terminal] { if ( auto* tabs = tabSplitter->tabWidgetFromWidget( terminal ) ) tabs->focusNextTab(); } ); - terminal->setCommand( "previous-tab", [terminal] { + terminal->setCommand( "previous-tab", [this, terminal] { if ( auto* tabs = tabSplitter->tabWidgetFromWidget( terminal ) ) tabs->focusPreviousTab(); } ); - terminal->setCommand( "split-right", - [terminal] { createTerminalSplit( SplitDirection::Right, terminal ); } ); - terminal->setCommand( "split-bottom", - [terminal] { createTerminalSplit( SplitDirection::Bottom, terminal ); } ); - terminal->setCommand( "split-left", - [terminal] { createTerminalSplit( SplitDirection::Left, terminal ); } ); - terminal->setCommand( "split-top", - [terminal] { createTerminalSplit( SplitDirection::Top, terminal ); } ); + terminal->setCommand( "split-right", [this, terminal] { + createTerminalSplit( SplitDirection::Right, terminal ); + } ); + terminal->setCommand( "split-bottom", [this, terminal] { + createTerminalSplit( SplitDirection::Bottom, terminal ); + } ); + terminal->setCommand( + "split-left", [this, terminal] { createTerminalSplit( SplitDirection::Left, terminal ); } ); + terminal->setCommand( + "split-top", [this, terminal] { createTerminalSplit( SplitDirection::Top, terminal ); } ); terminal->setCommand( "switch-to-previous-split", - [terminal] { tabSplitter->switchPreviousSplit( terminal ); } ); + [this, terminal] { tabSplitter->switchPreviousSplit( terminal ); } ); terminal->setCommand( "switch-to-next-split", - [terminal] { tabSplitter->switchNextSplit( terminal ); } ); + [this, terminal] { tabSplitter->switchNextSplit( terminal ); } ); terminal->addKeyBinding( { KEY_T, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "create-new-terminal" ); terminal->addKeyBinding( { KEY_W, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "close-tab" ); + terminal->addKeyBinding( { KEY_F11, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, + "debug-widget-tree-view" ); terminal->addKeyBinding( { KEY_PAGEDOWN, KEYMOD_CTRL }, "next-tab" ); terminal->addKeyBinding( { KEY_PAGEUP, KEYMOD_CTRL }, "previous-tab" ); terminal->addKeyBinding( { KEY_TAB, KEYMOD_CTRL }, "next-tab" ); @@ -310,7 +551,7 @@ void addTabKeyBindings( UITerminal* terminal, UITab* tab ) { "switch-to-next-split" ); } -bool closeWindow( EE::Window::Window* ) { +bool EtermApp::closeWindow( EE::Window::Window* ) { if ( closeApproved || !warnBeforeClose ) return true; bool running = false; @@ -321,13 +562,14 @@ bool closeWindow( EE::Window::Window* ) { return false; closeDialog = UIMessageBox::New( UIMessageBox::OK_CANCEL, - "Are you sure you want to close this window? It is still running a process." ); + i18n( "close_window_running_process_confirm", + "Are you sure you want to close this window? It is still running a process." ) ); closeDialog->setTitle( "eterm" ); - closeDialog->on( Event::OnConfirm, []( const Event* ) { + closeDialog->on( Event::OnConfirm, [this]( const Event* ) { closeApproved = true; appWindow->close(); } ); - closeDialog->on( Event::OnClose, []( const Event* ) { + closeDialog->on( Event::OnClose, [this]( const Event* ) { closeDialog = nullptr; closeDialogWidget = nullptr; } ); @@ -336,11 +578,9 @@ bool closeWindow( EE::Window::Window* ) { return false; } -} // namespace - -EE_MAIN_FUNC int main( int argc, char* argv[] ) { +int EtermApp::run( int argc, char* argv[] ) { #ifdef EE_DEBUG - Log::instance()->setLogToStdOut( true ); + Log::instance()->setLogToStdOut( !Runtime::isOffscreen() ); Log::instance()->setLiveWrite( true ); #endif args::ArgumentParser parser( "eterm" ); @@ -407,6 +647,9 @@ EE_MAIN_FUNC int main( int argc, char* argv[] ) { parser, "warn-before-closing", "Prompts for confirmation if a program is still running when closing the terminal.", { "warn-before-closing" } ); + args::Flag alwaysShowTabBar( parser, "always-show-tab-bar", + "Always show the tab bar, even with a single tab.", + { "always-show-tab-bar" } ); args::ValueFlag initialTabs( parser, "tabs", "Number of initial terminal tabs", { "tabs" }, 1 ); @@ -476,6 +719,23 @@ EE_MAIN_FUNC int main( int argc, char* argv[] ) { return EXIT_FAILURE; FileSystem::changeWorkingDirectory( initialWorkingDirectory ); appWindow->setClearColor( RGB( 0, 0, 0 ) ); + scene->getUIThemeManager()->setDefaultEffectsEnabled( false ); + scene->combineStyleSheet( R"css( + TabWidget { + max-tab-width: 200dp; + } + Tab > Tab::Text { + text-overflow: ellipsis; + } + .pseudo_anchor { + tint: var(--floating-icon); + cursor: arrow; + } + .pseudo_anchor:hover { + tint: var(--primary); + cursor: hand; + } + )css" ); auto& resourceScope = *scene->getResourceScope(); auto remixIconFont = FontTrueType::New( "eterm-remixicon", resourceScope ); @@ -539,21 +799,29 @@ EE_MAIN_FUNC int main( int argc, char* argv[] ) { mainLayout->setPixelsSize( appWindow->getSize().asFloat() ); tabSplitter = UITabWidgetSplitter::New( &splitterClient, scene ); - tabSplitter->setHideTabBarOnSingleTab( true ); - tabSplitter->setTabTryCloseCallback( - []( UIWidget* widget, UITabWidget::FocusTabBehavior, std::function ) { - if ( warnBeforeClose ) { - auto* tab = tabSplitter->getTabFromWidget( widget ); - if ( hasRunningChildren( tab ) ) { - requestCloseTab( tab ); - return false; - } + tabSplitter->setHideTabBarOnSingleTab( !alwaysShowTabBar.Get() ); + tabSplitter->setCanCreateSplitFn( [this]( SplitDirection, UIWidget* ) { + restoreMaximizedTabWidget(); + return true; + } ); + tabSplitter->setTabTryCloseCallback( [this]( UIWidget* widget, UITabWidget::FocusTabBehavior, + std::function ) { + if ( auto* detachedTabWidget = scene->getRoot()->find( "detached_tab_widget" ); + widget && detachedTabWidget && detachedTabWidget->inParentTreeOf( widget ) ) { + restoreMaximizedTabWidget(); + } + if ( warnBeforeClose ) { + auto* tab = tabSplitter->getTabFromWidget( widget ); + if ( hasRunningChildren( tab ) ) { + requestCloseTab( tab ); + return false; } - return true; - } ); - tabSplitter->setOnTabWidgetCreateCb( []( UITabWidget* tabs ) { - tabs->on( Event::OnTabSelected, []( const Event* ) { updateWindowTitle(); } ); - tabs->on( Event::OnTabClosed, []( const Event* event ) { + } + return true; + } ); + tabSplitter->setOnTabWidgetCreateCb( [this]( UITabWidget* tabs ) { + tabs->on( Event::OnTabSelected, [this]( const Event* ) { updateWindowTitle(); } ); + tabs->on( Event::OnTabClosed, [this]( const Event* event ) { auto* closedTab = static_cast( event )->getTab(); pendingExitCloseTabs.erase( std::remove( pendingExitCloseTabs.begin(), pendingExitCloseTabs.end(), closedTab ), @@ -575,15 +843,17 @@ EE_MAIN_FUNC int main( int argc, char* argv[] ) { for ( size_t tab = 0; tab < eemax( static_cast( 1 ), initialTabs.Get() ); ++tab ) { if ( !createTerminal( tabs ) ) { - appWindow->showMessageBox( EE::Window::Window::MessageBoxType::Error, "eterm", - "Operating System not supported." ); + appWindow->showMessageBox( + EE::Window::Window::MessageBoxType::Error, "eterm", + i18n( "operating_system_not_supported", "Operating System not supported." ) ); return EXIT_FAILURE; } } - appWindow->setCloseRequestCallback( &closeWindow ); + appWindow->setCloseRequestCallback( + [this]( EE::Window::Window* window ) { return closeWindow( window ); } ); app.setShowMemoryManagerResult( true ); - appWindow->runMainLoop( [] { + appWindow->runMainLoop( [this] { appWindow->getInput()->update(); SceneManager::instance()->update(); queueExitedTabs(); @@ -610,3 +880,10 @@ EE_MAIN_FUNC int main( int argc, char* argv[] ) { } ); return EXIT_SUCCESS; } + +} // namespace + +EE_MAIN_FUNC int main( int argc, char* argv[] ) { + EtermApp app; + return app.run( argc, argv ); +} From 71ede34599cefd6369cfa4fa1926e6befa4bc02d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Wed, 9 Sep 2026 00:56:03 -0300 Subject: [PATCH 6/6] eepp: add reusable UISettingsPanel - add the generic settings model and panel to EE::UI::Tools - support category navigation, filtering, and lazy materialization - add UISettingsPanel model and filtering tests ecode: migrate settings to UISettingsPanel - replace the application-specific settings model - use the generic settings panel implementation - add monitor refresh rate as a frame limit option - normalize UI labels and translations eterm: add persistent configuration and settings panel - persist application preferences and window state - use saved values as defaults for command-line options - persist command-line overrides - add graphical settings with live application - expose renderer, terminal behavior, appearance, font, and scale options --- bin/assets/i18n/en.xml | 6 +- bin/assets/i18n/zh.xml | 2 +- include/eepp/ui.hpp | 1 + include/eepp/ui/tools/uisettingspanel.hpp | 221 ++++ src/eepp/ui/tools/uisettingspanel.cpp | 976 ++++++++++++++++++ .../unit_tests/uisettingspanel_tests.cpp | 135 +++ src/tools/ecode/plugins/git/gitplugin.cpp | 1 + src/tools/ecode/settingsmodel.hpp | 148 --- src/tools/ecode/settingspage.hpp | 7 +- src/tools/ecode/settingspanel.cpp | 922 ++--------------- src/tools/ecode/settingspanel.hpp | 55 +- src/tools/eterm/appconfig.cpp | 165 +++ src/tools/eterm/appconfig.hpp | 103 ++ src/tools/eterm/eterm.cpp | 453 ++++---- src/tools/eterm/eterm.hpp | 112 ++ src/tools/eterm/settingspanel.cpp | 468 +++++++++ src/tools/eterm/settingspanel.hpp | 21 + 17 files changed, 2582 insertions(+), 1214 deletions(-) create mode 100644 include/eepp/ui/tools/uisettingspanel.hpp create mode 100644 src/eepp/ui/tools/uisettingspanel.cpp create mode 100644 src/tests/unit_tests/uisettingspanel_tests.cpp delete mode 100644 src/tools/ecode/settingsmodel.hpp create mode 100644 src/tools/eterm/appconfig.cpp create mode 100644 src/tools/eterm/appconfig.hpp create mode 100644 src/tools/eterm/eterm.hpp create mode 100644 src/tools/eterm/settingspanel.cpp create mode 100644 src/tools/eterm/settingspanel.hpp diff --git a/bin/assets/i18n/en.xml b/bin/assets/i18n/en.xml index 2ee79ecb0..586295b0e 100644 --- a/bin/assets/i18n/en.xml +++ b/bin/assets/i18n/en.xml @@ -887,14 +887,14 @@ the directory tree and in file dialogs to open a folder or file. Type Type to Locate UI Font & Size... - Ui Font Size + UI Font Size UI Language Multisample Anti-Aliasing Level - Ui Panel Font Size + UI Panel Font Size UI Prefers Color Scheme Renderer Renderer Version - Ui Scale Factor + UI Scale Factor UI Theme Copy Copy Containing Folder Path diff --git a/bin/assets/i18n/zh.xml b/bin/assets/i18n/zh.xml index 575b64cff..de3b2502c 100644 --- a/bin/assets/i18n/zh.xml +++ b/bin/assets/i18n/zh.xml @@ -681,7 +681,7 @@ file in the directory tree. Type Type to Locate 界面字体和大小... - Ui字体大小 + UI字体大小 界面语言 多重采样抗锯齿级别 界面面板字体大小 diff --git a/include/eepp/ui.hpp b/include/eepp/ui.hpp index 12b7e0186..9ad7b75f0 100644 --- a/include/eepp/ui.hpp +++ b/include/eepp/ui.hpp @@ -93,6 +93,7 @@ #include #include #include +#include #include #include #include diff --git a/include/eepp/ui/tools/uisettingspanel.hpp b/include/eepp/ui/tools/uisettingspanel.hpp new file mode 100644 index 000000000..a11d9122c --- /dev/null +++ b/include/eepp/ui/tools/uisettingspanel.hpp @@ -0,0 +1,221 @@ +#ifndef EE_UI_TOOLS_UISETTINGSPANEL_HPP +#define EE_UI_TOOLS_UISETTINGSPANEL_HPP + +#include +#include +#include +#include +#include +#include +#include + +namespace pugi { +class xml_node; +} + +namespace EE::UI { +class UICheckBox; +} + +namespace EE::UI::Tools { + +struct SettingView; + +struct EE_API SettingDescriptor { + std::string id; + std::string category; + String name; + String description; + String group; +}; + +struct EE_API BoolPointerSetting { + bool* value{ nullptr }; + std::function apply; +}; + +struct EE_API BoolSetting { + std::function get; + std::function set; +}; + +struct EE_API ChoiceSetting { + std::vector choices; + std::vector descriptions; + std::function get; + std::function set; +}; + +struct EE_API EditableChoiceSetting { + std::vector choices; + std::function get; + std::function set; +}; + +struct EE_API IntegerSetting { + int min{ 0 }; + int max{ 0 }; + std::function get; + std::function set; +}; + +struct EE_API TextSetting { + std::function get; + std::function set; + bool commitOnFocusLoss{ false }; + bool password{ false }; +}; + +struct EE_API FloatSetting { + double min{ 0 }; + double max{ 0 }; + double step{ 0 }; + std::function get; + std::function set; +}; + +struct EE_API ActionSetting { + String buttonText; + std::function action; +}; + +using SettingValue = + std::variant; + +struct EE_API SettingDefinition { + SettingDescriptor descriptor; + SettingValue value; + bool enabled{ true }; +}; + +struct EE_API SettingsCategory { + std::string id; + String parent; + String name; +}; + +struct EE_API SettingsGroup { + std::string category; + String name; + size_t beforeSetting{ 0 }; +}; + +class EE_API SettingsModel { + public: + void clear(); + + bool addCategory( SettingsCategory category ); + + bool addGroup( SettingsGroup group ); + + bool addSetting( SettingDefinition setting ); + + bool hasCategory( const std::string& id ) const; + + const std::vector& categories() const { return mCategories; } + + const std::vector& groups() const { return mGroups; } + + std::vector& settings() { return mSettings; } + + const std::vector& settings() const { return mSettings; } + + private: + std::vector mCategories; + std::vector mGroups; + std::vector mSettings; +}; + +class EE_API UISettingsPanel : public UILinearLayout { + public: + static UISettingsPanel* New( UIWidget* parent ); + + virtual ~UISettingsPanel(); + + SettingsModel& getModel(); + + const SettingsModel& getModel() const; + + bool addCategory( std::string id, String parent, String name ); + + bool addGroup( std::string category, String name ); + + bool addBool( SettingDescriptor descriptor, bool* value, + std::function apply = {} ); + + bool addBool( SettingDescriptor descriptor, std::function get, + std::function set ); + + bool addChoice( SettingDescriptor descriptor, std::vector choices, + std::function get, std::function set, + std::vector choiceDescriptions = {} ); + + bool addEditableChoice( SettingDescriptor descriptor, std::vector choices, + std::function get, std::function set ); + + bool addInteger( SettingDescriptor descriptor, int min, int max, std::function get, + std::function set ); + + bool addText( SettingDescriptor descriptor, std::function get, + std::function set, bool commitOnFocusLoss = false ); + + bool addFloat( SettingDescriptor descriptor, double min, double max, double step, + std::function get, std::function set ); + + bool addAction( SettingDescriptor descriptor, String buttonText, std::function action ); + + void build(); + + void selectCategory( const std::string& category ); + + void setCategoryEnabled( const std::string& category, bool enabled, + const std::string& excludedSetting = {} ); + + void refreshTextSetting( const std::string& id ); + + void setSearchResultsText( String text ); + + void setFilter( String filter ); + + void focusSearch(); + + void focusCategories(); + + bool isBuilt() const; + + protected: + struct Impl; + std::unique_ptr mImpl; + + explicit UISettingsPanel( UIWidget* parent ); + + void selectCategory( Impl& panel, const std::string& category ); + + void addCategory( Impl& panel, const std::string& id, const String& parent, + const String& name ); + + void addSubcategoryHeading( Impl& panel, const std::string& category, const String& name ); + + void setupCategories( Impl& panel ); + + UIWidget* createRow( Impl& panel, SettingDefinition& setting, SettingView& view, + pugi::xml_node layout ); + + UICheckBox* createBoolControl( Impl& panel, SettingDefinition& setting, SettingView& view ); + + void materializeCategory( Impl& panel, const std::string& category ); + + void materializeVisibleSettings( Impl& panel, const String& query ); + + void setCategoryEnabled( Impl& panel, const std::string& category, bool enabled, + const std::string& excludedSetting = {} ); + + void refreshTextSetting( Impl& panel, const std::string& id ); + + void filter( Impl& panel ); +}; + +} // namespace EE::UI::Tools + +#endif diff --git a/src/eepp/ui/tools/uisettingspanel.cpp b/src/eepp/ui/tools/uisettingspanel.cpp new file mode 100644 index 000000000..30d41ce24 --- /dev/null +++ b/src/eepp/ui/tools/uisettingspanel.cpp @@ -0,0 +1,976 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define PUGIXML_HEADER_ONLY +#include + +using namespace EE::UI::Models; + +namespace EE::UI::Tools { + +class UISettingsCategoryModel final : public Model { + public: + struct Node { + std::string id; + std::string text; + Node* parent{ nullptr }; + std::vector children; + std::vector visibleChildren; + }; + + static std::shared_ptr + create( const std::vector>>& categories, + const UnorderedMap& ids ) { + return std::make_shared( categories, ids ); + } + + UISettingsCategoryModel( + const std::vector>>& categories, + const UnorderedMap& ids ) { + mNodes.emplace_back(); + mRoot = &mNodes.back(); + for ( const auto& [parent, children] : categories ) { + std::string parentId; + if ( !children.empty() ) { + auto id = ids.find( parent + '/' + children.front() ); + if ( id != ids.end() ) { + auto separator = id->second.find( '.' ); + parentId = id->second.substr( 0, separator ) + ".*"; + } + } + mNodes.push_back( { std::move( parentId ), parent, mRoot } ); + auto* parentNode = &mNodes.back(); + mRoot->children.push_back( parentNode ); + for ( const auto& child : children ) { + auto id = ids.find( parent + '/' + child ); + mNodes.push_back( + { id == ids.end() ? std::string{} : id->second, child, parentNode } ); + parentNode->children.push_back( &mNodes.back() ); + } + } + filter( {}, {} ); + } + + size_t rowCount( const ModelIndex& parent = {} ) const { + auto* node = parent.isValid() ? static_cast( parent.internalData() ) : mRoot; + return node->visibleChildren.size(); + } + + size_t columnCount( const ModelIndex& = {} ) const { return 1; } + + ModelIndex index( int row, int column, const ModelIndex& parent = {} ) const { + auto* node = parent.isValid() ? static_cast( parent.internalData() ) : mRoot; + if ( row < 0 || column != 0 || static_cast( row ) >= node->visibleChildren.size() ) + return {}; + return createIndex( row, column, node->visibleChildren[row] ); + } + + ModelIndex parentIndex( const ModelIndex& index ) const { + if ( !index.isValid() ) + return {}; + auto* node = static_cast( index.internalData() ); + if ( !node->parent || node->parent == mRoot ) + return {}; + auto* parent = node->parent; + auto found = + std::find( mRoot->visibleChildren.begin(), mRoot->visibleChildren.end(), parent ); + return found == mRoot->visibleChildren.end() + ? ModelIndex{} + : createIndex( std::distance( mRoot->visibleChildren.begin(), found ), 0, + parent ); + } + + Variant data( const ModelIndex& index, ModelRole role = ModelRole::Display ) const { + if ( !index.isValid() || role != ModelRole::Display ) + return {}; + return Variant( static_cast( index.internalData() )->text ); + } + + void filter( const std::string_view query, + const UnorderedSet& matchingCategories ) { + mRoot->visibleChildren.clear(); + for ( auto* parent : mRoot->children ) { + parent->visibleChildren.clear(); + const bool parentMatches = query.empty() || String::icontains( parent->text, query ); + for ( auto* child : parent->children ) { + if ( parentMatches || String::icontains( child->text, query ) || + matchingCategories.contains( child->id ) ) + parent->visibleChildren.push_back( child ); + } + if ( !parent->visibleChildren.empty() ) + mRoot->visibleChildren.push_back( parent ); + } + invalidate( Model::UpdateFlag::InvalidateAllIndexes ); + } + + private: + std::deque mNodes; + Node* mRoot{ nullptr }; +}; + +static constexpr const char* SETTINGS_PANEL_LAYOUT = R"xml( + + + + + + + + + + + + + + +)xml"; + +static std::string settingsRowLayout( std::string_view control ) { + return R"xml( + + + + + + + +)xml" + std::string( control ) + + R"xml( + + + +)xml"; +} + +class SettingsLayoutTemplate { + public: + explicit SettingsLayoutTemplate( const std::string& layout ) { + [[maybe_unused]] auto result = + mDocument.load_string( layout.c_str(), pugi::parse_default | pugi::parse_ws_pcdata ); + eeASSERT( result ); + } + + pugi::xml_node root() const { return mDocument.first_child(); } + + private: + pugi::xml_document mDocument; +}; + +static constexpr const char* SETTINGS_CATEGORY_HEADING_LAYOUT = R"xml( + + + + +)xml"; +static constexpr const char* SETTINGS_SUBCATEGORY_HEADING_LAYOUT = R"xml( + +)xml"; +static const SettingsLayoutTemplate SETTINGS_BOOL_ROW_LAYOUT( settingsRowLayout( + R"xml()xml" ) ); +static const SettingsLayoutTemplate SETTINGS_CHOICE_ROW_LAYOUT( settingsRowLayout( + R"xml()xml" ) ); +static const SettingsLayoutTemplate SETTINGS_EDITABLE_CHOICE_ROW_LAYOUT( settingsRowLayout( + R"xml()xml" ) ); +static const SettingsLayoutTemplate SETTINGS_INTEGER_ROW_LAYOUT( settingsRowLayout( + R"xml()xml" ) ); +static const SettingsLayoutTemplate SETTINGS_TEXT_ROW_LAYOUT( settingsRowLayout( + R"xml()xml" ) ); +static const SettingsLayoutTemplate SETTINGS_ACTION_ROW_LAYOUT( settingsRowLayout( + R"xml()xml" ) ); + +static void disableTabFocusTree( Node* node ) { + if ( node->isWidget() ) + node->asType()->unsetTabFocusable(); + for ( auto* child = node->getFirstChild(); child; child = child->getNextNode() ) + disableTabFocusTree( child ); +} + +struct SettingView { + UIWidget* row{ nullptr }; +}; + +struct SubcategoryHeading { + std::string category; + String name; + UITextView* heading{ nullptr }; +}; + +struct UISettingsPanel::Impl { + EventConnectionList connections; + UITextInput* search{ nullptr }; + UITreeView* categories{ nullptr }; + UIScrollView* scroll{ nullptr }; + UILinearLayout* settings{ nullptr }; + UITextView* pageTitle{ nullptr }; + std::shared_ptr categoryModel; + UIBindingGroup bindingGroup; + SettingsModel model; + std::vector>> categoryItems; + UnorderedMap categoryIds; + UnorderedMap categorySearchText; + UnorderedMap categoryTitles; + UnorderedMap categoryHeadings; + UnorderedMap categorySections; + UnorderedMap categoryContainers; + UnorderedSet materializedCategories; + std::vector subcategoryHeadings; + std::vector settingViews; + std::string selectedCategory; + std::string categoryFilter; + String searchResultsText{ "Search Results" }; + bool built{ false }; +}; + +void SettingsModel::clear() { + mCategories.clear(); + mGroups.clear(); + mSettings.clear(); +} + +bool SettingsModel::addCategory( SettingsCategory category ) { + if ( category.id.empty() || + std::any_of( mCategories.begin(), mCategories.end(), + [&category]( const auto& item ) { return item.id == category.id; } ) ) + return false; + mCategories.emplace_back( std::move( category ) ); + return true; +} + +bool SettingsModel::addGroup( SettingsGroup group ) { + if ( !hasCategory( group.category ) ) + return false; + mGroups.emplace_back( std::move( group ) ); + return true; +} + +bool SettingsModel::addSetting( SettingDefinition setting ) { + if ( setting.descriptor.id.empty() || !hasCategory( setting.descriptor.category ) || + std::any_of( mSettings.begin(), mSettings.end(), [&setting]( const auto& item ) { + return item.descriptor.id == setting.descriptor.id; + } ) ) + return false; + mSettings.emplace_back( std::move( setting ) ); + return true; +} + +bool SettingsModel::hasCategory( const std::string& id ) const { + return std::any_of( mCategories.begin(), mCategories.end(), + [&id]( const auto& item ) { return item.id == id; } ); +} + +UISettingsPanel* UISettingsPanel::New( UIWidget* parent ) { + return eeNew( UISettingsPanel, ( parent ) ); +} + +UISettingsPanel::UISettingsPanel( UIWidget* parent ) : + UILinearLayout( "settingspanel", UIOrientation::Vertical ), mImpl( std::make_unique() ) { + setParent( parent ); + setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::MatchParent ); + auto* layout = getUISceneNode()->loadLayoutFromString( SETTINGS_PANEL_LAYOUT, this ); + mImpl->search = layout->find( "settings_filter" ); + mImpl->categories = layout->find( "settings_categories" ); + mImpl->settings = layout->find( "settings_rows" ); + mImpl->pageTitle = layout->find( "settings_page_title" ); + mImpl->scroll = layout->find( "settings_scroll" ); + mImpl->scroll->setVerticalScrollMode( ScrollBarMode::Auto ); + mImpl->scroll->setHorizontalScrollMode( ScrollBarMode::AlwaysOff ); + disableTabFocusTree( mImpl->categories->getVerticalScrollBar() ); + disableTabFocusTree( mImpl->categories->getHorizontalScrollBar() ); + disableTabFocusTree( mImpl->scroll->getVerticalScrollBar() ); + disableTabFocusTree( mImpl->scroll->getHorizontalScrollBar() ); +} + +UISettingsPanel::~UISettingsPanel() = default; + +SettingsModel& UISettingsPanel::getModel() { + return mImpl->model; +} + +const SettingsModel& UISettingsPanel::getModel() const { + return mImpl->model; +} + +bool UISettingsPanel::addCategory( std::string id, String parent, String name ) { + if ( mImpl->built || !mImpl->model.addCategory( { id, parent, name } ) ) + return false; + addCategory( *mImpl, id, parent, name ); + return true; +} + +bool UISettingsPanel::addGroup( std::string category, String name ) { + if ( mImpl->built ) + return false; + return mImpl->model.addGroup( + { std::move( category ), std::move( name ), mImpl->model.settings().size() } ); +} + +bool UISettingsPanel::addBool( SettingDescriptor descriptor, bool* value, + std::function apply ) { + return !mImpl->built && + mImpl->model.addSetting( + { std::move( descriptor ), BoolPointerSetting{ value, std::move( apply ) } } ); +} + +bool UISettingsPanel::addBool( SettingDescriptor descriptor, std::function get, + std::function set ) { + return !mImpl->built && + mImpl->model.addSetting( + { std::move( descriptor ), BoolSetting{ std::move( get ), std::move( set ) } } ); +} + +bool UISettingsPanel::addChoice( SettingDescriptor descriptor, std::vector choices, + std::function get, std::function set, + std::vector choiceDescriptions ) { + return !mImpl->built && + mImpl->model.addSetting( + { std::move( descriptor ), + ChoiceSetting{ std::move( choices ), std::move( choiceDescriptions ), + std::move( get ), std::move( set ) } } ); +} + +bool UISettingsPanel::addEditableChoice( SettingDescriptor descriptor, std::vector choices, + std::function get, + std::function set ) { + return !mImpl->built && + mImpl->model.addSetting( { std::move( descriptor ), + EditableChoiceSetting{ std::move( choices ), std::move( get ), + std::move( set ) } } ); +} + +bool UISettingsPanel::addInteger( SettingDescriptor descriptor, int min, int max, + std::function get, std::function set ) { + return !mImpl->built && min <= max && + mImpl->model.addSetting( + { std::move( descriptor ), + IntegerSetting{ min, max, std::move( get ), std::move( set ) } } ); +} + +bool UISettingsPanel::addText( SettingDescriptor descriptor, std::function get, + std::function set, + bool commitOnFocusLoss ) { + return !mImpl->built && + mImpl->model.addSetting( + { std::move( descriptor ), + TextSetting{ std::move( get ), std::move( set ), commitOnFocusLoss } } ); +} + +bool UISettingsPanel::addFloat( SettingDescriptor descriptor, double min, double max, double step, + std::function get, std::function set ) { + return !mImpl->built && min <= max && step > 0 && + mImpl->model.addSetting( + { std::move( descriptor ), + FloatSetting{ min, max, step, std::move( get ), std::move( set ) } } ); +} + +bool UISettingsPanel::addAction( SettingDescriptor descriptor, String buttonText, + std::function action ) { + return !mImpl->built && action && + mImpl->model.addSetting( + { std::move( descriptor ), + ActionSetting{ std::move( buttonText ), std::move( action ) } } ); +} + +void UISettingsPanel::build() { + if ( mImpl->built ) + return; + mImpl->built = true; + mImpl->settings->beginAttributesTransaction(); + materializeCategory( *mImpl, mImpl->selectedCategory ); + setupCategories( *mImpl ); + mImpl->settings->endAttributesTransaction(); + selectCategory( *mImpl, mImpl->selectedCategory ); + mImpl->connections += mImpl->search->connect( Event::OnTextChanged, [this]( const Event* ) { + String query = mImpl->search->getText(); + query.trim(); + const UintPtr debounceTag = reinterpret_cast( this ); + if ( query.size() < 2 ) { + mImpl->search->removeActionsByTag( debounceTag ); + filter( *mImpl ); + return; + } + mImpl->search->debounce( [this] { filter( *mImpl ); }, Milliseconds( 150 ), debounceTag ); + } ); +} + +void UISettingsPanel::selectCategory( const std::string& category ) { + selectCategory( *mImpl, category ); +} + +void UISettingsPanel::setCategoryEnabled( const std::string& category, bool enabled, + const std::string& excludedSetting ) { + setCategoryEnabled( *mImpl, category, enabled, excludedSetting ); +} + +void UISettingsPanel::refreshTextSetting( const std::string& id ) { + refreshTextSetting( *mImpl, id ); +} + +void UISettingsPanel::setSearchResultsText( String text ) { + mImpl->searchResultsText = std::move( text ); +} + +void UISettingsPanel::setFilter( String filterText ) { + const UintPtr debounceTag = reinterpret_cast( this ); + mImpl->search->setText( std::move( filterText ) ); + mImpl->search->removeActionsByTag( debounceTag ); + filter( *mImpl ); +} + +void UISettingsPanel::focusSearch() { + mImpl->search->setFocus(); + mImpl->search->getDocument().selectAll(); +} + +void UISettingsPanel::focusCategories() { + auto selected = mImpl->categories->getSelection().first(); + if ( selected.isValid() ) + mImpl->categories->setSelection( selected ); + mImpl->categories->setFocus(); +} + +bool UISettingsPanel::isBuilt() const { + return mImpl->built; +} + +void UISettingsPanel::addCategory( Impl& panel, const std::string& id, const String& parent, + const String& name ) { + const auto parentText = parent.toUtf8(); + const auto nameText = name.toUtf8(); + auto parentItems = + std::find_if( panel.categoryItems.begin(), panel.categoryItems.end(), + [&parentText]( const auto& item ) { return item.first == parentText; } ); + if ( parentItems == panel.categoryItems.end() ) { + panel.categoryItems.emplace_back( parentText, std::vector{ nameText } ); + } else { + parentItems->second.emplace_back( nameText ); + } + panel.categoryIds[parentText + '/' + nameText] = id; + panel.categorySearchText[id] = parent + " " + name; + panel.categoryTitles[id] = name; + auto* section = + getUISceneNode()->loadLayoutFromString( SETTINGS_CATEGORY_HEADING_LAYOUT, panel.settings ); + auto* heading = section->find( "settings_category_heading" ); + heading->setText( name ); + heading->setId( "settings_category_" + id ); + panel.categoryHeadings[id] = heading; + panel.categorySections[id] = section; + panel.categoryContainers[id] = section->find( "settings_category_rows" ); + if ( panel.selectedCategory.empty() ) + panel.selectedCategory = id; +} + +void UISettingsPanel::selectCategory( Impl& panel, const std::string& category ) { + if ( category.empty() || !panel.categories ) + return; + String title; + if ( String::endsWith( category, ".*" ) ) { + const std::string prefix = category.substr( 0, category.size() - 1 ); + for ( const auto& [parent, children] : panel.categoryItems ) { + if ( children.empty() ) + continue; + auto id = panel.categoryIds.find( parent + '/' + children.front() ); + if ( id != panel.categoryIds.end() && String::startsWith( id->second, prefix ) ) { + title = String::fromUtf8( parent ); + break; + } + } + } else if ( auto found = panel.categoryTitles.find( category ); + found != panel.categoryTitles.end() ) { + title = found->second; + } + if ( title.empty() ) + return; + panel.selectedCategory = category; + auto index = panel.categories->findRowWithText( + title.toUtf8(), true, UIAbstractView::FindRowWithTextMatchKind::Equals ); + if ( index.isValid() ) + panel.categories->setSelection( index ); + filter( panel ); + panel.scroll->getVerticalScrollBar()->setValue( 0, false ); +} + +void UISettingsPanel::addSubcategoryHeading( Impl& panel, const std::string& category, + const String& name ) { + panel.model.addGroup( { category, name, panel.model.settings().size() } ); +} + +void UISettingsPanel::setupCategories( Impl& panel ) { + auto model = UISettingsCategoryModel::create( panel.categoryItems, panel.categoryIds ); + panel.categoryModel = model; + panel.categories->setHeadersVisible( false ); + panel.categories->setAutoExpandOnSingleColumn( true ); + panel.categories->setFocusOnSelection( true ); + panel.categories->setModel( model ); + panel.categories->expandAll(); + panel.connections += + panel.categories->connect( Event::OnSelectionChanged, [this, &panel]( const Event* ) { + auto index = panel.categories->getSelection().first(); + if ( !index.isValid() ) + return; + auto* node = static_cast( index.internalData() ); + if ( !node || node->id.empty() ) + return; + panel.selectedCategory = node->id; + panel.pageTitle->setText( node->text ); + filter( panel ); + panel.scroll->getVerticalScrollBar()->setValue( 0, false ); + } ); +} + +UIWidget* UISettingsPanel::createRow( Impl& panel, SettingDefinition& setting, SettingView& view, + pugi::xml_node layout ) { + auto& binding = setting.descriptor; + auto container = panel.categoryContainers.find( binding.category ); + eeASSERT( container != panel.categoryContainers.end() ); + auto* row = getUISceneNode()->loadLayoutNodes( layout, container->second, 0 ); + row->setId( "setting_" + binding.id ); + row->find( "setting_name" )->setText( binding.name ); + auto* description = row->find( "setting_description" ); + description->setText( binding.description ); + view.row = row; + return row; +} + +UICheckBox* UISettingsPanel::createBoolControl( Impl& panel, SettingDefinition& setting, + SettingView& view ) { + auto* row = createRow( panel, setting, view, SETTINGS_BOOL_ROW_LAYOUT.root() ); + row->addClass( "settings_boolean_option" ); + auto* check = row->find( "setting_control_widget" ); + auto toggle = [check]( const Event* event ) { + if ( event->asMouseEvent()->getFlags() & EE_BUTTON_LMASK ) + check->setChecked( !check->isChecked() ); + }; + panel.connections += + row->find( "setting_name" )->connect( Event::MouseClick, toggle ); + panel.connections += + row->find( "setting_description" )->connect( Event::MouseClick, toggle ); + panel.connections += row->find( "setting_info" ) + ->connect( Event::MouseClick, std::move( toggle ) ); + return check; +} + +void UISettingsPanel::refreshTextSetting( Impl& panel, const std::string& id ) { + const auto& settings = panel.model.settings(); + for ( size_t i = 0; i < settings.size(); ++i ) { + if ( settings[i].descriptor.id != id || i >= panel.settingViews.size() || + !panel.settingViews[i].row ) + continue; + auto* value = std::get_if( &settings[i].value ); + auto* input = panel.settingViews[i].row->find( "setting_control_widget" ); + if ( value && input ) + input->setText( String::fromUtf8( value->get() ) ); + return; + } +} + +static void setNodeTreeEnabled( Node* node, bool enabled ); + +void UISettingsPanel::materializeCategory( Impl& panel, const std::string& category ) { + if ( category.empty() || panel.materializedCategories.contains( category ) ) + return; + auto container = panel.categoryContainers.find( category ); + if ( container == panel.categoryContainers.end() ) + return; + auto& settings = panel.model.settings(); + panel.settingViews.resize( settings.size() ); + container->second->beginAttributesTransaction(); + for ( size_t i = 0; i < settings.size(); ++i ) { + auto& setting = settings[i]; + if ( setting.descriptor.category != category ) + continue; + for ( const auto& group : panel.model.groups() ) { + if ( group.category != category || group.beforeSetting != i ) + continue; + auto* heading = + getUISceneNode() + ->loadLayoutFromString( SETTINGS_SUBCATEGORY_HEADING_LAYOUT, container->second ) + ->asType(); + heading->setText( group.name ); + panel.subcategoryHeadings.push_back( { group.category, group.name, heading } ); + } + auto& view = panel.settingViews[i]; + if ( auto* value = std::get_if( &setting.value ) ) { + auto* check = createBoolControl( panel, setting, view ); + auto binding = UIDataBind::New( value->value, check, + UIValueConverter::converterBool() ); + binding->onValueChangeCb = value->apply; + panel.bindingGroup += std::move( binding ); + } else if ( auto* value = std::get_if( &setting.value ) ) { + auto* check = createBoolControl( panel, setting, view ); + check->setChecked( value->get() ); + panel.connections += + check->connect( Event::OnValueChange, [check, value]( const Event* ) { + value->set( check->isChecked() ); + } ); + } else if ( auto* value = std::get_if( &setting.value ) ) { + auto* row = createRow( panel, setting, view, SETTINGS_CHOICE_ROW_LAYOUT.root() ); + auto* dropDown = row->find( "setting_control_widget" ); + auto model = ItemListOwnerModel::create( value->choices ); + dropDown->setModel( model ); + const size_t selected = value->get(); + if ( selected < value->choices.size() ) { + dropDown->getListView()->getSelection().set( model->index( selected, 0 ) ); + dropDown->setText( value->choices[selected] ); + } + if ( selected < value->descriptions.size() ) + dropDown->setTooltipText( value->descriptions[selected] ); + panel.connections += + dropDown->connect( Event::OnValueChange, [dropDown, value]( const Event* ) { + if ( dropDown->getListView()->getSelection().isEmpty() ) + return; + const size_t selected = dropDown->getListView()->getSelection().first().row(); + if ( selected < value->descriptions.size() ) + dropDown->setTooltipText( value->descriptions[selected] ); + value->set( selected ); + } ); + } else if ( auto* value = std::get_if( &setting.value ) ) { + auto* row = + createRow( panel, setting, view, SETTINGS_EDITABLE_CHOICE_ROW_LAYOUT.root() ); + auto* combo = row->find( "setting_control_widget" ); + for ( const auto& choice : value->choices ) + combo->getListBox()->addListBoxItem( choice ); + combo->setText( value->get() ); + panel.connections += + combo->connect( Event::OnValueChange, [combo, value]( const Event* ) { + if ( !value->set( combo->getText() ) ) { + combo->addClass( "error" ); + combo->getDropDownList()->addClass( "error" ); + return; + } + combo->removeClass( "error" ); + combo->getDropDownList()->removeClass( "error" ); + } ); + } else if ( auto* value = std::get_if( &setting.value ) ) { + auto* row = createRow( panel, setting, view, SETTINGS_INTEGER_ROW_LAYOUT.root() ); + auto* spin = row->find( "setting_control_widget" ); + spin->setMinValue( value->min )->setMaxValue( value->max ); + spin->unsetTabFocusable(); + spin->getButtonPushUp()->asType()->unsetTabFocusable(); + spin->getButtonPushDown()->asType()->unsetTabFocusable(); + spin->setValue( value->get() ); + panel.connections += + spin->connect( Event::OnValueChange, [spin, value]( const Event* ) { + value->set( static_cast( spin->getValue() ) ); + } ); + } else if ( auto* value = std::get_if( &setting.value ) ) { + auto* row = createRow( panel, setting, view, SETTINGS_TEXT_ROW_LAYOUT.root() ); + auto* input = row->find( "setting_control_widget" ); + if ( value->password ) + input->setMode( UITextInput::TextInputMode::Password ); + input->setText( String::fromUtf8( value->get() ) ); + auto commit = [input, value] { + if ( !value->set( input->getText().toUtf8() ) ) { + input->addClass( "error" ); + return; + } + input->removeClass( "error" ); + }; + if ( value->commitOnFocusLoss ) { + const auto debounceTag = reinterpret_cast( input ); + panel.connections += input->connect( + Event::OnTextChanged, [input, commit, debounceTag]( const Event* ) { + input->debounce( commit, Milliseconds( 500 ), debounceTag ); + } ); + auto flush = [input, commit, debounceTag]( const Event* ) { + input->removeActionsByTag( debounceTag ); + commit(); + }; + panel.connections += input->connect( Event::OnPressEnter, flush ); + panel.connections += input->connect( Event::OnFocusLoss, std::move( flush ) ); + } else { + panel.connections += + input->connect( Event::OnTextChanged, + [commit = std::move( commit )]( const Event* ) { commit(); } ); + } + } else if ( auto* value = std::get_if( &setting.value ) ) { + auto* row = createRow( panel, setting, view, SETTINGS_INTEGER_ROW_LAYOUT.root() ); + auto* spin = row->find( "setting_control_widget" ); + spin->setMinValue( value->min )->setMaxValue( value->max )->setClickStep( value->step ); + spin->allowFloatingPoint( true )->setValue( value->get() ); + spin->unsetTabFocusable(); + spin->getButtonPushUp()->asType()->unsetTabFocusable(); + spin->getButtonPushDown()->asType()->unsetTabFocusable(); + panel.connections += + spin->connect( Event::OnValueChange, + [spin, value]( const Event* ) { value->set( spin->getValue() ); } ); + } else if ( auto* value = std::get_if( &setting.value ) ) { + auto* row = createRow( panel, setting, view, SETTINGS_ACTION_ROW_LAYOUT.root() ); + auto* button = row->find( "setting_control_widget" ); + button->setText( value->buttonText ); + panel.connections += button->connect( Event::MouseClick, [value]( const Event* event ) { + if ( event->asMouseEvent()->getFlags() & EE_BUTTON_LMASK ) + value->action(); + } ); + } + if ( view.row && !setting.enabled ) + setNodeTreeEnabled( view.row, false ); + } + container->second->endAttributesTransaction(); + panel.materializedCategories.insert( category ); +} + +void UISettingsPanel::materializeVisibleSettings( Impl& panel, const String& query ) { + const std::string queryUtf8 = query.toUtf8(); + const bool aggregate = String::endsWith( panel.selectedCategory, ".*" ); + const std::string aggregatePrefix = + aggregate ? panel.selectedCategory.substr( 0, panel.selectedCategory.size() - 1 ) + : std::string{}; + for ( const auto& category : panel.model.categories() ) { + bool materialize = + query.empty() ? category.id == panel.selectedCategory || + ( aggregate && String::startsWith( category.id, aggregatePrefix ) ) + : String::icontains( category.parent, query ) || + String::icontains( category.name, query ); + if ( !materialize && !query.empty() ) { + for ( const auto& setting : panel.model.settings() ) { + const auto& descriptor = setting.descriptor; + if ( descriptor.category == category.id && + ( String::icontains( descriptor.name, query ) || + String::icontains( descriptor.description, query ) || + String::icontains( descriptor.group, query ) || + String::icontains( descriptor.id, queryUtf8 ) ) ) { + materialize = true; + break; + } + } + } + if ( materialize ) + materializeCategory( panel, category.id ); + } +} + +static void setNodeTreeEnabled( Node* node, bool enabled ) { + node->setEnabled( enabled ); + for ( Node* child = node->getFirstChild(); child; child = child->getNextNode() ) + setNodeTreeEnabled( child, enabled ); +} + +void UISettingsPanel::setCategoryEnabled( Impl& panel, const std::string& category, bool enabled, + const std::string& excludedSetting ) { + auto& settings = panel.model.settings(); + for ( size_t i = 0; i < settings.size(); ++i ) { + auto& setting = settings[i]; + const auto& descriptor = setting.descriptor; + if ( descriptor.category != category || descriptor.id == excludedSetting ) + continue; + setting.enabled = enabled; + if ( i < panel.settingViews.size() && panel.settingViews[i].row ) + setNodeTreeEnabled( panel.settingViews[i].row, enabled ); + } +} + +void UISettingsPanel::filter( Impl& panel ) { + String query = panel.search ? panel.search->getText() : String{}; + query.trim().toLower(); + if ( query.size() < 2 ) + query.clear(); + materializeVisibleSettings( panel, query ); + if ( !query.empty() ) { + panel.pageTitle->setText( panel.searchResultsText ); + } else if ( auto title = panel.categoryTitles.find( panel.selectedCategory ); + title != panel.categoryTitles.end() ) { + panel.pageTitle->setText( title->second ); + } + UnorderedSet matchingCategories; + const std::string queryUtf8 = query.toUtf8(); + const bool aggregate = String::endsWith( panel.selectedCategory, ".*" ); + const std::string aggregatePrefix = + aggregate ? panel.selectedCategory.substr( 0, panel.selectedCategory.size() - 1 ) + : std::string{}; + if ( !query.empty() ) { + for ( const auto& setting : panel.model.settings() ) { + const auto& binding = setting.descriptor; + if ( String::icontains( binding.name, query ) || + String::icontains( binding.description, query ) || + String::icontains( binding.group, query ) || + String::icontains( binding.id, queryUtf8 ) ) + matchingCategories.insert( binding.category ); + } + } + if ( queryUtf8 != panel.categoryFilter ) { + panel.categoryFilter = queryUtf8; + if ( auto model = std::static_pointer_cast( panel.categoryModel ) ) + model->filter( queryUtf8, matchingCategories ); + } + panel.categories->expandAll(); + for ( auto& [category, heading] : panel.categoryHeadings ) + heading->setVisible( query.empty() && aggregate && + String::startsWith( category, aggregatePrefix ) ); + const auto& settings = panel.model.settings(); + for ( size_t i = 0; i < settings.size(); ++i ) { + const auto& binding = settings[i].descriptor; + const auto categoryName = panel.categorySearchText.find( binding.category ); + const bool categoryMatches = categoryName != panel.categorySearchText.end() && + String::icontains( categoryName->second, query ); + const bool matches = + query.empty() + ? binding.category == panel.selectedCategory || + ( aggregate && String::startsWith( binding.category, aggregatePrefix ) ) + : categoryMatches || String::icontains( binding.name, query ) || + String::icontains( binding.description, query ) || + String::icontains( binding.group, query ) || + String::icontains( binding.id, queryUtf8 ); + if ( i < panel.settingViews.size() && panel.settingViews[i].row ) + panel.settingViews[i].row->setVisible( matches ); + } + for ( const auto& category : panel.model.categories() ) { + auto section = panel.categorySections.find( category.id ); + if ( section == panel.categorySections.end() ) + continue; + bool visible = false; + for ( size_t i = 0; i < settings.size(); ++i ) { + if ( settings[i].descriptor.category == category.id && i < panel.settingViews.size() && + panel.settingViews[i].row && panel.settingViews[i].row->isVisible() ) { + visible = true; + break; + } + } + section->second->setVisible( visible ); + } + for ( auto& subcategory : panel.subcategoryHeadings ) { + if ( !subcategory.heading ) + continue; + const bool hasVisibleSetting = std::any_of( + settings.begin(), settings.end(), [&panel, &subcategory]( const auto& setting ) { + const auto& binding = setting.descriptor; + const size_t index = &setting - panel.model.settings().data(); + return binding.category == subcategory.category && + binding.group == subcategory.name && index < panel.settingViews.size() && + panel.settingViews[index].row && panel.settingViews[index].row->isVisible(); + } ); + subcategory.heading->setVisible( hasVisibleSetting ); + } +} + +} // namespace EE::UI::Tools diff --git a/src/tests/unit_tests/uisettingspanel_tests.cpp b/src/tests/unit_tests/uisettingspanel_tests.cpp new file mode 100644 index 000000000..a4db1c0ee --- /dev/null +++ b/src/tests/unit_tests/uisettingspanel_tests.cpp @@ -0,0 +1,135 @@ +#include "utest.hpp" + +#include +#include +#include +#include +#include +#include +#include + +using namespace EE; +using namespace EE::System; +using namespace EE::UI; +using namespace EE::UI::Tools; +using namespace EE::Window; + +UTEST( UISettingsPanelModel, validatesCategoriesGroupsAndSettings ) { + SettingsModel model; + + EXPECT_FALSE( model.addCategory( { {}, "General", "Behavior" } ) ); + EXPECT_TRUE( model.addCategory( { "general.behavior", "General", "Behavior" } ) ); + EXPECT_FALSE( model.addCategory( { "general.behavior", "General", "Other" } ) ); + EXPECT_TRUE( model.hasCategory( "general.behavior" ) ); + EXPECT_FALSE( model.hasCategory( "general.missing" ) ); + + EXPECT_FALSE( model.addGroup( { "general.missing", "Display", 0 } ) ); + EXPECT_TRUE( model.addGroup( { "general.behavior", "Display", 0 } ) ); + + bool value = false; + EXPECT_FALSE( model.addSetting( { { {}, "general.behavior", "Empty", "Empty identifier", {} }, + BoolPointerSetting{ &value, {} } } ) ); + EXPECT_FALSE( + model.addSetting( { { "missing", "general.missing", "Missing", "Missing category", {} }, + BoolPointerSetting{ &value, {} } } ) ); + EXPECT_TRUE( model.addSetting( + { { "enabled", "general.behavior", "Enabled", "Enable the option", "Display" }, + BoolPointerSetting{ &value, {} } } ) ); + EXPECT_FALSE( model.addSetting( + { { "enabled", "general.behavior", "Duplicate", "Duplicate identifier", {} }, + BoolPointerSetting{ &value, {} } } ) ); + + EXPECT_EQ( 1u, model.categories().size() ); + EXPECT_EQ( 1u, model.groups().size() ); + EXPECT_EQ( 1u, model.settings().size() ); + EXPECT_STDSTREQ( "enabled", model.settings().front().descriptor.id ); +} + +UTEST( UISettingsPanelModel, clearRemovesAllRules ) { + SettingsModel model; + bool value = true; + EXPECT_TRUE( model.addCategory( { "editor.document", "Editor", "Document" } ) ); + EXPECT_TRUE( model.addGroup( { "editor.document", "Files", 0 } ) ); + EXPECT_TRUE( + model.addSetting( { { "trim", "editor.document", "Trim", "Trim whitespace", "Files" }, + BoolPointerSetting{ &value, {} } } ) ); + + model.clear(); + + EXPECT_TRUE( model.categories().empty() ); + EXPECT_TRUE( model.groups().empty() ); + EXPECT_TRUE( model.settings().empty() ); +} + +UTEST( UISettingsPanel, buildsAndMaterializesCategoriesLazily ) { + UIApplication app( + WindowSettings( 800, 600, "eepp - UISettingsPanel Test", WindowStyle::Default, + WindowBackend::Default, 32 ), + UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) ); + auto* panel = UISettingsPanel::New( app.getUI()->getRoot() ); + bool firstValue = false; + bool secondValue = true; + + EXPECT_TRUE( panel->addCategory( "general.behavior", "General", "Behavior" ) ); + EXPECT_TRUE( panel->addCategory( "editor.display", "Editor", "Display" ) ); + EXPECT_TRUE( panel->addBool( { "first", "general.behavior", "First", "The first setting", {} }, + &firstValue ) ); + EXPECT_TRUE( panel->addBool( { "second", "editor.display", "Second", "The second setting", {} }, + &secondValue ) ); + EXPECT_EQ( nullptr, panel->find( "setting_first" ) ); + EXPECT_EQ( nullptr, panel->find( "setting_second" ) ); + + panel->build(); + + EXPECT_TRUE( panel->isBuilt() ); + auto* firstRow = panel->find( "setting_first" ); + EXPECT_NE( nullptr, firstRow ); + EXPECT_TRUE( firstRow->isVisible() ); + EXPECT_EQ( nullptr, panel->find( "setting_second" ) ); + EXPECT_FALSE( panel->addCategory( "late.category", "Late", "Category" ) ); + + panel->selectCategory( "editor.display" ); + auto* secondRow = panel->find( "setting_second" ); + EXPECT_NE( nullptr, secondRow ); + EXPECT_FALSE( firstRow->isVisible() ); + EXPECT_TRUE( secondRow->isVisible() ); + EXPECT_STDSTREQ( "Display", + panel->find( "settings_page_title" )->getText().toUtf8() ); + + panel->setCategoryEnabled( "editor.display", false ); + EXPECT_FALSE( secondRow->isEnabled() ); + panel->setCategoryEnabled( "editor.display", true ); + EXPECT_TRUE( secondRow->isEnabled() ); +} + +UTEST( UISettingsPanel, filtersAcrossUnmaterializedCategories ) { + UIApplication app( + WindowSettings( 800, 600, "eepp - UISettingsPanel Filter Test", WindowStyle::Default, + WindowBackend::Default, 32 ), + UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) ); + auto* panel = UISettingsPanel::New( app.getUI()->getRoot() ); + bool firstValue = false; + bool secondValue = false; + panel->setSearchResultsText( "Filtered Settings" ); + EXPECT_TRUE( panel->addCategory( "general.behavior", "General", "Behavior" ) ); + EXPECT_TRUE( panel->addCategory( "editor.display", "Editor", "Display" ) ); + EXPECT_TRUE( panel->addBool( { "first", "general.behavior", "First", "Ordinary option", {} }, + &firstValue ) ); + EXPECT_TRUE( panel->addBool( + { "second", "editor.display", "Second", "Unique searchable phrase", {} }, &secondValue ) ); + panel->build(); + + panel->setFilter( "searchable" ); + + auto* firstRow = panel->find( "setting_first" ); + auto* secondRow = panel->find( "setting_second" ); + EXPECT_NE( nullptr, secondRow ); + EXPECT_FALSE( firstRow->isVisible() ); + EXPECT_TRUE( secondRow->isVisible() ); + EXPECT_STDSTREQ( "Filtered Settings", + panel->find( "settings_page_title" )->getText().toUtf8() ); + + panel->setFilter( {} ); + EXPECT_TRUE( firstRow->isVisible() ); + EXPECT_FALSE( secondRow->isVisible() ); +} diff --git a/src/tools/ecode/plugins/git/gitplugin.cpp b/src/tools/ecode/plugins/git/gitplugin.cpp index 618d7a260..6bfb22c98 100644 --- a/src/tools/ecode/plugins/git/gitplugin.cpp +++ b/src/tools/ecode/plugins/git/gitplugin.cpp @@ -31,6 +31,7 @@ #include #include #include +#include #include #include diff --git a/src/tools/ecode/settingsmodel.hpp b/src/tools/ecode/settingsmodel.hpp deleted file mode 100644 index 2a2354146..000000000 --- a/src/tools/ecode/settingsmodel.hpp +++ /dev/null @@ -1,148 +0,0 @@ -#ifndef ECODE_SETTINGSMODEL_HPP -#define ECODE_SETTINGSMODEL_HPP - -#include -#include -#include -#include -#include -#include - -namespace ecode { - -struct SettingDescriptor { - std::string id; - std::string category; - String name; - String description; - String group; -}; - -struct BoolPointerSetting { - bool* value{ nullptr }; - std::function apply; -}; - -struct BoolSetting { - std::function get; - std::function set; -}; - -struct ChoiceSetting { - std::vector choices; - std::vector descriptions; - std::function get; - std::function set; -}; - -struct EditableChoiceSetting { - std::vector choices; - std::function get; - std::function set; -}; - -struct IntegerSetting { - int min{ 0 }; - int max{ 0 }; - std::function get; - std::function set; -}; - -struct TextSetting { - std::function get; - std::function set; - bool commitOnFocusLoss{ false }; - bool password{ false }; -}; - -struct FloatSetting { - double min{ 0 }; - double max{ 0 }; - double step{ 0 }; - std::function get; - std::function set; -}; - -struct ActionSetting { - String buttonText; - std::function action; -}; - -using SettingValue = - std::variant; - -struct SettingDefinition { - SettingDescriptor descriptor; - SettingValue value; - bool enabled{ true }; -}; - -struct SettingsCategory { - std::string id; - String parent; - String name; -}; - -struct SettingsGroup { - std::string category; - String name; - size_t beforeSetting{ 0 }; -}; - -class SettingsModel { - public: - void clear() { - mCategories.clear(); - mGroups.clear(); - mSettings.clear(); - } - - bool addCategory( SettingsCategory category ) { - if ( category.id.empty() || - std::any_of( mCategories.begin(), mCategories.end(), - [&category]( const auto& item ) { return item.id == category.id; } ) ) - return false; - mCategories.emplace_back( std::move( category ) ); - return true; - } - - bool addGroup( SettingsGroup group ) { - if ( !hasCategory( group.category ) ) - return false; - mGroups.emplace_back( std::move( group ) ); - return true; - } - - bool addSetting( SettingDefinition setting ) { - if ( setting.descriptor.id.empty() || !hasCategory( setting.descriptor.category ) || - std::any_of( mSettings.begin(), mSettings.end(), [&setting]( const auto& item ) { - return item.descriptor.id == setting.descriptor.id; - } ) ) - return false; - mSettings.emplace_back( std::move( setting ) ); - return true; - } - - bool hasCategory( const std::string& id ) const { - return std::any_of( mCategories.begin(), mCategories.end(), - [&id]( const auto& item ) { return item.id == id; } ); - } - - const std::vector& categories() const { return mCategories; } - - const std::vector& groups() const { return mGroups; } - - std::vector& settings() { return mSettings; } - - const std::vector& settings() const { return mSettings; } - - private: - std::vector mCategories; - std::vector mGroups; - std::vector mSettings; -}; - -} // namespace ecode - -#endif diff --git a/src/tools/ecode/settingspage.hpp b/src/tools/ecode/settingspage.hpp index b12d96f6b..634a927ee 100644 --- a/src/tools/ecode/settingspage.hpp +++ b/src/tools/ecode/settingspage.hpp @@ -2,7 +2,12 @@ #define ECODE_SETTINGSPAGE_HPP #include "settingsdocument.hpp" -#include "settingsmodel.hpp" + +#include + +using namespace EE; +using namespace EE::System; +using namespace EE::UI::Tools; namespace ecode { diff --git a/src/tools/ecode/settingspanel.cpp b/src/tools/ecode/settingspanel.cpp index d1d910da0..412f68724 100644 --- a/src/tools/ecode/settingspanel.cpp +++ b/src/tools/ecode/settingspanel.cpp @@ -6,308 +6,10 @@ #include "settingsdocument.hpp" #include "settingspage.hpp" #include "uitreeviewfs.hpp" -#include #include -#define PUGIXML_HEADER_ONLY -#include - -using namespace EE::UI::Models; namespace ecode { -class SettingsCategoryModel final : public Model { - public: - struct Node { - std::string id; - std::string text; - Node* parent{ nullptr }; - std::vector children; - std::vector visibleChildren; - }; - - static std::shared_ptr - create( const std::vector>>& categories, - const UnorderedMap& ids ) { - return std::make_shared( categories, ids ); - } - - SettingsCategoryModel( - const std::vector>>& categories, - const UnorderedMap& ids ) { - mNodes.emplace_back(); - mRoot = &mNodes.back(); - for ( const auto& [parent, children] : categories ) { - std::string parentId; - if ( !children.empty() ) { - auto id = ids.find( parent + '/' + children.front() ); - if ( id != ids.end() ) { - auto separator = id->second.find( '.' ); - parentId = id->second.substr( 0, separator ) + ".*"; - } - } - mNodes.push_back( { std::move( parentId ), parent, mRoot } ); - auto* parentNode = &mNodes.back(); - mRoot->children.push_back( parentNode ); - for ( const auto& child : children ) { - auto id = ids.find( parent + '/' + child ); - mNodes.push_back( - { id == ids.end() ? std::string{} : id->second, child, parentNode } ); - parentNode->children.push_back( &mNodes.back() ); - } - } - filter( {}, {} ); - } - - size_t rowCount( const ModelIndex& parent = {} ) const { - auto* node = parent.isValid() ? static_cast( parent.internalData() ) : mRoot; - return node->visibleChildren.size(); - } - - size_t columnCount( const ModelIndex& = {} ) const { return 1; } - - ModelIndex index( int row, int column, const ModelIndex& parent = {} ) const { - auto* node = parent.isValid() ? static_cast( parent.internalData() ) : mRoot; - if ( row < 0 || column != 0 || static_cast( row ) >= node->visibleChildren.size() ) - return {}; - return createIndex( row, column, node->visibleChildren[row] ); - } - - ModelIndex parentIndex( const ModelIndex& index ) const { - if ( !index.isValid() ) - return {}; - auto* node = static_cast( index.internalData() ); - if ( !node->parent || node->parent == mRoot ) - return {}; - auto* parent = node->parent; - auto found = - std::find( mRoot->visibleChildren.begin(), mRoot->visibleChildren.end(), parent ); - return found == mRoot->visibleChildren.end() - ? ModelIndex{} - : createIndex( std::distance( mRoot->visibleChildren.begin(), found ), 0, - parent ); - } - - Variant data( const ModelIndex& index, ModelRole role = ModelRole::Display ) const { - if ( !index.isValid() || role != ModelRole::Display ) - return {}; - return Variant( static_cast( index.internalData() )->text ); - } - - void filter( const std::string_view query, - const UnorderedSet& matchingCategories ) { - mRoot->visibleChildren.clear(); - for ( auto* parent : mRoot->children ) { - parent->visibleChildren.clear(); - const bool parentMatches = query.empty() || String::icontains( parent->text, query ); - for ( auto* child : parent->children ) { - if ( parentMatches || String::icontains( child->text, query ) || - matchingCategories.contains( child->id ) ) - parent->visibleChildren.push_back( child ); - } - if ( !parent->visibleChildren.empty() ) - mRoot->visibleChildren.push_back( parent ); - } - invalidate( Model::UpdateFlag::InvalidateAllIndexes ); - } - - private: - std::deque mNodes; - Node* mRoot{ nullptr }; -}; - -static constexpr const char* SETTINGS_PANEL_LAYOUT = R"xml( - - - - - - - - - - - - - - -)xml"; - -static std::string settingsRowLayout( std::string_view control ) { - return R"xml( - - - - - - - -)xml" + std::string( control ) + - R"xml( - - - -)xml"; -} - -class SettingsLayoutTemplate { - public: - explicit SettingsLayoutTemplate( const std::string& layout ) { - [[maybe_unused]] auto result = - mDocument.load_string( layout.c_str(), pugi::parse_default | pugi::parse_ws_pcdata ); - eeASSERT( result ); - } - - pugi::xml_node root() const { return mDocument.first_child(); } - - private: - pugi::xml_document mDocument; -}; - -static constexpr const char* SETTINGS_CATEGORY_HEADING_LAYOUT = R"xml( - - - - -)xml"; -static constexpr const char* SETTINGS_SUBCATEGORY_HEADING_LAYOUT = R"xml( - -)xml"; -static const SettingsLayoutTemplate SETTINGS_BOOL_ROW_LAYOUT( settingsRowLayout( - R"xml()xml" ) ); -static const SettingsLayoutTemplate SETTINGS_CHOICE_ROW_LAYOUT( settingsRowLayout( - R"xml()xml" ) ); -static const SettingsLayoutTemplate SETTINGS_EDITABLE_CHOICE_ROW_LAYOUT( settingsRowLayout( - R"xml()xml" ) ); -static const SettingsLayoutTemplate SETTINGS_INTEGER_ROW_LAYOUT( settingsRowLayout( - R"xml()xml" ) ); -static const SettingsLayoutTemplate SETTINGS_TEXT_ROW_LAYOUT( settingsRowLayout( - R"xml()xml" ) ); -static const SettingsLayoutTemplate SETTINGS_ACTION_ROW_LAYOUT( settingsRowLayout( - R"xml()xml" ) ); - -static void disableTabFocusTree( Node* node ) { - if ( node->isWidget() ) - node->asType()->unsetTabFocusable(); - for ( auto* child = node->getFirstChild(); child; child = child->getNextNode() ) - disableTabFocusTree( child ); -} - SettingsPanel::SettingsPanel( App* app ) : mApp( app ), mLifetime( this, app ? app->getUISceneNode() : nullptr ) {} @@ -316,161 +18,70 @@ SettingsPanel::PanelState& SettingsPanel::state( Scope scope ) { } void SettingsPanel::PanelState::reset() { - bindingGroup.clear(); connections.clear(); window = nullptr; - search = nullptr; - categories = nullptr; - scroll = nullptr; - settings = nullptr; - pageTitle = nullptr; - categoryModel.reset(); - categoryItems.clear(); - categoryIds.clear(); - categorySearchText.clear(); - categoryTitles.clear(); - categoryHeadings.clear(); - categorySections.clear(); - categoryContainers.clear(); - materializedCategories.clear(); - subcategoryHeadings.clear(); - model.clear(); + panel = nullptr; documents.clear(); - settingViews.clear(); - selectedCategory.clear(); - categoryFilter.clear(); } void SettingsPanel::show( Scope scope, const std::string& category ) { - auto& panel = state( scope ); - if ( panel.window ) { - panel.window->show(); - panel.window->toFront(); - selectCategory( panel, category ); - panel.search->runOnMainThread( [search = panel.search] { search->setFocus(); } ); + auto& state = this->state( scope ); + if ( state.window ) { + state.window->show(); + state.window->toFront(); + selectCategory( state, category ); + state.panel->runOnMainThread( [panel = state.panel] { panel->focusSearch(); } ); return; } - Clock c; + Clock clock; create( scope ); - selectCategory( panel, category ); + selectCategory( state, category ); Log::info( "Settings Panel %s created in %s", scope == Scope::User ? "User" : "Project", - c.getElapsedTime().toString() ); -} - -void SettingsPanel::selectCategory( PanelState& panel, const std::string& category ) { - if ( category.empty() || !panel.categories ) - return; - String title; - if ( String::endsWith( category, ".*" ) ) { - const std::string prefix = category.substr( 0, category.size() - 1 ); - for ( const auto& [parent, children] : panel.categoryItems ) { - if ( !children.empty() ) { - auto id = panel.categoryIds.find( parent + '/' + children.front() ); - if ( id != panel.categoryIds.end() && String::startsWith( id->second, prefix ) ) { - title = String::fromUtf8( parent ); - break; - } - } - } - } else if ( auto found = panel.categoryTitles.find( category ); - found != panel.categoryTitles.end() ) { - title = found->second; - } - if ( title.empty() ) - return; - panel.selectedCategory = category; - auto index = panel.categories->findRowWithText( - title.toUtf8(), true, UIAbstractView::FindRowWithTextMatchKind::Equals ); - if ( index.isValid() ) - panel.categories->setSelection( index ); - filter( panel ); - panel.scroll->getVerticalScrollBar()->setValue( 0, false ); + clock.getElapsedTime().toString() ); } void SettingsPanel::create( Scope scope ) { - auto& panel = state( scope ); + auto& state = this->state( scope ); UIWindow::StyleConfig config{ UI_WIN_DEFAULT_FLAGS | UI_WIN_MAXIMIZE_BUTTON | UI_WIN_MODAL }; - panel.window = UIWindow::NewOpt( UIWindow::SIMPLE_LAYOUT, config ); - panel.window->setId( scope == Scope::User ? "settings_panel" : "project_settings_panel" ); - panel.window->setTitle( scope == Scope::User + state.window = UIWindow::NewOpt( UIWindow::SIMPLE_LAYOUT, config ); + state.window->setId( scope == Scope::User ? "settings_panel" : "project_settings_panel" ); + state.window->setTitle( scope == Scope::User ? mApp->i18n( "settings", "Settings" ) : mApp->i18n( "project_settings", "Project Settings" ) ); const auto sceneSize = mApp->getUISceneNode()->getPixelsSize(); - panel.window->setPixelsSize( { eeclamp( sceneSize.getWidth() * 0.82f, 720.f, 1200.f ), + state.window->setPixelsSize( { eeclamp( sceneSize.getWidth() * 0.82f, 720.f, 1200.f ), eeclamp( sceneSize.getHeight() * 0.82f, 520.f, 850.f ) } ); - panel.window->setMinWindowSize( 640, 440 ); - panel.window->setKeyBindingCommand( "closeWindow", [window = panel.window, this] { + state.window->setMinWindowSize( 640, 440 ); + state.window->setKeyBindingCommand( "closeWindow", [window = state.window, this] { if ( !SceneManager::instance()->isShuttingDown() ) { window->closeWindow(); if ( mApp->getSplitter() && mApp->getSplitter()->getCurWidget() ) mApp->getSplitter()->getCurWidget()->setFocus(); } } ); - panel.window->getKeyBindings().addKeybind( { KEY_ESCAPE }, "closeWindow" ); - auto* layout = mApp->getUISceneNode()->loadLayoutFromString( SETTINGS_PANEL_LAYOUT, - panel.window->getContainer() ); - panel.search = layout->find( "settings_filter" ); - panel.categories = layout->find( "settings_categories" ); - panel.settings = layout->find( "settings_rows" ); - panel.pageTitle = layout->find( "settings_page_title" ); - panel.scroll = layout->find( "settings_scroll" ); - panel.scroll->setVerticalScrollMode( ScrollBarMode::Auto ); - panel.scroll->setHorizontalScrollMode( ScrollBarMode::AlwaysOff ); - disableTabFocusTree( panel.categories->getVerticalScrollBar() ); - disableTabFocusTree( panel.categories->getHorizontalScrollBar() ); - disableTabFocusTree( panel.scroll->getVerticalScrollBar() ); - disableTabFocusTree( panel.scroll->getHorizontalScrollBar() ); - panel.window->setKeyBindingCommand( "focusSettingsFilter", [&panel] { - panel.search->setFocus(); - panel.search->getDocument().selectAll(); - } ); - panel.window->setKeyBindingCommand( "focusSettingsCategories", [&panel] { - auto selected = panel.categories->getSelection().first(); - if ( selected.isValid() ) - panel.categories->setSelection( selected ); - panel.categories->setFocus(); - } ); - panel.window->getKeyBindings().addKeybind( { KEY_F, KeyMod::getDefaultModifier() }, + state.window->getKeyBindings().addKeybind( { KEY_ESCAPE }, "closeWindow" ); + state.panel = UISettingsPanel::New( state.window->getContainer() ); + state.panel->setSearchResultsText( mApp->i18n( "search_results", "Search Results" ) ); + state.window->setKeyBindingCommand( "focusSettingsFilter", + [panel = state.panel] { panel->focusSearch(); } ); + state.window->setKeyBindingCommand( "focusSettingsCategories", + [panel = state.panel] { panel->focusCategories(); } ); + state.window->getKeyBindings().addKeybind( { KEY_F, KeyMod::getDefaultModifier() }, "focusSettingsFilter" ); - panel.window->getKeyBindings().addKeybind( + state.window->getKeyBindings().addKeybind( { KEY_E, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "focusSettingsCategories" ); - panel.settings->beginAttributesTransaction(); if ( scope == Scope::User ) { - addUserSettings( panel ); - addPluginSettings( panel ); + addUserSettings( state ); + addPluginSettings( state ); } else { - addProjectSettings( panel ); + addProjectSettings( state ); } - materializeCategory( panel, panel.selectedCategory ); - setupCategories( panel ); - panel.settings->endAttributesTransaction(); - filter( panel ); - panel.connections += - panel.search->connect( Event::OnTextChanged, [this, &panel]( const Event* ) { - String query = panel.search->getText(); - query.trim(); - const UintPtr debounceTag = reinterpret_cast( &panel ); - if ( query.size() < 2 ) { - panel.search->removeActionsByTag( debounceTag ); - filter( panel ); - return; - } - panel.search->debounce( [this, &panel] { filter( panel ); }, Milliseconds( 150 ), - debounceTag ); - } ); - panel.connections += panel.window->connect( Event::OnWindowReady, [&panel]( const Event* ) { - auto title = panel.categoryTitles.find( panel.selectedCategory ); - if ( title != panel.categoryTitles.end() ) { - auto index = panel.categories->findRowWithText( - title->second.toUtf8(), true, UIAbstractView::FindRowWithTextMatchKind::Equals ); - if ( index.isValid() ) - panel.categories->setSelection( index ); - } - panel.search->runOnMainThread( [search = panel.search] { search->setFocus(); } ); - } ); - panel.connections += - panel.window->connect( Event::OnWindowClose, [this, &panel, scope]( const Event* ) { - for ( const auto& document : panel.documents ) { + state.panel->build(); + state.connections += state.window->connect( + Event::OnWindowReady, [&state]( const Event* ) { state.panel->focusSearch(); } ); + state.connections += + state.window->connect( Event::OnWindowClose, [this, &state, scope]( const Event* ) { + for ( const auto& document : state.documents ) { if ( !document->save() ) Log::error( "Could not save settings document: %s", document->path() ); } @@ -478,368 +89,84 @@ void SettingsPanel::create( Scope scope ) { mApp->saveConfig(); else mApp->saveProject(); - panel.reset(); + state.reset(); } ); - panel.window->center(); - panel.window->showWhenReady(); - panel.search->setFocus(); + state.window->center(); + state.window->showWhenReady(); + state.panel->focusSearch(); } -void SettingsPanel::addCategory( PanelState& panel, const std::string& id, const String& parent, - const String& name ) { - panel.model.addCategory( { id, parent, name } ); - const auto parentText = parent.toUtf8(); - const auto nameText = name.toUtf8(); - auto parentItems = - std::find_if( panel.categoryItems.begin(), panel.categoryItems.end(), - [&parentText]( const auto& item ) { return item.first == parentText; } ); - if ( parentItems == panel.categoryItems.end() ) { - panel.categoryItems.emplace_back( parentText, std::vector{ nameText } ); - } else { - parentItems->second.emplace_back( nameText ); - } - panel.categoryIds[parentText + '/' + nameText] = id; - panel.categorySearchText[id] = parent + " " + name; - panel.categoryTitles[id] = name; - auto* section = mApp->getUISceneNode()->loadLayoutFromString( SETTINGS_CATEGORY_HEADING_LAYOUT, - panel.settings ); - auto* heading = section->find( "settings_category_heading" ); - heading->setText( name ); - heading->setId( "settings_category_" + id ); - panel.categoryHeadings[id] = heading; - panel.categorySections[id] = section; - panel.categoryContainers[id] = section->find( "settings_category_rows" ); - if ( panel.selectedCategory.empty() ) - panel.selectedCategory = id; +void SettingsPanel::selectCategory( PanelState& state, const std::string& category ) { + state.panel->selectCategory( category ); } -void SettingsPanel::addSubcategoryHeading( PanelState& panel, const std::string& category, - const String& name ) { - panel.model.addGroup( { category, name, panel.model.settings().size() } ); +void SettingsPanel::addCategory( PanelState& state, std::string id, String parent, String name ) { + state.panel->addCategory( std::move( id ), std::move( parent ), std::move( name ) ); } -void SettingsPanel::setupCategories( PanelState& panel ) { - auto model = SettingsCategoryModel::create( panel.categoryItems, panel.categoryIds ); - panel.categoryModel = model; - panel.categories->setHeadersVisible( false ); - panel.categories->setAutoExpandOnSingleColumn( true ); - panel.categories->setFocusOnSelection( true ); - panel.categories->setModel( model ); - panel.categories->expandAll(); - panel.connections += - panel.categories->connect( Event::OnSelectionChanged, [this, &panel]( const Event* ) { - auto index = panel.categories->getSelection().first(); - if ( !index.isValid() ) - return; - auto* node = static_cast( index.internalData() ); - if ( !node || node->id.empty() ) - return; - panel.selectedCategory = node->id; - panel.pageTitle->setText( node->text ); - filter( panel ); - panel.scroll->getVerticalScrollBar()->setValue( 0, false ); - } ); +void SettingsPanel::addSubcategoryHeading( PanelState& state, std::string category, String name ) { + state.panel->addGroup( std::move( category ), std::move( name ) ); } -UIWidget* SettingsPanel::createRow( PanelState& panel, SettingDefinition& setting, - SettingView& view, pugi::xml_node layout ) { - auto& binding = setting.descriptor; - auto container = panel.categoryContainers.find( binding.category ); - eeASSERT( container != panel.categoryContainers.end() ); - auto* row = mApp->getUISceneNode()->loadLayoutNodes( layout, container->second, 0 ); - row->setId( "setting_" + binding.id ); - row->find( "setting_name" )->setText( binding.name ); - auto* description = row->find( "setting_description" ); - description->setText( binding.description ); - view.row = row; - return row; -} - -void SettingsPanel::addBool( PanelState& panel, SettingDescriptor binding, bool* value, +void SettingsPanel::addBool( PanelState& state, SettingDescriptor binding, bool* value, std::function apply ) { - panel.model.addSetting( - { std::move( binding ), BoolPointerSetting{ value, std::move( apply ) } } ); + state.panel->addBool( std::move( binding ), value, std::move( apply ) ); } -void SettingsPanel::addBool( PanelState& panel, SettingDescriptor binding, +void SettingsPanel::addBool( PanelState& state, SettingDescriptor binding, std::function get, std::function set ) { - panel.model.addSetting( - { std::move( binding ), BoolSetting{ std::move( get ), std::move( set ) } } ); + state.panel->addBool( std::move( binding ), std::move( get ), std::move( set ) ); } -UICheckBox* SettingsPanel::createBoolControl( PanelState& panel, SettingDefinition& setting, - SettingView& view ) { - auto* row = createRow( panel, setting, view, SETTINGS_BOOL_ROW_LAYOUT.root() ); - row->addClass( "settings_boolean_option" ); - auto* check = row->find( "setting_control_widget" ); - auto toggle = [check]( const Event* event ) { - if ( event->asMouseEvent()->getFlags() & EE_BUTTON_LMASK ) - check->setChecked( !check->isChecked() ); - }; - panel.connections += - row->find( "setting_name" )->connect( Event::MouseClick, toggle ); - panel.connections += - row->find( "setting_description" )->connect( Event::MouseClick, toggle ); - panel.connections += row->find( "setting_info" ) - ->connect( Event::MouseClick, std::move( toggle ) ); - return check; -} - -void SettingsPanel::addChoice( PanelState& panel, SettingDescriptor binding, +void SettingsPanel::addChoice( PanelState& state, SettingDescriptor binding, const std::vector& choices, std::function get, std::function set, std::vector choiceDescriptions ) { - panel.model.addSetting( - { std::move( binding ), ChoiceSetting{ choices, std::move( choiceDescriptions ), - std::move( get ), std::move( set ) } } ); + state.panel->addChoice( std::move( binding ), choices, std::move( get ), std::move( set ), + std::move( choiceDescriptions ) ); } -void SettingsPanel::addEditableChoice( PanelState& panel, SettingDescriptor binding, +void SettingsPanel::addEditableChoice( PanelState& state, SettingDescriptor binding, const std::vector& choices, std::function get, std::function set ) { - panel.model.addSetting( - { std::move( binding ), - EditableChoiceSetting{ choices, std::move( get ), std::move( set ) } } ); + state.panel->addEditableChoice( std::move( binding ), choices, std::move( get ), + std::move( set ) ); } -void SettingsPanel::addInteger( PanelState& panel, SettingDescriptor binding, int min, int max, +void SettingsPanel::addInteger( PanelState& state, SettingDescriptor binding, int min, int max, std::function get, std::function set ) { - panel.model.addSetting( - { std::move( binding ), IntegerSetting{ min, max, std::move( get ), std::move( set ) } } ); + state.panel->addInteger( std::move( binding ), min, max, std::move( get ), std::move( set ) ); } -void SettingsPanel::addText( PanelState& panel, SettingDescriptor binding, +void SettingsPanel::addText( PanelState& state, SettingDescriptor binding, std::function get, std::function set, bool commitOnFocusLoss ) { - panel.model.addSetting( { std::move( binding ), TextSetting{ std::move( get ), std::move( set ), - commitOnFocusLoss } } ); + state.panel->addText( std::move( binding ), std::move( get ), std::move( set ), + commitOnFocusLoss ); } -void SettingsPanel::addFloat( PanelState& panel, SettingDescriptor binding, double min, double max, +void SettingsPanel::addFloat( PanelState& state, SettingDescriptor binding, double min, double max, double step, std::function get, std::function set ) { - panel.model.addSetting( { std::move( binding ), FloatSetting{ min, max, step, std::move( get ), - std::move( set ) } } ); + state.panel->addFloat( std::move( binding ), min, max, step, std::move( get ), + std::move( set ) ); } -void SettingsPanel::addAction( PanelState& panel, SettingDescriptor binding, +void SettingsPanel::addAction( PanelState& state, SettingDescriptor binding, const String& buttonText, std::function action ) { - panel.model.addSetting( - { std::move( binding ), ActionSetting{ buttonText, std::move( action ) } } ); + state.panel->addAction( std::move( binding ), buttonText, std::move( action ) ); } -void SettingsPanel::refreshTextSetting( PanelState& panel, const std::string& id ) { - const auto& settings = panel.model.settings(); - for ( size_t i = 0; i < settings.size(); ++i ) { - if ( settings[i].descriptor.id != id || i >= panel.settingViews.size() || - !panel.settingViews[i].row ) - continue; - auto* value = std::get_if( &settings[i].value ); - auto* input = panel.settingViews[i].row->find( "setting_control_widget" ); - if ( value && input ) - input->setText( String::fromUtf8( value->get() ) ); - return; - } +void SettingsPanel::refreshTextSetting( PanelState& state, const std::string& id ) { + state.panel->refreshTextSetting( id ); } -static void setNodeTreeEnabled( Node* node, bool enabled ); - -void SettingsPanel::materializeCategory( PanelState& panel, const std::string& category ) { - if ( category.empty() || panel.materializedCategories.contains( category ) ) - return; - auto container = panel.categoryContainers.find( category ); - if ( container == panel.categoryContainers.end() ) - return; - auto& settings = panel.model.settings(); - panel.settingViews.resize( settings.size() ); - container->second->beginAttributesTransaction(); - for ( size_t i = 0; i < settings.size(); ++i ) { - auto& setting = settings[i]; - if ( setting.descriptor.category != category ) - continue; - for ( const auto& group : panel.model.groups() ) { - if ( group.category != category || group.beforeSetting != i ) - continue; - auto* heading = - mApp->getUISceneNode() - ->loadLayoutFromString( SETTINGS_SUBCATEGORY_HEADING_LAYOUT, container->second ) - ->asType(); - heading->setText( group.name ); - panel.subcategoryHeadings.push_back( { group.category, group.name, heading } ); - } - auto& view = panel.settingViews[i]; - if ( auto* value = std::get_if( &setting.value ) ) { - auto* check = createBoolControl( panel, setting, view ); - auto binding = UIDataBind::New( value->value, check, - UIValueConverter::converterBool() ); - binding->onValueChangeCb = value->apply; - panel.bindingGroup += std::move( binding ); - } else if ( auto* value = std::get_if( &setting.value ) ) { - auto* check = createBoolControl( panel, setting, view ); - check->setChecked( value->get() ); - panel.connections += - check->connect( Event::OnValueChange, [check, value]( const Event* ) { - value->set( check->isChecked() ); - } ); - } else if ( auto* value = std::get_if( &setting.value ) ) { - auto* row = createRow( panel, setting, view, SETTINGS_CHOICE_ROW_LAYOUT.root() ); - auto* dropDown = row->find( "setting_control_widget" ); - auto model = ItemListOwnerModel::create( value->choices ); - dropDown->setModel( model ); - const size_t selected = value->get(); - if ( selected < value->choices.size() ) { - dropDown->getListView()->getSelection().set( model->index( selected, 0 ) ); - dropDown->setText( value->choices[selected] ); - } - if ( selected < value->descriptions.size() ) - dropDown->setTooltipText( value->descriptions[selected] ); - panel.connections += - dropDown->connect( Event::OnValueChange, [dropDown, value]( const Event* ) { - if ( dropDown->getListView()->getSelection().isEmpty() ) - return; - const size_t selected = dropDown->getListView()->getSelection().first().row(); - if ( selected < value->descriptions.size() ) - dropDown->setTooltipText( value->descriptions[selected] ); - value->set( selected ); - } ); - } else if ( auto* value = std::get_if( &setting.value ) ) { - auto* row = - createRow( panel, setting, view, SETTINGS_EDITABLE_CHOICE_ROW_LAYOUT.root() ); - auto* combo = row->find( "setting_control_widget" ); - for ( const auto& choice : value->choices ) - combo->getListBox()->addListBoxItem( choice ); - combo->setText( value->get() ); - panel.connections += - combo->connect( Event::OnValueChange, [combo, value]( const Event* ) { - if ( !value->set( combo->getText() ) ) { - combo->addClass( "error" ); - combo->getDropDownList()->addClass( "error" ); - return; - } - combo->removeClass( "error" ); - combo->getDropDownList()->removeClass( "error" ); - } ); - } else if ( auto* value = std::get_if( &setting.value ) ) { - auto* row = createRow( panel, setting, view, SETTINGS_INTEGER_ROW_LAYOUT.root() ); - auto* spin = row->find( "setting_control_widget" ); - spin->setMinValue( value->min )->setMaxValue( value->max ); - spin->unsetTabFocusable(); - spin->getButtonPushUp()->asType()->unsetTabFocusable(); - spin->getButtonPushDown()->asType()->unsetTabFocusable(); - spin->setValue( value->get() ); - panel.connections += - spin->connect( Event::OnValueChange, [spin, value]( const Event* ) { - value->set( static_cast( spin->getValue() ) ); - } ); - } else if ( auto* value = std::get_if( &setting.value ) ) { - auto* row = createRow( panel, setting, view, SETTINGS_TEXT_ROW_LAYOUT.root() ); - auto* input = row->find( "setting_control_widget" ); - if ( value->password ) - input->setMode( UITextInput::TextInputMode::Password ); - input->setText( String::fromUtf8( value->get() ) ); - auto commit = [input, value] { - if ( !value->set( input->getText().toUtf8() ) ) { - input->addClass( "error" ); - return; - } - input->removeClass( "error" ); - }; - if ( value->commitOnFocusLoss ) { - const auto debounceTag = reinterpret_cast( input ); - panel.connections += input->connect( - Event::OnTextChanged, [input, commit, debounceTag]( const Event* ) { - input->debounce( commit, Milliseconds( 500 ), debounceTag ); - } ); - auto flush = [input, commit, debounceTag]( const Event* ) { - input->removeActionsByTag( debounceTag ); - commit(); - }; - panel.connections += input->connect( Event::OnPressEnter, flush ); - panel.connections += input->connect( Event::OnFocusLoss, std::move( flush ) ); - } else { - panel.connections += - input->connect( Event::OnTextChanged, - [commit = std::move( commit )]( const Event* ) { commit(); } ); - } - } else if ( auto* value = std::get_if( &setting.value ) ) { - auto* row = createRow( panel, setting, view, SETTINGS_INTEGER_ROW_LAYOUT.root() ); - auto* spin = row->find( "setting_control_widget" ); - spin->setMinValue( value->min )->setMaxValue( value->max )->setClickStep( value->step ); - spin->allowFloatingPoint( true )->setValue( value->get() ); - spin->unsetTabFocusable(); - spin->getButtonPushUp()->asType()->unsetTabFocusable(); - spin->getButtonPushDown()->asType()->unsetTabFocusable(); - panel.connections += - spin->connect( Event::OnValueChange, - [spin, value]( const Event* ) { value->set( spin->getValue() ); } ); - } else if ( auto* value = std::get_if( &setting.value ) ) { - auto* row = createRow( panel, setting, view, SETTINGS_ACTION_ROW_LAYOUT.root() ); - auto* button = row->find( "setting_control_widget" ); - button->setText( value->buttonText ); - panel.connections += button->connect( Event::MouseClick, [value]( const Event* event ) { - if ( event->asMouseEvent()->getFlags() & EE_BUTTON_LMASK ) - value->action(); - } ); - } - if ( view.row && !setting.enabled ) - setNodeTreeEnabled( view.row, false ); - } - container->second->endAttributesTransaction(); - panel.materializedCategories.insert( category ); -} - -void SettingsPanel::materializeVisibleSettings( PanelState& panel, const String& query ) { - const std::string queryUtf8 = query.toUtf8(); - const bool aggregate = String::endsWith( panel.selectedCategory, ".*" ); - const std::string aggregatePrefix = - aggregate ? panel.selectedCategory.substr( 0, panel.selectedCategory.size() - 1 ) - : std::string{}; - for ( const auto& category : panel.model.categories() ) { - bool materialize = - query.empty() ? category.id == panel.selectedCategory || - ( aggregate && String::startsWith( category.id, aggregatePrefix ) ) - : String::icontains( category.parent, query ) || - String::icontains( category.name, query ); - if ( !materialize && !query.empty() ) { - for ( const auto& setting : panel.model.settings() ) { - const auto& descriptor = setting.descriptor; - if ( descriptor.category == category.id && - ( String::icontains( descriptor.name, query ) || - String::icontains( descriptor.description, query ) || - String::icontains( descriptor.group, query ) || - String::icontains( descriptor.id, queryUtf8 ) ) ) { - materialize = true; - break; - } - } - } - if ( materialize ) - materializeCategory( panel, category.id ); - } -} - -static void setNodeTreeEnabled( Node* node, bool enabled ) { - node->setEnabled( enabled ); - for ( Node* child = node->getFirstChild(); child; child = child->getNextNode() ) - setNodeTreeEnabled( child, enabled ); -} - -void SettingsPanel::setCategoryEnabled( PanelState& panel, const std::string& category, +void SettingsPanel::setCategoryEnabled( PanelState& state, const std::string& category, bool enabled, const std::string& excludedSetting ) { - auto& settings = panel.model.settings(); - for ( size_t i = 0; i < settings.size(); ++i ) { - auto& setting = settings[i]; - const auto& descriptor = setting.descriptor; - if ( descriptor.category != category || descriptor.id == excludedSetting ) - continue; - setting.enabled = enabled; - if ( i < panel.settingViews.size() && panel.settingViews[i].row ) - setNodeTreeEnabled( panel.settingViews[i].row, enabled ); - } + state.panel->setCategoryEnabled( category, enabled, excludedSetting ); } - void SettingsPanel::addUserSettings( PanelState& panel ) { addCategory( panel, "general.behavior", mApp->i18n( "general", "General" ), mApp->i18n( "behavior", "Behavior" ) ); @@ -1819,18 +1146,34 @@ void SettingsPanel::addUserSettings( PanelState& panel ) { "Vsync configuration changed.\nRestart ecode to see the changes." ) .unescape() ); } ); - addInteger( + const String monitorRefreshRate = mApp->i18n( "monitor_refresh_rate", "Monitor Refresh Rate" ); + const String unlimitedFrameRate = mApp->i18n( "unlimited", "Unlimited" ); + addEditableChoice( panel, { "frameRateLimit", "window.renderer", mApp->i18n( "frame_rate_limit", "Frame Rate Limit" ), - mApp->i18n( "frame_rate_limit_desc", - "Limit rendered frames per second. Set 0 to disable." ) }, - 0, 1000, [this] { return mApp->getConfig().context.FrameRateLimit; }, - [this]( int value ) { + mApp->i18n( "frame_rate_limit_desc", "Limit rendered frames per second, follow the " + "monitor refresh rate, or disable the limit." ) }, + { monitorRefreshRate, unlimitedFrameRate, "30", "60", "75", "120", "144", "165", "240" }, + [this, monitorRefreshRate, unlimitedFrameRate] { + const auto value = mApp->getConfig().context.FrameRateLimit; + return value == ContextSettings::FrameRateLimitScreenRefreshRate ? monitorRefreshRate + : value == 0 ? unlimitedFrameRate + : String( String::toString( value ) ); + }, + [this, monitorRefreshRate, unlimitedFrameRate]( const String& selection ) { + Int32 value; + if ( selection == monitorRefreshRate ) + value = ContextSettings::FrameRateLimitScreenRefreshRate; + else if ( selection == unlimitedFrameRate ) + value = 0; + else if ( !String::fromString( value, selection ) || value < 0 || value > 1000 ) + return false; mApp->getConfig().context.FrameRateLimit = value; mApp->saveConfig(); mApp->getWindow()->setFrameRateLimit( value ); mApp->getNotificationCenter()->addNotification( mApp->i18n( "frame_rate_limit_applied", "Frame Rate Limit Applied" ) ); + return true; } ); std::vector rendererVersions = Renderer::getAvailableGraphicsLibraryVersions(); @@ -2096,7 +1439,7 @@ void SettingsPanel::addPluginSettings( PanelState& panel ) { } const std::string category = "plugins." + plugin->getId(); addCategory( panel, category, mApp->i18n( "plugins", "Plugins" ), plugin->getTitle() ); - SettingsPage page( panel.model, document, category, plugin->getId() ); + SettingsPage page( panel.panel->getModel(), document, category, plugin->getId() ); plugin->registerSettings( page ); const std::string path = document->path(); addAction( panel, @@ -2256,87 +1599,4 @@ void SettingsPanel::addProjectSettings( PanelState& panel ) { } ); } -void SettingsPanel::filter( PanelState& panel ) { - String query = panel.search ? panel.search->getText() : String{}; - query.trim().toLower(); - if ( query.size() < 2 ) - query.clear(); - materializeVisibleSettings( panel, query ); - if ( !query.empty() ) { - panel.pageTitle->setText( mApp->i18n( "search_results", "Search Results" ) ); - } else if ( auto title = panel.categoryTitles.find( panel.selectedCategory ); - title != panel.categoryTitles.end() ) { - panel.pageTitle->setText( title->second ); - } - UnorderedSet matchingCategories; - const std::string queryUtf8 = query.toUtf8(); - const bool aggregate = String::endsWith( panel.selectedCategory, ".*" ); - const std::string aggregatePrefix = - aggregate ? panel.selectedCategory.substr( 0, panel.selectedCategory.size() - 1 ) - : std::string{}; - if ( !query.empty() ) { - for ( const auto& setting : panel.model.settings() ) { - const auto& binding = setting.descriptor; - if ( String::icontains( binding.name, query ) || - String::icontains( binding.description, query ) || - String::icontains( binding.group, query ) || - String::icontains( binding.id, queryUtf8 ) ) - matchingCategories.insert( binding.category ); - } - } - if ( queryUtf8 != panel.categoryFilter ) { - panel.categoryFilter = queryUtf8; - if ( auto model = std::static_pointer_cast( panel.categoryModel ) ) - model->filter( queryUtf8, matchingCategories ); - } - panel.categories->expandAll(); - for ( auto& [category, heading] : panel.categoryHeadings ) - heading->setVisible( query.empty() && aggregate && - String::startsWith( category, aggregatePrefix ) ); - const auto& settings = panel.model.settings(); - for ( size_t i = 0; i < settings.size(); ++i ) { - const auto& binding = settings[i].descriptor; - const auto categoryName = panel.categorySearchText.find( binding.category ); - const bool categoryMatches = categoryName != panel.categorySearchText.end() && - String::icontains( categoryName->second, query ); - const bool matches = - query.empty() - ? binding.category == panel.selectedCategory || - ( aggregate && String::startsWith( binding.category, aggregatePrefix ) ) - : categoryMatches || String::icontains( binding.name, query ) || - String::icontains( binding.description, query ) || - String::icontains( binding.group, query ) || - String::icontains( binding.id, queryUtf8 ); - if ( i < panel.settingViews.size() && panel.settingViews[i].row ) - panel.settingViews[i].row->setVisible( matches ); - } - for ( const auto& category : panel.model.categories() ) { - auto section = panel.categorySections.find( category.id ); - if ( section == panel.categorySections.end() ) - continue; - bool visible = false; - for ( size_t i = 0; i < settings.size(); ++i ) { - if ( settings[i].descriptor.category == category.id && i < panel.settingViews.size() && - panel.settingViews[i].row && panel.settingViews[i].row->isVisible() ) { - visible = true; - break; - } - } - section->second->setVisible( visible ); - } - for ( auto& subcategory : panel.subcategoryHeadings ) { - if ( !subcategory.heading ) - continue; - const bool hasVisibleSetting = std::any_of( - settings.begin(), settings.end(), [&panel, &subcategory]( const auto& setting ) { - const auto& binding = setting.descriptor; - const size_t index = &setting - panel.model.settings().data(); - return binding.category == subcategory.category && - binding.group == subcategory.name && index < panel.settingViews.size() && - panel.settingViews[index].row && panel.settingViews[index].row->isVisible(); - } ); - subcategory.heading->setVisible( hasVisibleSetting ); - } -} - } // namespace ecode diff --git a/src/tools/ecode/settingspanel.hpp b/src/tools/ecode/settingspanel.hpp index 8463030a1..cdfdebac4 100644 --- a/src/tools/ecode/settingspanel.hpp +++ b/src/tools/ecode/settingspanel.hpp @@ -1,11 +1,9 @@ #ifndef ECODE_SETTINGSPANEL_HPP #define ECODE_SETTINGSPANEL_HPP -#include "settingsmodel.hpp" #include #include -#include -#include +#include #include namespace ecode { @@ -22,39 +20,11 @@ class SettingsPanel { void show( Scope scope, const std::string& category = {} ); protected: - struct SettingView { - UIWidget* row{ nullptr }; - }; - struct SubcategoryHeading { - std::string category; - String name; - UITextView* heading{ nullptr }; - }; - struct PanelState { UIWindow* window{ nullptr }; + UISettingsPanel* panel{ nullptr }; EventConnectionList connections; - UITextInput* search{ nullptr }; - UITreeView* categories{ nullptr }; - UIScrollView* scroll{ nullptr }; - UILinearLayout* settings{ nullptr }; - UITextView* pageTitle{ nullptr }; - std::shared_ptr categoryModel; - UIBindingGroup bindingGroup; - SettingsModel model; std::vector> documents; - std::vector>> categoryItems; - UnorderedMap categoryIds; - UnorderedMap categorySearchText; - UnorderedMap categoryTitles; - UnorderedMap categoryHeadings; - UnorderedMap categorySections; - UnorderedMap categoryContainers; - UnorderedSet materializedCategories; - std::vector subcategoryHeadings; - std::vector settingViews; - std::string selectedCategory; - std::string categoryFilter; void reset(); }; @@ -76,13 +46,9 @@ class SettingsPanel { void addProjectSettings( PanelState& state ); - void addCategory( PanelState& state, const std::string& id, const String& parent, - const String& name ); + void addCategory( PanelState& state, std::string id, String parent, String name ); - void addSubcategoryHeading( PanelState& state, const std::string& category, - const String& name ); - - void setupCategories( PanelState& state ); + void addSubcategoryHeading( PanelState& state, std::string category, String name ); void addBool( PanelState& state, SettingDescriptor binding, bool* value, std::function apply = {} ); @@ -110,22 +76,11 @@ class SettingsPanel { void addAction( PanelState& state, SettingDescriptor binding, const String& buttonText, std::function action ); + void refreshTextSetting( PanelState& state, const std::string& id ); - UIWidget* createRow( PanelState& state, SettingDefinition& setting, SettingView& view, - pugi::xml_node layout ); - - UICheckBox* createBoolControl( PanelState& state, SettingDefinition& setting, - SettingView& view ); - - void materializeCategory( PanelState& state, const std::string& category ); - - void materializeVisibleSettings( PanelState& state, const String& query ); - void setCategoryEnabled( PanelState& state, const std::string& category, bool enabled, const std::string& excludedSetting = {} ); - - void filter( PanelState& state ); }; } // namespace ecode diff --git a/src/tools/eterm/appconfig.cpp b/src/tools/eterm/appconfig.cpp new file mode 100644 index 000000000..085fcdace --- /dev/null +++ b/src/tools/eterm/appconfig.cpp @@ -0,0 +1,165 @@ +#include "appconfig.hpp" +#include +#include + +using namespace EE::Graphics; +using namespace EE::System; +using namespace EE::UI; +using namespace eterm::Terminal; +using EE::eemax; + +namespace eterm { + +AppConfig::AppConfig( std::string configPath ) : mConfigPath( std::move( configPath ) ) { + FileSystem::dirAddSlashAtEnd( mConfigPath ); + mIni.path( mConfigPath + "eterm.ini" ); + mState.path( mConfigPath + "eterm.state.ini" ); +} + +void AppConfig::load() { + mIni.readFile(); + mState.readFile(); + terminal.shell = mIni.getValue( "terminal", "shell", terminal.shell ); + terminal.shellArguments = + mIni.getValue( "terminal", "shell_arguments", terminal.shellArguments ); + terminal.workingDirectory = + mIni.getValue( "terminal", "working_directory", terminal.workingDirectory ); + terminal.executeInShell = + mIni.getValue( "terminal", "execute_in_shell", terminal.executeInShell ); + terminal.historySize = mIni.getValueU( "terminal", "scrollback", terminal.historySize ); + terminal.cursorStyle = TerminalCursorHelper::modeFromString( mIni.getValue( + "terminal", "cursor_style", TerminalCursorHelper::modeToString( terminal.cursorStyle ) ) ); + terminal.useFrameBuffer = mIni.getValueB( "terminal", "framebuffer", terminal.useFrameBuffer ); + terminal.closeOnExit = mIni.getValueB( "terminal", "close_on_exit", terminal.closeOnExit ); + terminal.initialTabs = mIni.getValueU( "terminal", "initial_tabs", terminal.initialTabs ); + terminal.exclusiveMode = mIni.getValueB( "terminal", "exclusive_mode", terminal.exclusiveMode ); + const auto newTerminalBehavior = + mIni.getValue( "terminal", "new_terminal_behavior", "current" ); + terminal.newTerminalBehavior = + newTerminalBehavior == "vertical" ? NewTerminalBehavior::VerticalSplit + : newTerminalBehavior == "horizontal" ? NewTerminalBehavior::HorizontalSplit + : NewTerminalBehavior::CurrentTabBar; + terminal.scrollBarType = mIni.getValue( "terminal", "scrollbar_type", "overlay" ) == "outside" + ? ScrollViewType::Outside + : ScrollViewType::Overlay; + const auto scrollBarMode = mIni.getValue( "terminal", "scrollbar_mode", "auto" ); + terminal.scrollBarMode = scrollBarMode == "always_on" ? ScrollBarMode::AlwaysOn + : scrollBarMode == "always_off" ? ScrollBarMode::AlwaysOff + : ScrollBarMode::Auto; + font.path = mIni.getValue( "font", "path", font.path ); + font.fallbackPath = mIni.getValue( "font", "fallback_path", font.fallbackPath ); + font.size = mIni.getValueF( "font", "size", font.size ); + font.hinting = Font::fontHintingFromString( + mIni.getValue( "font", "hinting", Font::fontHintingToString( font.hinting ) ) ); + font.antialiasing = Font::fontAntialiasingFromString( mIni.getValue( + "font", "antialiasing", Font::fontAntialiasingToString( font.antialiasing ) ) ); + font.uiPath = mIni.getValue( "font", "ui_path", font.uiPath ); + font.uiSize = mIni.getValueF( "font", "ui_size", font.uiSize ); + + window.pixelDensity = mIni.getValueF( "window", "pixel_density", window.pixelDensity ); + window.maxFPS = mIni.getValueU( "window", "frame_rate_limit", window.maxFPS ); + window.vsync = mIni.getValueB( "window", "vsync", window.vsync ); + window.benchmarkMode = mIni.getValueB( "window", "benchmark_mode", window.benchmarkMode ); + window.warnBeforeClose = + mIni.getValueB( "window", "warn_before_closing", window.warnBeforeClose ); + window.alwaysShowTabBar = + mIni.getValueB( "window", "always_show_tab_bar", window.alwaysShowTabBar ); + window.rendererVersion = Renderer::glVersionFromString( + mIni.getValue( "window", "renderer_version", + Renderer::graphicsLibraryVersionToString( window.rendererVersion ) ) ); + window.multisamples = mIni.getValueU( "window", "multisamples", window.multisamples ); + theme.colorScheme = mIni.getValue( "theme", "color_scheme", theme.colorScheme ); + theme.uiColorScheme = ColorSchemePreferences::fromStringExt( mIni.getValue( + "theme", "ui_color_scheme", ColorSchemePreferences::toString( theme.uiColorScheme ) ) ); + + windowState.size.setWidth( mState.getValueI( "window", "width", windowState.size.getWidth() ) ); + windowState.size.setHeight( + mState.getValueI( "window", "height", windowState.size.getHeight() ) ); + windowState.position.x = mState.getValueI( "window", "x", windowState.position.x ); + windowState.position.y = mState.getValueI( "window", "y", windowState.position.y ); + windowState.displayIndex = + mState.getValueI( "window", "display_index", windowState.displayIndex ); + windowState.maximized = mState.getValueB( "window", "maximized", windowState.maximized ); +} + +bool AppConfig::save() { + if ( !FileSystem::isDirectory( mConfigPath ) && !FileSystem::makeDir( mConfigPath, true ) ) + return false; + return savePreferences() && saveWindowState(); +} + +bool AppConfig::savePreferences() { + if ( !FileSystem::isDirectory( mConfigPath ) && !FileSystem::makeDir( mConfigPath, true ) ) + return false; + mIni.deleteValue( "theme", "language" ); + mIni.deleteValue( "window", "max_fps" ); + mIni.setValue( "terminal", "shell", terminal.shell ); + mIni.setValue( "terminal", "shell_arguments", terminal.shellArguments ); + mIni.setValue( "terminal", "working_directory", terminal.workingDirectory ); + mIni.setValue( "terminal", "execute_in_shell", terminal.executeInShell ); + mIni.setValueU( "terminal", "scrollback", terminal.historySize ); + mIni.setValue( "terminal", "cursor_style", + TerminalCursorHelper::modeToString( terminal.cursorStyle ) ); + mIni.setValueB( "terminal", "framebuffer", terminal.useFrameBuffer ); + mIni.setValueB( "terminal", "close_on_exit", terminal.closeOnExit ); + mIni.setValueU( "terminal", "initial_tabs", terminal.initialTabs ); + mIni.setValueB( "terminal", "exclusive_mode", terminal.exclusiveMode ); + mIni.setValue( "terminal", "new_terminal_behavior", + terminal.newTerminalBehavior == NewTerminalBehavior::VerticalSplit ? "vertical" + : terminal.newTerminalBehavior == NewTerminalBehavior::HorizontalSplit + ? "horizontal" + : "current" ); + mIni.setValue( "terminal", "scrollbar_type", + terminal.scrollBarType == ScrollViewType::Overlay ? "overlay" : "outside" ); + mIni.setValue( "terminal", "scrollbar_mode", + terminal.scrollBarMode == ScrollBarMode::AlwaysOn ? "always_on" + : terminal.scrollBarMode == ScrollBarMode::AlwaysOff ? "always_off" + : "auto" ); + mIni.setValue( "font", "path", font.path ); + mIni.setValue( "font", "fallback_path", font.fallbackPath ); + mIni.setValueF( "font", "size", font.size ); + mIni.setValue( "font", "hinting", Font::fontHintingToString( font.hinting ) ); + mIni.setValue( "font", "antialiasing", Font::fontAntialiasingToString( font.antialiasing ) ); + mIni.setValue( "font", "ui_path", font.uiPath ); + mIni.setValueF( "font", "ui_size", font.uiSize ); + mIni.setValueF( "window", "pixel_density", window.pixelDensity ); + mIni.setValueU( "window", "frame_rate_limit", window.maxFPS ); + mIni.setValueB( "window", "vsync", window.vsync ); + mIni.setValueB( "window", "benchmark_mode", window.benchmarkMode ); + mIni.setValueB( "window", "warn_before_closing", window.warnBeforeClose ); + mIni.setValueB( "window", "always_show_tab_bar", window.alwaysShowTabBar ); + mIni.setValue( "window", "renderer_version", + Renderer::graphicsLibraryVersionToString( window.rendererVersion ) ); + mIni.setValueU( "window", "multisamples", window.multisamples ); + mIni.setValue( "theme", "color_scheme", theme.colorScheme ); + mIni.setValue( "theme", "ui_color_scheme", + ColorSchemePreferences::toString( theme.uiColorScheme ) ); + return mIni.writeFile(); +} + +bool AppConfig::saveWindowState() { + if ( !FileSystem::isDirectory( mConfigPath ) && !FileSystem::makeDir( mConfigPath, true ) ) + return false; + mState.setValueI( "window", "width", windowState.size.getWidth() ); + mState.setValueI( "window", "height", windowState.size.getHeight() ); + mState.setValueI( "window", "x", windowState.position.x ); + mState.setValueI( "window", "y", windowState.position.y ); + mState.setValueI( "window", "display_index", windowState.displayIndex ); + mState.setValueB( "window", "maximized", windowState.maximized ); + return mState.writeFile(); +} + +void AppConfig::captureWindowState( EE::Window::Window* window ) { + if ( !window ) + return; + windowState.size = Sys::getPlatformType() == Sys::PlatformType::macOS + ? window->getSizeInScreenCoordinates() + : window->getLastWindowedSizeInScreenCoordinates(); + windowState.position = window->getPosition(); + windowState.position.x = eemax( 0, windowState.position.x ); + windowState.position.y = eemax( 0, windowState.position.y ); + windowState.displayIndex = window->getCurrentDisplayIndex(); + windowState.maximized = window->isMaximized(); +} + +} // namespace eterm diff --git a/src/tools/eterm/appconfig.hpp b/src/tools/eterm/appconfig.hpp new file mode 100644 index 000000000..cc2df5461 --- /dev/null +++ b/src/tools/eterm/appconfig.hpp @@ -0,0 +1,103 @@ +#ifndef ETERM_CONFIG_HPP +#define ETERM_CONFIG_HPP + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace eterm { + +using namespace EE; +using namespace EE::Graphics; +using namespace EE::Math; +using namespace EE::System; +using namespace EE::UI; + +enum class NewTerminalBehavior { CurrentTabBar, VerticalSplit, HorizontalSplit }; + +struct TerminalConfig { + std::string shell; + std::string shellArguments; + std::string workingDirectory; + std::string executeInShell; + size_t historySize{ 10000 }; + size_t initialTabs{ 1 }; + Terminal::TerminalCursorMode cursorStyle{ Terminal::TerminalCursorMode::SteadyUnderline }; + bool useFrameBuffer{ false }; + bool closeOnExit{ false }; + bool exclusiveMode{ false }; + NewTerminalBehavior newTerminalBehavior{ NewTerminalBehavior::CurrentTabBar }; + ScrollViewType scrollBarType{ ScrollViewType::Overlay }; + ScrollBarMode scrollBarMode{ ScrollBarMode::Auto }; +}; + +struct FontConfig { + std::string path; + std::string fallbackPath; + FontHinting hinting{ FontHinting::Full }; + FontAntialiasing antialiasing{ FontAntialiasing::Grayscale }; + Float size{ 11 }; + std::string uiPath; + Float uiSize{ 11 }; +}; + +struct WindowConfig { + Float pixelDensity{ 0 }; + Uint32 maxFPS{ static_cast( ContextSettings::FrameRateLimitScreenRefreshRate ) }; + bool vsync{ false }; + bool benchmarkMode{ false }; + bool warnBeforeClose{ false }; + bool alwaysShowTabBar{ false }; + GraphicsLibraryVersion rendererVersion{ GLv_default }; + Uint32 multisamples{ 0 }; +}; + +struct ThemeConfig { + std::string colorScheme; + ColorSchemeExtPreference uiColorScheme{ ColorSchemeExtPreference::System }; +}; + +struct WindowStateConfig { + Sizei size{ 1280, 720 }; + Vector2i position{ -1, -1 }; + int displayIndex{ 0 }; + bool maximized{ false }; +}; + +struct AppConfig { + TerminalConfig terminal; + FontConfig font; + WindowConfig window; + ThemeConfig theme; + WindowStateConfig windowState; + + explicit AppConfig( std::string configPath ); + + void load(); + + bool save(); + + bool savePreferences(); + + bool saveWindowState(); + + void captureWindowState( EE::Window::Window* window ); + + const std::string& getConfigPath() const { return mConfigPath; } + + private: + std::string mConfigPath; + IniFile mIni; + IniFile mState; +}; + +} // namespace eterm + +#endif diff --git a/src/tools/eterm/eterm.cpp b/src/tools/eterm/eterm.cpp index 3bd8b7e5f..3a6722794 100644 --- a/src/tools/eterm/eterm.cpp +++ b/src/tools/eterm/eterm.cpp @@ -1,116 +1,21 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include +#include "eterm.hpp" +#include "settingspanel.hpp" -#include #include -#include -#include -#include -using namespace EE; -using namespace EE::Graphics; -using namespace EE::Scene; -using namespace EE::System; -using namespace EE::UI; -using namespace EE::UI::Tools; -using namespace EE::Window; -using namespace eterm::Terminal; -using namespace eterm::UI; +namespace eterm { -namespace { - -struct TerminalLaunchConfig { - std::string program; - std::vector arguments; - std::string workingDirectory; - std::string executeInShell; - size_t historySize{ 10000 }; - TerminalCursorMode cursorStyle{ TerminalCursorMode::SteadyUnderline }; - FontHinting fontHinting{ FontHinting::Full }; - FontAntialiasing fontAntialiasing{ FontAntialiasing::Grayscale }; - bool useFrameBuffer{ false }; - bool keepAlive{ true }; - bool closeOnExit{ false }; -}; - -class EtermApp { - public: - int run( int argc, char* argv[] ); - - class TerminalSplitterClient : public UITabWidgetSplitter::Client { - public: - explicit TerminalSplitterClient( EtermApp& app ) : mApp( app ) {} - - void onTabCreated( UITab* tab, UIWidget* ) override; - void onWidgetFocusChange( UIWidget* ) override; - - private: - EtermApp& mApp; - }; - - private: - std::string getResourcePath() const; - String i18n( const std::string& key, const String& defaultValue ) const; - void loadColorSchemes( const std::string& resPath ); - static UITerminal* terminalFromTab( UITab* tab ); - void updateWindowTitle(); - bool hasTerminals() const; - static bool hasRunningChildren( UITab* tab ); - void closeTab( UITab* tab ); - void queueExitCloseTab( UITab* tab ); - void queueExitedTabs(); - void requestCloseTab( UITab* tab ); - void renameSession( UITerminal* terminal ); - void maximizeTabWidget( UITabWidget* tabWidget ); - void restoreMaximizedTabWidget(); - void configureTab( UITab* tab ); - UITerminal* createTerminal( UITabWidget* target = nullptr ); - UITerminal* createTerminalSplit( SplitDirection direction, UITerminal* terminal ); - void addTabKeyBindings( UITerminal* terminal ); - bool closeWindow( EE::Window::Window* ); - - EE::Window::Window* appWindow{ nullptr }; - UISceneNode* scene{ nullptr }; - UILinearLayout* mainLayout{ nullptr }; - UITabWidgetSplitter* tabSplitter{ nullptr }; - FontTrueType* terminalFont{ nullptr }; - UIIcon* terminalIcon{ nullptr }; - UIMessageBox* closeDialog{ nullptr }; - UIWidget* closeDialogWidget{ nullptr }; - UIWindow* maximizedTabWidgetWindow{ nullptr }; - UITabWidget* maximizedTabWidget{ nullptr }; - UINodeLink* maximizedTabWidgetLink{ nullptr }; - TerminalLaunchConfig terminalConfig; - std::map terminalColorSchemes; - const TerminalColorScheme* selectedColorScheme{ nullptr }; - Float terminalFontSize{ 12 }; - bool warnBeforeClose{ false }; - bool closeApproved{ false }; - bool benchmarkMode{ false }; - Clock secondsCounter; - SmallVector pendingExitCloseTabs; - TerminalSplitterClient splitterClient{ *this }; -}; - -void EtermApp::TerminalSplitterClient::onTabCreated( UITab* tab, UIWidget* ) { +void App::TerminalSplitterClient::onTabCreated( UITab* tab, UIWidget* ) { if ( mApp.terminalIcon ) tab->setIcon( mApp.terminalIcon->createDrawable( PixelDensity::dpToPxI( 12 ) ) ); mApp.configureTab( tab ); } -void EtermApp::TerminalSplitterClient::onWidgetFocusChange( UIWidget* ) { +void App::TerminalSplitterClient::onWidgetFocusChange( UIWidget* ) { mApp.updateWindowTitle(); } -std::string EtermApp::getResourcePath() const { +std::string App::getResourcePath() const { std::string resPath = Sys::getProcessPath(); #if EE_PLATFORM == EE_PLATFORM_MACOS if ( String::contains( resPath, "ecode.app" ) ) { @@ -130,11 +35,11 @@ std::string EtermApp::getResourcePath() const { return resPath; } -String EtermApp::i18n( const std::string& key, const String& defaultValue ) const { +String App::i18n( const std::string& key, const String& defaultValue ) const { return scene ? scene->i18n( key, defaultValue ) : defaultValue; } -void EtermApp::loadColorSchemes( const std::string& resPath ) { +void App::loadColorSchemes( const std::string& resPath ) { auto colorSchemes = TerminalColorScheme::loadFromFile( resPath + "colorschemes/terminalcolorschemes.conf" ); const std::string configColorSchemesPath = @@ -153,13 +58,106 @@ void EtermApp::loadColorSchemes( const std::string& resPath ) { } } -UITerminal* EtermApp::terminalFromTab( UITab* tab ) { +UITerminal* App::terminalFromTab( UITab* tab ) { return tab && tab->getOwnedWidget() && tab->getOwnedWidget()->isType( UI_TYPE_TERMINAL ) ? tab->getOwnedWidget()->asType() : nullptr; } -void EtermApp::updateWindowTitle() { +void App::savePreferences() { + if ( config && !config->savePreferences() ) + Log::error( "Could not save eterm configuration to %s", config->getConfigPath() ); +} + +void App::forEachTerminal( const std::function& fn ) { + if ( !tabSplitter ) + return; + tabSplitter->forEachWidgetType( + UI_TYPE_TERMINAL, [&fn]( UIWidget* widget ) { fn( widget->asType() ); } ); +} + +void App::createNewTerminal() { + auto* current = tabSplitter ? tabSplitter->getCurWidget() : nullptr; + auto* terminal = + current && current->isType( UI_TYPE_TERMINAL ) ? current->asType() : nullptr; + switch ( config->terminal.newTerminalBehavior ) { + case NewTerminalBehavior::VerticalSplit: + createTerminalSplit( SplitDirection::Right, terminal ); + break; + case NewTerminalBehavior::HorizontalSplit: + createTerminalSplit( SplitDirection::Bottom, terminal ); + break; + default: + createTerminal(); + break; + } +} + +void App::openFontPicker( bool uiFont, bool fallbackFont ) { + const Uint32 flags = UIFontPickerDialog::ShowStyle | + ( fallbackFont ? 0 : UIFontPickerDialog::ShowSize ) | + ( !uiFont && !fallbackFont ? UIFontPickerDialog::MonospaceOnly : 0 ); + auto* dialog = UIFontPickerDialog::New( flags ); + dialog->setTitle( i18n( "select_font", "Select Font" ) ); + dialog->setCloseShortcut( KEY_ESCAPE ); + std::string currentPath = uiFont ? config->font.uiPath + : fallbackFont ? config->font.fallbackPath + : config->font.path; + if ( !currentPath.empty() ) + dialog->setSelectedFont( currentPath ); + if ( !fallbackFont ) { + auto selection = dialog->getSelection(); + selection.size = static_cast( uiFont ? config->font.uiSize : config->font.size ); + dialog->setSelection( selection ); + } + dialog->setOnFontPicked( [this, uiFont, fallbackFont]( const UIFontSelection& selection ) { + if ( selection.font.path.empty() ) + return; + auto& resourceScope = *scene->getResourceScope(); + if ( uiFont ) { + auto font = FontTrueType::New( "eterm-ui-font", resourceScope ); + if ( font->loadFromFile( selection.font.path ) ) { + config->font.uiPath = selection.font.path; + config->font.uiSize = selection.size; + scene->getUIThemeManager()->setDefaultFont( font.get() ); + scene->getUIThemeManager()->setDefaultFontSize( config->font.uiSize ); + scene->getRoot()->reloadStyle( true, true, true, true, true ); + } + } else if ( fallbackFont ) { + auto font = FontTrueType::New( "eterm-fallback-font", resourceScope ); + if ( font->loadFromFile( selection.font.path ) ) { + config->font.fallbackPath = selection.font.path; + resourceScope.getFontService().addFallbackFont( std::move( font ) ); + } + } else { + auto font = FontTrueType::New( "eterm-monospace", resourceScope ); + if ( font->loadFromFile( selection.font.path ) ) { + config->font.path = selection.font.path; + config->font.size = selection.size; + terminalFont = font.get(); + terminalFontSize = PixelDensity::dpToPx( config->font.size ); + FontFamily::loadFromRegular( terminalFont ); + forEachTerminal( [this]( UITerminal* terminal ) { + terminal->setFont( terminalFont ); + terminal->setFontSize( terminalFontSize ); + } ); + } + } + savePreferences(); + } ); + dialog->show(); +} + +void App::showSettings() { + if ( settingsWindow ) { + settingsWindow->show(); + settingsWindow->toFront(); + return; + } + settingsWindow = eterm::SettingsPanel::create( *this ); +} + +void App::updateWindowTitle() { if ( !appWindow ) return; String title{ "eterm" }; @@ -181,7 +179,7 @@ void EtermApp::updateWindowTitle() { appWindow->setTitle( title ); } -bool EtermApp::hasTerminals() const { +bool App::hasTerminals() const { bool found = false; if ( tabSplitter ) tabSplitter->forEachWidgetStoppable( [&found]( UIWidget* ) { @@ -191,27 +189,27 @@ bool EtermApp::hasTerminals() const { return found; } -bool EtermApp::hasRunningChildren( UITab* tab ) { +bool App::hasRunningChildren( UITab* tab ) { auto* terminal = terminalFromTab( tab ); return terminal && terminal->getTerm() && Sys::processHasChildren( terminal->getTerm()->getProcessId() ); } -void EtermApp::closeTab( UITab* tab ) { +void App::closeTab( UITab* tab ) { if ( !tabSplitter || !tab || !tab->getOwnedWidget() ) return; tabSplitter->closeTab( tab->getOwnedWidget()->asType(), UITabWidget::FocusTabBehavior::Default ); } -void EtermApp::queueExitCloseTab( UITab* tab ) { +void App::queueExitCloseTab( UITab* tab ) { if ( tab && std::find( pendingExitCloseTabs.begin(), pendingExitCloseTabs.end(), tab ) == pendingExitCloseTabs.end() ) { pendingExitCloseTabs.emplace_back( tab ); } } -void EtermApp::queueExitedTabs() { +void App::queueExitedTabs() { if ( !terminalConfig.closeOnExit ) return; tabSplitter->forEachTab( [this]( UITab* tab ) { @@ -225,7 +223,7 @@ void EtermApp::queueExitedTabs() { } ); } -void EtermApp::requestCloseTab( UITab* tab ) { +void App::requestCloseTab( UITab* tab ) { if ( !warnBeforeClose || !hasRunningChildren( tab ) ) { closeTab( tab ); return; @@ -250,7 +248,7 @@ void EtermApp::requestCloseTab( UITab* tab ) { closeDialog->showWhenReady(); } -void EtermApp::renameSession( UITerminal* terminal ) { +void App::renameSession( UITerminal* terminal ) { if ( !terminal ) return; auto* msgBox = @@ -266,7 +264,7 @@ void EtermApp::renameSession( UITerminal* terminal ) { msgBox->showWhenReady(); } -void EtermApp::maximizeTabWidget( UITabWidget* tabWidget ) { +void App::maximizeTabWidget( UITabWidget* tabWidget ) { if ( !tabWidget || scene->getRoot()->hasChild( "detached_tab_widget_win" ) ) return; @@ -284,7 +282,7 @@ void EtermApp::maximizeTabWidget( UITabWidget* tabWidget ) { win->setKeyBindingCommand( "close-maximized-tab-widget", [this] { restoreMaximizedTabWidget(); } ); win->getKeyBindings().addKeybind( { KEY_ESCAPE }, "close-maximized-tab-widget" ); - win->setCheckEphemeralCloseFn( [this]( Node* focusNode ) { + win->setCheckEphemeralCloseFn( []( Node* focusNode ) { if ( focusNode->isType( UI_TYPE_POPUPMENU ) ) return false; if ( focusNode->getSceneNode()->isUISceneNode() ) { @@ -335,7 +333,7 @@ void EtermApp::maximizeTabWidget( UITabWidget* tabWidget ) { } ); } -void EtermApp::restoreMaximizedTabWidget() { +void App::restoreMaximizedTabWidget() { if ( !scene || !SceneManager::isActive() ) return; if ( !maximizedTabWidgetLink || !maximizedTabWidget ) @@ -359,7 +357,7 @@ void EtermApp::restoreMaximizedTabWidget() { win->close(); } -void EtermApp::configureTab( UITab* tab ) { +void App::configureTab( UITab* tab ) { tab->on( Event::OnCreateContextMenu, [this]( const Event* event ) { const auto* menuEvent = static_cast( event ); auto* menu = menuEvent->getMenu(); @@ -392,6 +390,7 @@ void EtermApp::configureTab( UITab* tab ) { terminal->getExclusiveMode() ); exclusiveMode->setId( UITerminal::getExclusiveModeToggleCommandName() ); addItem( i18n( "rename_session", "Rename Session" ), "", "terminal-rename" ); + addItem( i18n( "settings", "Settings" ), "settings", "open-settings" ); menu->addSeparator(); const bool canMove = clickedTab->getTabWidget()->getTabCount() > 1; @@ -427,6 +426,8 @@ void EtermApp::configureTab( UITab* tab ) { maximizeTabWidget( clickedTab->getTabWidget() ); } else if ( command == "restore-maximized-tab-widget" ) { restoreMaximizedTabWidget(); + } else if ( command == "open-settings" ) { + showSettings(); } else { terminal->execute( command ); } @@ -437,7 +438,7 @@ void EtermApp::configureTab( UITab* tab ) { } ); } -UITerminal* EtermApp::createTerminal( UITabWidget* target ) { +UITerminal* App::createTerminal( UITabWidget* target ) { if ( !target && tabSplitter ) { auto* current = tabSplitter->getCurWidget(); target = current ? tabSplitter->tabWidgetFromWidget( current ) @@ -464,6 +465,9 @@ UITerminal* EtermApp::createTerminal( UITabWidget* target ) { terminal->getTerm()->setCursorMode( terminalConfig.cursorStyle ); terminal->getTerm()->setFontHinting( terminalConfig.fontHinting ); terminal->getTerm()->setFontAntialiasing( terminalConfig.fontAntialiasing ); + terminal->setExclusiveMode( config->terminal.exclusiveMode ); + terminal->setScrollViewType( config->terminal.scrollBarType ); + terminal->setVerticalScrollMode( config->terminal.scrollBarMode ); if ( selectedColorScheme ) terminal->setColorScheme( *selectedColorScheme ); @@ -481,6 +485,30 @@ UITerminal* EtermApp::createTerminal( UITabWidget* target ) { if ( terminalConfig.closeOnExit && event.type == TerminalDisplay::EventType::PROCESS_EXIT ) queueExitCloseTab( tab ); } ); + terminal->on( Event::OnCreateContextMenu, [this, terminal]( const Event* event ) { + auto menu = static_cast( event )->getMenu(); + const auto addItem = [this, menu]( const String& text, const std::string& icon, + const std::string& command ) { + DrawablePtr drawable; + if ( auto* menuIcon = scene->findIcon( icon ) ) + drawable = menuIcon->createDrawable( PixelDensity::dpToPxI( 12 ) ); + auto* item = menu->add( text, std::move( drawable ) ); + item->setId( command ); + return item; + }; + menu->addSeparator(); + addItem( i18n( "settings", "Settings" ), "settings", "open-settings" ); + menu->on( Event::OnItemClicked, [this, terminal]( const Event* itemEvent ) { + if ( !itemEvent->getNode()->isType( UI_TYPE_MENUITEM ) ) + return; + const auto& command = itemEvent->getNode()->getId(); + auto* previous = tabSplitter->getCurWidget(); + tabSplitter->setCurrentWidget( terminal ); + terminal->execute( command ); + if ( previous && tabSplitter->ownedWidgetExists( previous ) ) + tabSplitter->setCurrentWidget( previous ); + } ); + } ); tabSplitter->setCurrentWidget( terminal ); if ( !terminalConfig.executeInShell.empty() ) terminal->executeFile( terminalConfig.executeInShell ); @@ -489,15 +517,16 @@ UITerminal* EtermApp::createTerminal( UITabWidget* target ) { return terminal; } -UITerminal* EtermApp::createTerminalSplit( SplitDirection direction, UITerminal* terminal ) { +UITerminal* App::createTerminalSplit( SplitDirection direction, UITerminal* terminal ) { auto* source = terminal ? tabSplitter->tabWidgetFromWidget( terminal ) : nullptr; auto* target = source ? tabSplitter->splitTabWidget( direction, source ) : nullptr; return target ? createTerminal( target ) : nullptr; } -void EtermApp::addTabKeyBindings( UITerminal* terminal ) { +void App::addTabKeyBindings( UITerminal* terminal ) { tabSplitter->registerSplitterCommands( *terminal ); - terminal->setCommand( "create-new-terminal", [this] { createTerminal(); } ); + terminal->setCommand( "create-new-terminal", [this] { createNewTerminal(); } ); + terminal->setCommand( "open-settings", [this] { showSettings(); } ); terminal->setCommand( "debug-widget-tree-view", [this] { UIWidgetInspector::create( scene ); } ); terminal->setCommand( "terminal-rename", [this, terminal] { renameSession( terminal ); } ); @@ -528,6 +557,7 @@ void EtermApp::addTabKeyBindings( UITerminal* terminal ) { [this, terminal] { tabSplitter->switchNextSplit( terminal ); } ); terminal->addKeyBinding( { KEY_T, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "create-new-terminal" ); + terminal->addKeyBinding( { KEY_COMMA, KeyMod::getDefaultModifier() }, "open-settings" ); terminal->addKeyBinding( { KEY_W, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "close-tab" ); terminal->addKeyBinding( { KEY_F11, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "debug-widget-tree-view" ); @@ -551,7 +581,7 @@ void EtermApp::addTabKeyBindings( UITerminal* terminal ) { "switch-to-next-split" ); } -bool EtermApp::closeWindow( EE::Window::Window* ) { +bool App::closeWindow( EE::Window::Window* ) { if ( closeApproved || !warnBeforeClose ) return true; bool running = false; @@ -578,25 +608,30 @@ bool EtermApp::closeWindow( EE::Window::Window* ) { return false; } -int EtermApp::run( int argc, char* argv[] ) { +int App::run( int argc, char* argv[] ) { #ifdef EE_DEBUG Log::instance()->setLogToStdOut( !Runtime::isOffscreen() ); Log::instance()->setLiveWrite( true ); #endif + config = std::make_unique( Sys::getConfigPath( "eterm" ) ); + config->load(); args::ArgumentParser parser( "eterm" ); args::HelpFlag help( parser, "help", "Display this help menu", { 'h', "help" } ); args::ValueFlag shell( parser, "shell", "Shell name or path", { 's', "shell" }, - "" ); + config->terminal.shell ); args::ValueFlag shellArgs( parser, "shell-args", "Shell command line arguments", - { "shell-args" }, "" ); + { "shell-args" }, config->terminal.shellArguments ); args::ValueFlag historySize( parser, "scrollback", "Maximum history size (lines)", - { 'l', "scrollback" }, 10000 ); + { 'l', "scrollback" }, config->terminal.historySize ); args::Flag fb( parser, "framebuffer", "Use frame buffer (more memory usage, less CPU usage)", { "fb", "framebuffer" } ); - args::ValueFlag fontPath( parser, "fontpath", "Font path", { 'f', "font" } ); + args::ValueFlag fontPath( parser, "fontpath", "Font path", { 'f', "font" }, + config->font.path ); args::ValueFlag fallbackFontPath( parser, "fallback-fontpath", - "Fallback Font path", { "fallback-font" } ); - args::ValueFlag fontSize( parser, "fontsize", "Font size (in dp)", { "fontsize" }, 11 ); + "Fallback Font path", { "fallback-font" }, + config->font.fallbackPath ); + args::ValueFlag fontSize( parser, "fontsize", "Font size (in dp)", { "fontsize" }, + config->font.size ); const std::unordered_map fontHintingMap{ { "none", FontHinting::None }, { "slight", FontHinting::Slight }, @@ -604,7 +639,7 @@ int EtermApp::run( int argc, char* argv[] ) { }; args::MapFlag fontHinting( parser, "font-hinting", "Font hinting mode (accepted values: none, slight, full)", - { "font-hinting" }, fontHintingMap, FontHinting::Full ); + { "font-hinting" }, fontHintingMap, config->font.hinting ); const std::unordered_map fontAntialiasingMap{ { "none", FontAntialiasing::None }, { "grayscale", FontAntialiasing::Grayscale }, @@ -613,33 +648,35 @@ int EtermApp::run( int argc, char* argv[] ) { args::MapFlag fontAntialiasing( parser, "font-antialiasing", "Font antialiasing mode (accepted values: none, grayscale, subpixel)", - { "font-antialiasing" }, fontAntialiasingMap, FontAntialiasing::Grayscale ); - args::ValueFlag width( parser, "winwidth", "Window width (in dp)", { "width" }, 1280 ); + { "font-antialiasing" }, fontAntialiasingMap, config->font.antialiasing ); + args::ValueFlag width( parser, "winwidth", "Window width (in dp)", { "width" }, + config->windowState.size.getWidth() ); args::ValueFlag height( parser, "winheight", "Window height (in dp)", { "height" }, - 720 ); + config->windowState.size.getHeight() ); args::ValueFlag pixelDensity( parser, "pixel-density", "Set default application pixel density", { 'd', "pixel-density" } ); args::Positional wd( parser, "wording-dir", "Working Directory / executable" ); args::Flag closeOnExit( parser, "close-on-exit", "close the application when the executable exits", { 'c', "close" } ); - args::ValueFlag executeInShell( - parser, "execute-in-shell", "execute program in shell", { 'e', "execute" }, "" ); + args::ValueFlag executeInShell( parser, "execute-in-shell", + "execute program in shell", { 'e', "execute" }, + config->terminal.executeInShell ); args::Flag vsync( parser, "vsync", "Enable vsync", { "vsync" } ); args::ValueFlag colorScheme( parser, "color-scheme", "Load color scheme", - { "color-scheme" }, "" ); + { "color-scheme" }, config->theme.colorScheme ); args::Flag listColorSchemes( parser, "color-schemes", "Lists color schemes", { "list-color-schemes" } ); args::ValueFlag maxFPS( parser, "max-fps", "Maximum rendering frames per second of the terminal. Default " "value will be the refresh rate of the screen.", - { "max-fps" }, 0 ); + { "max-fps" }, config->window.maxFPS ); args::MapFlag cursorStyle( parser, "cursor-style", "Sets the cursor-style (accepted values: blinking_block, steady_block, blink_underline, " "steady_underline, blink_bar, steady_bar)", { "cursor-style" }, TerminalCursorHelper::getTerminalCursorModeMap(), - TerminalCursorMode::SteadyUnderline ); + config->terminal.cursorStyle ); args::Flag benchmarkModeFlag( parser, "benchmark-mode", "Render as much as possible to measure the rendering performance.", { "benchmark-mode" } ); @@ -651,7 +688,7 @@ int EtermApp::run( int argc, char* argv[] ) { "Always show the tab bar, even with a single tab.", { "always-show-tab-bar" } ); args::ValueFlag initialTabs( parser, "tabs", "Number of initial terminal tabs", - { "tabs" }, 1 ); + { "tabs" }, config->terminal.initialTabs ); try { parser.ParseCLI( argc, argv ); @@ -667,56 +704,105 @@ int EtermApp::run( int argc, char* argv[] ) { std::cerr << parser; return EXIT_FAILURE; } + if ( !config->savePreferences() ) + Log::error( "Could not save eterm configuration to %s", config->getConfigPath() ); + + config->terminal.shell = shell.Get(); + config->terminal.shellArguments = shellArgs.Get(); + config->terminal.historySize = historySize.Get(); + config->font.path = fontPath.Get(); + config->font.fallbackPath = fallbackFontPath.Get(); + config->font.size = fontSize.Get(); + config->font.hinting = fontHinting.Get(); + config->font.antialiasing = fontAntialiasing.Get(); + config->windowState.size = { static_cast( width.Get() ), + static_cast( height.Get() ) }; + if ( pixelDensity ) + config->window.pixelDensity = pixelDensity.Get(); + if ( wd ) + config->terminal.workingDirectory = wd.Get(); + config->terminal.executeInShell = executeInShell.Get(); + config->theme.colorScheme = colorScheme.Get(); + config->window.maxFPS = maxFPS.Get(); + config->terminal.cursorStyle = cursorStyle.Get(); + config->terminal.initialTabs = initialTabs.Get(); + config->terminal.useFrameBuffer |= fb.Get(); + config->terminal.closeOnExit |= closeOnExit.Get(); + config->window.vsync |= vsync.Get(); + config->window.benchmarkMode |= benchmarkModeFlag.Get(); + config->window.warnBeforeClose |= warnBeforeCloseFlag.Get(); + config->window.alwaysShowTabBar |= alwaysShowTabBar.Get(); const std::string initialWorkingDirectory = FileSystem::getCurrentWorkingDirectory(); const std::string resPath = getResourcePath(); - if ( listColorSchemes.Get() || colorScheme ) - loadColorSchemes( resPath ); + loadColorSchemes( resPath ); if ( listColorSchemes.Get() ) { std::cout << "Color schemes:\n"; for ( const auto& colorSchemeEntry : terminalColorSchemes ) std::cout << "\t" << colorSchemeEntry.first << "\n"; return EXIT_SUCCESS; } - if ( colorScheme ) { - auto colorSchemeIt = terminalColorSchemes.find( colorScheme.Get() ); + if ( !config->theme.colorScheme.empty() ) { + auto colorSchemeIt = terminalColorSchemes.find( config->theme.colorScheme ); if ( colorSchemeIt != terminalColorSchemes.end() ) selectedColorScheme = &colorSchemeIt->second; } DisplayManager* displayManager = Engine::instance()->getDisplayManager(); - Display* currentDisplay = displayManager->getDisplayIndex( 0 ); + Display* currentDisplay = displayManager->getDisplayIndex( + config->windowState.displayIndex >= 0 && + config->windowState.displayIndex < displayManager->getDisplayCount() + ? config->windowState.displayIndex + : 0 ); if ( !currentDisplay ) { std::cerr << "Display not found, exiting" << std::endl; return EXIT_FAILURE; } - Sizei windowSize( width.Get(), height.Get() ); + Sizei windowSize( config->windowState.size ); const auto displaySize = currentDisplay->getUsableBounds().getSize(); if ( displaySize.getWidth() > 0 && windowSize.getWidth() >= displaySize.getWidth() ) windowSize.setWidth( static_cast( displaySize.getWidth() * 0.8f ) ); if ( displaySize.getHeight() > 0 && windowSize.getHeight() >= displaySize.getHeight() ) windowSize.setHeight( static_cast( displaySize.getHeight() * 0.75f ) ); + FontTrueTypePtr uiFont; + if ( !config->font.uiPath.empty() && FileSystem::fileExists( config->font.uiPath ) ) { + uiFont = FontTrueType::New( "eterm-ui-font" ); + if ( !uiFont->loadFromFile( config->font.uiPath ) ) + uiFont.reset(); + } UIApplication::Settings appSettings; appSettings.basePath = FileSystem::removeLastFolderFromPath( resPath ); - appSettings.pixelDensity = - pixelDensity ? pixelDensity.Get() : currentDisplay->getPixelDensity(); - appSettings.fontHinting = fontHinting.Get(); - appSettings.fontAntialiasing = fontAntialiasing.Get(); + appSettings.pixelDensity = config->window.pixelDensity > 0 ? config->window.pixelDensity + : currentDisplay->getPixelDensity(); + appSettings.fontHinting = config->font.hinting; + appSettings.fontAntialiasing = config->font.antialiasing; + appSettings.baseFont = uiFont.get(); const Int32 frameRateLimit = - benchmarkModeFlag.Get() - ? 0 - : static_cast( maxFPS.Get() ? maxFPS.Get() : currentDisplay->getRefreshRate() ); - UIApplication app( WindowSettings( windowSize.getWidth(), windowSize.getHeight(), "eterm", - WindowStyle::Default, WindowBackend::Default, 32, - resPath + "icon/eterm.png", - appSettings.pixelDensity.value() ), - appSettings, ContextSettings( vsync.Get(), frameRateLimit ) ); + config->window.benchmarkMode ? 0 : static_cast( config->window.maxFPS ); + UIApplication app( + WindowSettings( windowSize.getWidth(), windowSize.getHeight(), "eterm", + WindowStyle::Default, WindowBackend::Default, 32, + resPath + "icon/eterm.png", appSettings.pixelDensity.value() ), + appSettings, + ContextSettings( config->window.vsync, frameRateLimit, config->window.multisamples, + config->window.rendererVersion ) ); appWindow = app.getWindow(); scene = app.getUI(); if ( !appWindow || !appWindow->isOpen() || !scene ) return EXIT_FAILURE; + scene->setColorSchemePreference( config->theme.uiColorScheme ); + scene->getUIThemeManager()->setDefaultFontSize( config->font.uiSize ); + if ( config->windowState.position != Vector2i( -1, -1 ) ) { + appWindow->setPosition( config->windowState.position.x + + ( config->windowState.maximized ? -1 : 0 ), + config->windowState.position.y ); + } +#if EE_PLATFORM != EE_PLATFORM_EMSCRIPTEN && EE_PLATFORM != EE_PLATFORM_MACOS + if ( config->windowState.maximized ) + appWindow->maximize(); +#endif FileSystem::changeWorkingDirectory( initialWorkingDirectory ); appWindow->setClearColor( RGB( 0, 0, 0 ) ); scene->getUIThemeManager()->setDefaultEffectsEnabled( false ); @@ -748,9 +834,9 @@ int EtermApp::run( int argc, char* argv[] ) { "eterm", remixIconFont.get(), noniconsFont.get(), codIconFont.get() ) ); terminalIcon = scene->findIcon( "terminal" ); } - if ( fontPath && FileSystem::fileExists( fontPath.Get() ) ) { + if ( !config->font.path.empty() && FileSystem::fileExists( config->font.path ) ) { terminalFont = FontTrueType::New( "eterm-monospace", resourceScope ).get(); - if ( terminalFont->loadFromFile( fontPath.Get() ) ) + if ( terminalFont->loadFromFile( config->font.path ) ) FontFamily::loadFromRegular( terminalFont ); else terminalFont = nullptr; @@ -764,34 +850,37 @@ int EtermApp::run( int argc, char* argv[] ) { FontFamily::loadFromRegular( terminalFont, "DejaVuSansMono" ); } - if ( fallbackFontPath ) { - if ( FileSystem::fileExists( fallbackFontPath.Get() ) ) { + if ( !config->font.fallbackPath.empty() ) { + if ( FileSystem::fileExists( config->font.fallbackPath ) ) { auto fallback = FontTrueType::New( "eterm-fallback-font", resourceScope ); - if ( fallback->loadFromFile( fallbackFontPath.Get() ) ) + if ( fallback->loadFromFile( config->font.fallbackPath ) ) resourceScope.getFontService().addFallbackFont( std::move( fallback ) ); } } else if ( auto fallback = resourceScope.findFont( "DroidSansFallbackFull" ) ) { resourceScope.getFontService().addFallbackFont( std::move( fallback ) ); } - const std::string launchPath = wd ? wd.Get() : initialWorkingDirectory; + const std::string launchPath = config->terminal.workingDirectory.empty() + ? initialWorkingDirectory + : config->terminal.workingDirectory; FileInfo launchFile( launchPath ); const bool launchExecutable = launchFile.isRegularFile() && launchFile.isExecutable(); - terminalConfig.program = launchExecutable ? launchFile.getFilepath() : shell.Get(); - terminalConfig.arguments = - shellArgs ? String::split( shellArgs.Get() ) : std::vector{}; + terminalConfig.program = launchExecutable ? launchFile.getFilepath() : config->terminal.shell; + terminalConfig.arguments = config->terminal.shellArguments.empty() + ? std::vector{} + : String::split( config->terminal.shellArguments ); terminalConfig.workingDirectory = launchFile.getDirectoryPath(); - terminalConfig.executeInShell = executeInShell.Get(); - terminalConfig.historySize = historySize.Get(); - terminalConfig.cursorStyle = cursorStyle.Get(); - terminalConfig.fontHinting = fontHinting.Get(); - terminalConfig.fontAntialiasing = fontAntialiasing.Get(); - terminalConfig.useFrameBuffer = fb.Get(); - terminalConfig.keepAlive = !launchExecutable && !shell; - terminalConfig.closeOnExit = closeOnExit.Get(); - warnBeforeClose = warnBeforeCloseFlag.Get(); - benchmarkMode = benchmarkModeFlag.Get(); - terminalFontSize = PixelDensity::dpToPx( fontSize.Get() ); + terminalConfig.executeInShell = config->terminal.executeInShell; + terminalConfig.historySize = config->terminal.historySize; + terminalConfig.cursorStyle = config->terminal.cursorStyle; + terminalConfig.fontHinting = config->font.hinting; + terminalConfig.fontAntialiasing = config->font.antialiasing; + terminalConfig.useFrameBuffer = config->terminal.useFrameBuffer; + terminalConfig.keepAlive = !launchExecutable && config->terminal.shell.empty(); + terminalConfig.closeOnExit = config->terminal.closeOnExit; + warnBeforeClose = config->window.warnBeforeClose; + benchmarkMode = config->window.benchmarkMode; + terminalFontSize = PixelDensity::dpToPx( config->font.size ); mainLayout = UILinearLayout::NewVertical(); mainLayout->setParent( scene->getRoot() ); @@ -799,7 +888,7 @@ int EtermApp::run( int argc, char* argv[] ) { mainLayout->setPixelsSize( appWindow->getSize().asFloat() ); tabSplitter = UITabWidgetSplitter::New( &splitterClient, scene ); - tabSplitter->setHideTabBarOnSingleTab( !alwaysShowTabBar.Get() ); + tabSplitter->setHideTabBarOnSingleTab( !config->window.alwaysShowTabBar ); tabSplitter->setCanCreateSplitFn( [this]( SplitDirection, UIWidget* ) { restoreMaximizedTabWidget(); return true; @@ -841,7 +930,8 @@ int EtermApp::run( int argc, char* argv[] ) { } mainLayout->updateLayout(); - for ( size_t tab = 0; tab < eemax( static_cast( 1 ), initialTabs.Get() ); ++tab ) { + for ( size_t tab = 0; tab < eemax( static_cast( 1 ), config->terminal.initialTabs ); + ++tab ) { if ( !createTerminal( tabs ) ) { appWindow->showMessageBox( EE::Window::Window::MessageBoxType::Error, "eterm", @@ -878,12 +968,15 @@ int EtermApp::run( int argc, char* argv[] ) { secondsCounter.restart(); } } ); + config->captureWindowState( appWindow ); + if ( !config->saveWindowState() ) + Log::error( "Could not save eterm window state to %s", config->getConfigPath() ); return EXIT_SUCCESS; } -} // namespace +} // namespace eterm EE_MAIN_FUNC int main( int argc, char* argv[] ) { - EtermApp app; + App app; return app.run( argc, argv ); } diff --git a/src/tools/eterm/eterm.hpp b/src/tools/eterm/eterm.hpp new file mode 100644 index 000000000..41161f7e3 --- /dev/null +++ b/src/tools/eterm/eterm.hpp @@ -0,0 +1,112 @@ +#pragma once + +#include "appconfig.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace EE; +using namespace EE::Graphics; +using namespace EE::Scene; +using namespace EE::System; +using namespace EE::UI; +using namespace EE::UI::Tools; +using namespace EE::Window; +using namespace eterm::Terminal; +using namespace eterm::UI; +using namespace eterm; + +namespace eterm { + +struct TerminalLaunchConfig { + std::string program; + std::vector arguments; + std::string workingDirectory; + std::string executeInShell; + size_t historySize{ 10000 }; + TerminalCursorMode cursorStyle{ TerminalCursorMode::SteadyUnderline }; + FontHinting fontHinting{ FontHinting::Full }; + FontAntialiasing fontAntialiasing{ FontAntialiasing::Grayscale }; + bool useFrameBuffer{ false }; + bool keepAlive{ true }; + bool closeOnExit{ false }; +}; + +class App { + public: + int run( int argc, char* argv[] ); + + class TerminalSplitterClient : public UITabWidgetSplitter::Client { + public: + explicit TerminalSplitterClient( App& app ) : mApp( app ) {} + + void onTabCreated( UITab* tab, UIWidget* ) override; + void onWidgetFocusChange( UIWidget* ) override; + + private: + App& mApp; + }; + + private: + friend struct SettingsPanel; + + std::string getResourcePath() const; + String i18n( const std::string& key, const String& defaultValue ) const; + void loadColorSchemes( const std::string& resPath ); + static UITerminal* terminalFromTab( UITab* tab ); + void updateWindowTitle(); + bool hasTerminals() const; + static bool hasRunningChildren( UITab* tab ); + void closeTab( UITab* tab ); + void queueExitCloseTab( UITab* tab ); + void queueExitedTabs(); + void requestCloseTab( UITab* tab ); + void renameSession( UITerminal* terminal ); + void maximizeTabWidget( UITabWidget* tabWidget ); + void restoreMaximizedTabWidget(); + void configureTab( UITab* tab ); + UITerminal* createTerminal( UITabWidget* target = nullptr ); + UITerminal* createTerminalSplit( SplitDirection direction, UITerminal* terminal ); + void addTabKeyBindings( UITerminal* terminal ); + void showSettings(); + void openFontPicker( bool uiFont, bool fallbackFont = false ); + void forEachTerminal( const std::function& fn ); + void savePreferences(); + void createNewTerminal(); + bool closeWindow( EE::Window::Window* ); + + EE::Window::Window* appWindow{ nullptr }; + UISceneNode* scene{ nullptr }; + UILinearLayout* mainLayout{ nullptr }; + UITabWidgetSplitter* tabSplitter{ nullptr }; + FontTrueType* terminalFont{ nullptr }; + UIIcon* terminalIcon{ nullptr }; + UIMessageBox* closeDialog{ nullptr }; + UIWindow* settingsWindow{ nullptr }; + UIWidget* closeDialogWidget{ nullptr }; + UIWindow* maximizedTabWidgetWindow{ nullptr }; + UITabWidget* maximizedTabWidget{ nullptr }; + UINodeLink* maximizedTabWidgetLink{ nullptr }; + TerminalLaunchConfig terminalConfig; + std::unique_ptr config; + std::map terminalColorSchemes; + const TerminalColorScheme* selectedColorScheme{ nullptr }; + Float terminalFontSize{ 12 }; + bool warnBeforeClose{ false }; + bool closeApproved{ false }; + bool benchmarkMode{ false }; + Clock secondsCounter; + SmallVector pendingExitCloseTabs; + TerminalSplitterClient splitterClient{ *this }; +}; + +} // namespace eterm diff --git a/src/tools/eterm/settingspanel.cpp b/src/tools/eterm/settingspanel.cpp new file mode 100644 index 000000000..817bea06c --- /dev/null +++ b/src/tools/eterm/settingspanel.cpp @@ -0,0 +1,468 @@ +#include "settingspanel.hpp" +#include "eterm.hpp" + +#include +#include +#include + +#include + +using namespace EE::UI::Tools; + +namespace eterm { + +UIWindow* SettingsPanel::create( App& app ) { + UIWindow::StyleConfig windowStyle{ UI_WIN_DEFAULT_FLAGS | UI_WIN_MAXIMIZE_BUTTON | + UI_WIN_MODAL }; + auto settingsWindow = UIWindow::NewOpt( UIWindow::SIMPLE_LAYOUT, windowStyle ); + settingsWindow->setId( "settings_panel" ); + settingsWindow->setTitle( app.i18n( "settings", "Settings" ) ); + const auto sceneSize = app.scene->getPixelsSize(); + settingsWindow->setPixelsSize( { eeclamp( sceneSize.getWidth() * 0.82f, 720.f, 1200.f ), + eeclamp( sceneSize.getHeight() * 0.82f, 520.f, 850.f ) } ); + settingsWindow->setMinWindowSize( 640, 440 ); + auto* panel = UISettingsPanel::New( settingsWindow->getContainer() ); + panel->setSearchResultsText( app.i18n( "search_results", "Search Results" ) ); + + panel->addCategory( "appearance.theme", app.i18n( "appearance", "Appearance" ), + app.i18n( "theme_and_language", "Theme & Language" ) ); + panel->addChoice( + { "uiColorScheme", "appearance.theme", + app.i18n( "ui_prefes_color_scheme", "UI Prefers Color Scheme" ), + app.i18n( + "ui_prefers_color_scheme_desc", + "Choose whether the interface follows the system, light, or dark appearance." ) }, + { app.i18n( "system", "System" ), app.i18n( "light", "Light" ), + app.i18n( "dark", "Dark" ) }, + [&app] { + return app.config->theme.uiColorScheme == ColorSchemeExtPreference::System ? size_t{ 0 } + : app.config->theme.uiColorScheme == ColorSchemeExtPreference::Light + ? size_t{ 1 } + : size_t{ 2 }; + }, + [&app]( size_t selected ) { + static constexpr ColorSchemeExtPreference values[] = { ColorSchemeExtPreference::System, + ColorSchemeExtPreference::Light, + ColorSchemeExtPreference::Dark }; + app.config->theme.uiColorScheme = values[std::min( selected, size_t{ 2 } )]; + app.scene->setColorSchemePreference( app.config->theme.uiColorScheme ); + app.savePreferences(); + } ); + panel->addCategory( "appearance.fonts", app.i18n( "appearance", "Appearance" ), + app.i18n( "fonts_and_scale", "Fonts & Scale" ) ); + panel->addAction( + { "uiFont", "appearance.fonts", + app.i18n( "ui_font_and_size_ellipsis", "UI Font & Size..." ), + app.i18n( "ui_font_desc", "Choose the proportional font used by the interface." ) }, + app.i18n( "choose_font", "Choose Font..." ), [&app] { app.openFontPicker( true ); } ); + panel->addAction( + { "terminalFont", "appearance.fonts", + app.i18n( "terminal_font_and_size_ellipsis", "Terminal Font & Size..." ), + app.i18n( "terminal_font_desc", "Choose the monospace font used by terminals." ) }, + app.i18n( "choose_font", "Choose Font..." ), [&app] { app.openFontPicker( false ); } ); + panel->addAction( + { "fallbackFont", "appearance.fonts", + app.i18n( "fallback_font_ellipsis", "Fallback Font..." ), + app.i18n( "fallback_font_desc", "Choose the font used for missing glyphs." ) }, + app.i18n( "choose_font", "Choose Font..." ), + [&app] { app.openFontPicker( false, true ); } ); + panel->addFloat( + { "uiFontSize", "appearance.fonts", app.i18n( "ui_font_size", "UI Font Size" ), + app.i18n( "ui_font_size_desc", "Set the font size used by the application UI." ) }, + 6, 72, 0.5, [&app] { return app.config->font.uiSize; }, + [&app]( double value ) { + app.config->font.uiSize = value; + app.scene->getUIThemeManager()->setDefaultFontSize( value ); + app.scene->getRoot()->reloadStyle( true, true, true, true, true ); + app.savePreferences(); + } ); + panel->addFloat( + { "terminalFontSize", "appearance.fonts", + app.i18n( "terminal_font_size", "Terminal Font Size" ), + app.i18n( "terminal_font_size_desc", "Set the default terminal font size." ) }, + 6, 72, 0.5, [&app] { return app.config->font.size; }, + [&app]( double value ) { + app.config->font.size = value; + app.terminalFontSize = PixelDensity::dpToPx( value ); + app.forEachTerminal( + [&app]( UITerminal* terminal ) { terminal->setFontSize( app.terminalFontSize ); } ); + app.savePreferences(); + } ); + panel->addFloat( + { "uiScaleFactor", "appearance.fonts", app.i18n( "ui_scale_factor", "UI Scale Factor" ), + app.i18n( "ui_scale_factor_desc", + "Scale the complete user interface. Restart required." ) }, + 1, 6, 0.1, + [&app] { + return app.config->window.pixelDensity > 0 ? app.config->window.pixelDensity + : PixelDensity::getPixelDensity(); + }, + [&app]( double value ) { + app.config->window.pixelDensity = value; + app.savePreferences(); + } ); + panel->addChoice( + { "fontHinting", "appearance.fonts", app.i18n( "ui_font_hint", "Font Hinting" ), + app.i18n( "ui_font_hint_desc", "Control glyph alignment to the pixel grid." ) }, + { app.i18n( "none", "None" ), app.i18n( "slight", "Slight" ), app.i18n( "full", "Full" ) }, + [&app] { return static_cast( app.config->font.hinting ); }, + [&app]( size_t selected ) { + static constexpr FontHinting values[] = { FontHinting::None, FontHinting::Slight, + FontHinting::Full }; + app.config->font.hinting = values[std::min( selected, size_t{ 2 } )]; + app.scene->getResourceScope()->getFontService().setHinting( app.config->font.hinting ); + app.forEachTerminal( + []( UITerminal* terminal ) { terminal->syncFontRenderingConfig(); } ); + app.savePreferences(); + } ); + panel->addChoice( + { "fontAntialiasing", "appearance.fonts", + app.i18n( "ui_font_antialiasing", "Font Anti-Aliasing" ), + app.i18n( "ui_font_antialiasing_desc", "Choose how glyph edges are smoothed." ) }, + { app.i18n( "none", "None" ), app.i18n( "grayscale", "Grayscale" ), + app.i18n( "subpixel", "Subpixel" ) }, + [&app] { return static_cast( app.config->font.antialiasing ); }, + [&app]( size_t selected ) { + static constexpr FontAntialiasing values[] = { + FontAntialiasing::None, FontAntialiasing::Grayscale, FontAntialiasing::Subpixel }; + app.config->font.antialiasing = values[std::min( selected, size_t{ 2 } )]; + app.scene->getResourceScope()->getFontService().setAntialiasing( + app.config->font.antialiasing ); + app.forEachTerminal( + []( UITerminal* terminal ) { terminal->syncFontRenderingConfig(); } ); + app.savePreferences(); + } ); + + panel->addCategory( "window.renderer", app.i18n( "window", "Window" ), + app.i18n( "renderer", "Renderer" ) ); + panel->addBool( + { "vsync", "window.renderer", app.i18n( "vsync", "VSync" ), + app.i18n( "vsync_desc", "Synchronize rendering with the display. Restart required." ) }, + &app.config->window.vsync, [&app]( bool ) { app.savePreferences(); } ); + const String monitorRefreshRate = app.i18n( "monitor_refresh_rate", "Monitor Refresh Rate" ); + const String unlimitedFrameRate = app.i18n( "unlimited", "Unlimited" ); + panel->addEditableChoice( + { "frameRateLimit", "window.renderer", app.i18n( "frame_rate_limit", "Frame Rate Limit" ), + app.i18n( "frame_rate_limit_desc", "Limit rendered frames per second, follow the monitor " + "refresh rate, or disable the limit." ) }, + { monitorRefreshRate, unlimitedFrameRate, "30", "60", "75", "120", "144", "165", "240" }, + [&app, monitorRefreshRate, unlimitedFrameRate] { + return app.config->window.maxFPS == + static_cast( ContextSettings::FrameRateLimitScreenRefreshRate ) + ? monitorRefreshRate + : app.config->window.maxFPS == 0 + ? unlimitedFrameRate + : String( String::toString( app.config->window.maxFPS ) ); + }, + [&app, monitorRefreshRate, unlimitedFrameRate]( const String& selection ) { + Uint32 value; + if ( selection == monitorRefreshRate ) + value = ContextSettings::FrameRateLimitScreenRefreshRate; + else if ( selection == unlimitedFrameRate ) + value = 0; + else if ( !String::fromString( value, selection ) || value > 1000 ) + return false; + app.config->window.maxFPS = value; + app.appWindow->setFrameRateLimit( value ); + app.savePreferences(); + return true; + } ); + auto rendererVersions = Renderer::getAvailableGraphicsLibraryVersions(); + std::vector rendererNames; + for ( auto version : rendererVersions ) + rendererNames.emplace_back( Renderer::graphicsLibraryVersionToString( version ) ); + if ( !rendererVersions.empty() ) + panel->addChoice( + { "rendererVersion", "window.renderer", + app.i18n( "ui_renderer_version", "Renderer Version" ), + app.i18n( "ui_renderer_version_desc", + "Select the graphics API. Restart required." ) }, + rendererNames, + [&app, rendererVersions] { + auto found = std::find( rendererVersions.begin(), rendererVersions.end(), + app.config->window.rendererVersion ); + return found == rendererVersions.end() + ? size_t{ 0 } + : static_cast( found - rendererVersions.begin() ); + }, + [&app, rendererVersions]( size_t selected ) { + app.config->window.rendererVersion = + rendererVersions[std::min( selected, rendererVersions.size() - 1 )]; + app.savePreferences(); + } ); + panel->addChoice( + { "multisamples", "window.renderer", + app.i18n( "ui_multisamples_level", "Multisample Anti-Aliasing Level" ), + app.i18n( "ui_multisamples_level_desc", + "Set renderer multisampling. Restart required." ) }, + { "0", "2", "4", "8", "16" }, + [&app] { + static constexpr Uint32 values[] = { 0, 2, 4, 8, 16 }; + auto found = std::find( std::begin( values ), std::end( values ), + app.config->window.multisamples ); + return found == std::end( values ) + ? size_t{ 0 } + : static_cast( found - std::begin( values ) ); + }, + [&app]( size_t selected ) { + static constexpr Uint32 values[] = { 0, 2, 4, 8, 16 }; + app.config->window.multisamples = values[std::min( selected, size_t{ 4 } )]; + app.savePreferences(); + } ); + panel->addBool( + { "benchmarkMode", "window.renderer", app.i18n( "benchmark_mode", "Benchmark Mode" ), + app.i18n( "benchmark_mode_desc", "Render continuously to measure performance." ) }, + &app.config->window.benchmarkMode, [&app]( bool value ) { + app.benchmarkMode = value; + app.savePreferences(); + } ); + + panel->addCategory( "terminal.behavior", app.i18n( "terminal", "Terminal" ), + app.i18n( "behavior", "Behavior" ) ); + panel->addChoice( + { "newTerminalBehavior", "terminal.behavior", + app.i18n( "new_terminal_behavior", "New Terminal Behavior" ), + app.i18n( "new_terminal_behavior_desc", + "Choose where new terminal sessions are opened." ) }, + { app.i18n( "open_in_same_tabbar", "Open in Current Tab Bar" ), + app.i18n( "open_in_vertical_split", "Open in New Vertical Split" ), + app.i18n( "open_in_horizontal_split", "Open in New Horizontal Split" ) }, + [&app] { return static_cast( app.config->terminal.newTerminalBehavior ); }, + [&app]( size_t selected ) { + app.config->terminal.newTerminalBehavior = + static_cast( std::min( selected, size_t{ 2 } ) ); + app.savePreferences(); + } ); + panel->addBool( + { "terminalExclusiveMode", "terminal.behavior", + app.i18n( "enable_exclusive_mode_by_default", "Enable Exclusive Mode by Default" ), + app.i18n( "enable_exclusive_mode_by_default_tooltip", + "Disable global keybindings in newly created terminals." ) }, + &app.config->terminal.exclusiveMode, [&app]( bool value ) { + app.forEachTerminal( + [value]( UITerminal* terminal ) { terminal->setExclusiveMode( value ); } ); + app.savePreferences(); + } ); + panel->addBool( + { "closeTerminalTabOnExit", "terminal.behavior", + app.i18n( "close_terminal_tab_on_exit", "Close Terminal Tab on Exit" ), + app.i18n( "close_terminal_tab_on_exit_tooltip", "Close a tab when its process exits." ) }, + &app.config->terminal.closeOnExit, [&app]( bool value ) { + app.terminalConfig.closeOnExit = value; + app.forEachTerminal( + [value]( UITerminal* terminal ) { terminal->getTerm()->setKeepAlive( !value ); } ); + app.savePreferences(); + } ); + panel->addBool( { "warnBeforeClosingTerminal", "terminal.behavior", + app.i18n( "warn_before_closing_tab", "Warn Before Closing Tab" ), + app.i18n( "warn_before_closing_tab_tooltip", + "Ask before closing a terminal while a program is running." ) }, + &app.config->window.warnBeforeClose, [&app]( bool value ) { + app.warnBeforeClose = value; + app.savePreferences(); + } ); + const std::vector knownShells{ "bash", "sh", "zsh", "fish", "nu", "csh", + "tcsh", "ksh", "dash", "cmd", "powershell" }; + std::vector installedShells; + for ( const auto& shell : knownShells ) { + auto path = Sys::which( shell ); + if ( !path.empty() ) + installedShells.emplace_back( std::move( path ) ); + } + auto currentShell = app.config->terminal.shell; + if ( currentShell.empty() ) { + if ( const char* shell = std::getenv( "SHELL" ); shell ) + currentShell = FileSystem::fileExists( shell ) ? shell : Sys::which( shell ); + if ( currentShell.empty() ) + currentShell = Sys::which( + Sys::getPlatformType() == Sys::PlatformType::Windows ? "powershell" : "bash" ); + } + if ( !currentShell.empty() && std::find( installedShells.begin(), installedShells.end(), + String( currentShell ) ) == installedShells.end() ) + installedShells.emplace_back( currentShell ); + panel->addEditableChoice( + { "terminalShell", "terminal.behavior", app.i18n( "terminal_shell", "Terminal Shell" ), + app.i18n( "terminal_shell_desc", "Set the shell executable used by new terminals." ) }, + installedShells, [currentShell] { return String( currentShell ); }, + [&app]( const String& selection ) { + auto shell = selection.toUtf8(); + if ( Sys::which( shell ).empty() && !FileSystem::fileExists( shell ) ) + return false; + app.config->terminal.shell = shell; + app.terminalConfig.program = shell; + app.savePreferences(); + return true; + } ); + panel->addText( + { "terminalShellArguments", "terminal.behavior", + app.i18n( "terminal_shell_arguments", "Terminal Shell Arguments" ), + app.i18n( "terminal_shell_arguments_desc", + "Set shell arguments used by new terminals." ) }, + [&app] { return app.config->terminal.shellArguments; }, + [&app]( const std::string& value ) { + app.config->terminal.shellArguments = value; + app.terminalConfig.arguments = String::split( value ); + app.savePreferences(); + return true; + }, + true ); + panel->addInteger( + { "terminalScrollback", "terminal.behavior", + app.i18n( "terminal_scrollback", "Terminal Scrollback" ), + app.i18n( "configure_terminal_scrollback_desc", + "Set retained terminal history lines." ) }, + 0, std::numeric_limits::max(), + [&app] { + return static_cast( std::min( app.config->terminal.historySize, + std::numeric_limits::max() ) ); + }, + [&app]( int value ) { + app.config->terminal.historySize = value; + app.terminalConfig.historySize = value; + app.savePreferences(); + } ); + panel->addBool( + { "terminalFrameBuffer", "terminal.behavior", app.i18n( "framebuffer", "Use Framebuffer" ), + app.i18n( + "framebuffer_desc", + "Render terminals through an FBO instead of the default VBO path. This can improve " + "rendering performance by using a different invalidation strategy." ) }, + &app.config->terminal.useFrameBuffer, [&app]( bool value ) { + app.terminalConfig.useFrameBuffer = value; + app.savePreferences(); + } ); + + panel->addCategory( "terminal.appearance", app.i18n( "terminal", "Terminal" ), + app.i18n( "appearance", "Appearance" ) ); + std::vector schemeNames{ app.i18n( "default", "Default" ) }; + std::vector schemeIds{ "" }; + for ( const auto& [name, scheme] : app.terminalColorSchemes ) { + schemeNames.emplace_back( name ); + schemeIds.emplace_back( name ); + } + if ( !schemeIds.empty() ) + panel->addChoice( + { "terminalColorScheme", "terminal.appearance", + app.i18n( "terminal_color_scheme", "Terminal Color Scheme" ), + app.i18n( "terminal_color_scheme_desc", "Choose the colors used by terminals." ) }, + schemeNames, + [&app, schemeIds] { + auto found = + std::find( schemeIds.begin(), schemeIds.end(), app.config->theme.colorScheme ); + return found == schemeIds.end() ? size_t{ 0 } + : static_cast( found - schemeIds.begin() ); + }, + [&app, schemeIds]( size_t selected ) { + app.config->theme.colorScheme = + schemeIds[std::min( selected, schemeIds.size() - 1 )]; + if ( app.config->theme.colorScheme.empty() ) { + app.selectedColorScheme = nullptr; + auto defaultScheme = TerminalColorScheme::getDefault(); + app.forEachTerminal( [&defaultScheme]( UITerminal* terminal ) { + terminal->setColorScheme( defaultScheme ); + } ); + app.savePreferences(); + return; + } + auto found = app.terminalColorSchemes.find( app.config->theme.colorScheme ); + if ( found != app.terminalColorSchemes.end() ) { + app.selectedColorScheme = &found->second; + app.forEachTerminal( [&app]( UITerminal* terminal ) { + terminal->setColorScheme( *app.selectedColorScheme ); + } ); + } + app.savePreferences(); + } ); + panel->addChoice( + { "terminalScrollbarType", "terminal.appearance", + app.i18n( "scrollbar_type", "Scrollbar Type" ), + app.i18n( "scrollbar_type_desc", + "Place the scrollbar over or outside terminal content." ) }, + { app.i18n( "overlay", "Overlay" ), app.i18n( "outside", "Outside" ) }, + [&app] { return app.config->terminal.scrollBarType == ScrollViewType::Overlay ? 0 : 1; }, + [&app]( size_t selected ) { + app.config->terminal.scrollBarType = + selected == 0 ? ScrollViewType::Overlay : ScrollViewType::Outside; + app.forEachTerminal( [&app]( UITerminal* terminal ) { + terminal->setScrollViewType( app.config->terminal.scrollBarType ); + } ); + app.savePreferences(); + } ); + panel->addChoice( + { "terminalCursorStyle", "terminal.appearance", app.i18n( "cursor_style", "Cursor Style" ), + app.i18n( "cursor_style_desc", "Choose the terminal cursor shape and animation." ) }, + { app.i18n( "blinking_block", "Blinking Block" ), + app.i18n( "steady_block", "Steady Block" ), + app.i18n( "blink_underline", "Blink Underline" ), + app.i18n( "steady_underline", "Steady Underline" ), app.i18n( "blink_bar", "Blink Bar" ), + app.i18n( "steady_bar", "Steady Bar" ) }, + [&app] { + static constexpr TerminalCursorMode modes[] = { + TerminalCursorMode::BlinkingBlock, TerminalCursorMode::SteadyBlock, + TerminalCursorMode::BlinkUnderline, TerminalCursorMode::SteadyUnderline, + TerminalCursorMode::BlinkBar, TerminalCursorMode::SteadyBar }; + auto found = std::find( std::begin( modes ), std::end( modes ), + app.config->terminal.cursorStyle ); + return found == std::end( modes ) ? size_t{ 0 } + : static_cast( found - std::begin( modes ) ); + }, + [&app]( size_t selected ) { + static constexpr TerminalCursorMode modes[] = { + TerminalCursorMode::BlinkingBlock, TerminalCursorMode::SteadyBlock, + TerminalCursorMode::BlinkUnderline, TerminalCursorMode::SteadyUnderline, + TerminalCursorMode::BlinkBar, TerminalCursorMode::SteadyBar }; + app.config->terminal.cursorStyle = modes[std::min( selected, size_t{ 5 } )]; + app.terminalConfig.cursorStyle = app.config->terminal.cursorStyle; + app.forEachTerminal( [&app]( UITerminal* terminal ) { + terminal->getTerm()->setCursorMode( app.config->terminal.cursorStyle ); + } ); + app.savePreferences(); + } ); + panel->addChoice( + { "terminalScrollbarMode", "terminal.appearance", + app.i18n( "scrollbar_mode", "Scrollbar Mode" ), + app.i18n( "scrollbar_mode_desc", "Control when the terminal scrollbar is visible." ) }, + { app.i18n( "auto", "Auto" ), app.i18n( "always_visible", "Always Visible" ), + app.i18n( "always_hidden", "Always Hidden" ) }, + [&app] { + return app.config->terminal.scrollBarMode == ScrollBarMode::Auto ? 0 + : app.config->terminal.scrollBarMode == ScrollBarMode::AlwaysOn ? 1 + : 2; + }, + [&app]( size_t selected ) { + app.config->terminal.scrollBarMode = selected == 0 ? ScrollBarMode::Auto + : selected == 1 ? ScrollBarMode::AlwaysOn + : ScrollBarMode::AlwaysOff; + app.forEachTerminal( [&app]( UITerminal* terminal ) { + terminal->setVerticalScrollMode( app.config->terminal.scrollBarMode ); + } ); + app.savePreferences(); + } ); + panel->addBool( { "alwaysShowTabBar", "terminal.appearance", + app.i18n( "always_show_tab_bar", "Always Show Tab Bar" ), + app.i18n( "always_show_tab_bar_desc", + "Show the tab bar even when it contains one tab." ) }, + &app.config->window.alwaysShowTabBar, [&app]( bool value ) { + app.tabSplitter->setHideTabBarOnSingleTab( !value ); + app.savePreferences(); + } ); + + panel->build(); + settingsWindow->setKeyBindingCommand( "closeWindow", [&app] { + if ( app.settingsWindow ) + app.settingsWindow->closeWindow(); + } ); + settingsWindow->getKeyBindings().addKeybind( { KEY_ESCAPE }, "closeWindow" ); + settingsWindow->on( Event::OnWindowClose, [&app]( const Event* ) { + app.savePreferences(); + app.settingsWindow = nullptr; + if ( app.tabSplitter && app.tabSplitter->getCurWidget() ) + app.tabSplitter->getCurWidget()->setFocus(); + } ); + settingsWindow->center(); + settingsWindow->showWhenReady(); + panel->focusSearch(); + return settingsWindow; +} + +} // namespace eterm diff --git a/src/tools/eterm/settingspanel.hpp b/src/tools/eterm/settingspanel.hpp new file mode 100644 index 000000000..376954f67 --- /dev/null +++ b/src/tools/eterm/settingspanel.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include + +namespace EE::UI { +class UIWindow; +class UISceneNode; +} // namespace EE::UI + +using namespace EE; +using namespace EE::UI; + +namespace eterm { + +class App; + +struct SettingsPanel { + static UIWindow* create( App& ); +}; + +} // namespace eterm