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..586295b0e 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 @@ -886,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/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..de3b2502c 100644 --- a/bin/assets/i18n/zh.xml +++ b/bin/assets/i18n/zh.xml @@ -223,6 +223,7 @@ , 和 (Unicode字符 Exclusive Mode + 启用独占模式 在终端打开文件夹 在终端执行 Expand All @@ -680,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 283e3cc26..5ab9f023d 100644 --- a/include/eepp/ui.hpp +++ b/include/eepp/ui.hpp @@ -96,6 +96,7 @@ #include #include #include +#include #include #include #include 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/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/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 7ede8e369..47751f26c 100644 --- a/include/eepp/ui/uiscenenode.hpp +++ b/include/eepp/ui/uiscenenode.hpp @@ -864,7 +864,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 aa4dc176d..fb9298510 100644 --- a/include/eepp/ui/uiwidget.hpp +++ b/include/eepp/ui/uiwidget.hpp @@ -26,6 +26,7 @@ class UITooltip; class UIStyle; class AccessibilityWidgetResolver; struct AccessibilityProperties; +class UIWidget; struct MarginAuto { static constexpr auto Left = ( 1 << 0 ); @@ -34,6 +35,8 @@ struct MarginAuto { static constexpr auto Bottom = ( 1 << 3 ); }; +using WidgetQueryResult = SmallVector; + /** * @brief Base class for all UI widgets in the eepp framework. * @@ -1153,7 +1156,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. @@ -1163,7 +1166,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. @@ -1225,7 +1228,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. @@ -1235,7 +1238,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/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/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/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/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 4c758d32e..8cf82b98f 100644 --- a/src/eepp/ui/uiscenenode.cpp +++ b/src/eepp/ui/uiscenenode.cpp @@ -548,12 +548,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; @@ -648,7 +648,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 27e64627c..a2091cb65 100644 --- a/src/eepp/ui/uiwidget.cpp +++ b/src/eepp/ui/uiwidget.cpp @@ -1705,8 +1705,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 ); @@ -1716,8 +1716,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() ); @@ -1729,8 +1728,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 ); @@ -1740,7 +1739,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() ); @@ -1815,8 +1814,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 ); @@ -1826,7 +1825,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() ) @@ -1862,7 +1861,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/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; 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/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/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/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/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() ) { 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/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 }; 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/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(); 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 014c4c1dc..3a6722794 100644 --- a/src/tools/eterm/eterm.cpp +++ b/src/tools/eterm/eterm.cpp @@ -1,78 +1,21 @@ -#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 { +void App::TerminalSplitterClient::onTabCreated( UITab* tab, UIWidget* ) { + if ( mApp.terminalIcon ) + tab->setIcon( mApp.terminalIcon->createDrawable( PixelDensity::dpToPxI( 12 ) ) ); + mApp.configureTab( tab ); +} -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 }; -}; +void App::TerminalSplitterClient::onWidgetFocusChange( UIWidget* ) { + mApp.updateWindowTitle(); +} -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 { - public: - void onTabCreated( UITab* tab, UIWidget* ) override { - if ( terminalIcon ) - tab->setIcon( terminalIcon->createDrawable( PixelDensity::dpToPxI( 12 ) ) ); - } - - void onWidgetFocusChange( UIWidget* ) override { updateWindowTitle(); } -}; - -TerminalSplitterClient splitterClient; - -std::string getResourcePath() { +std::string App::getResourcePath() const { std::string resPath = Sys::getProcessPath(); #if EE_PLATFORM == EE_PLATFORM_MACOS if ( String::contains( resPath, "ecode.app" ) ) { @@ -92,7 +35,11 @@ std::string getResourcePath() { return resPath; } -void loadColorSchemes( const std::string& resPath ) { +String App::i18n( const std::string& key, const String& defaultValue ) const { + return scene ? scene->i18n( key, defaultValue ) : defaultValue; +} + +void App::loadColorSchemes( const std::string& resPath ) { auto colorSchemes = TerminalColorScheme::loadFromFile( resPath + "colorschemes/terminalcolorschemes.conf" ); const std::string configColorSchemesPath = @@ -111,16 +58,109 @@ void loadColorSchemes( const std::string& resPath ) { } } -UITerminal* terminalFromTab( UITab* tab ) { +UITerminal* App::terminalFromTab( UITab* tab ) { return tab && tab->getOwnedWidget() && tab->getOwnedWidget()->isType( UI_TYPE_TERMINAL ) ? tab->getOwnedWidget()->asType() : nullptr; } -void 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; - std::string title{ "eterm" }; + String title{ "eterm" }; if ( tabSplitter ) { if ( auto* terminal = tabSplitter->getCurWidget() && tabSplitter->getCurWidget()->isType( UI_TYPE_TERMINAL ) @@ -134,12 +174,12 @@ void updateWindowTitle() { if ( benchmarkMode ) { title += " - "; title += String::toString( appWindow->getFPS() ); - title += " FPS"; + title += " " + i18n( "fps", "FPS" ); } appWindow->setTitle( title ); } -bool hasTerminals() { +bool App::hasTerminals() const { bool found = false; if ( tabSplitter ) tabSplitter->forEachWidgetStoppable( [&found]( UIWidget* ) { @@ -149,30 +189,30 @@ bool hasTerminals() { return found; } -bool hasRunningChildren( UITab* tab ) { +bool App::hasRunningChildren( UITab* tab ) { auto* terminal = terminalFromTab( tab ); return terminal && terminal->getTerm() && Sys::processHasChildren( terminal->getTerm()->getProcessId() ); } -void closeTab( UITab* tab ) { +void App::closeTab( UITab* tab ) { if ( !tabSplitter || !tab || !tab->getOwnedWidget() ) return; tabSplitter->closeTab( tab->getOwnedWidget()->asType(), UITabWidget::FocusTabBehavior::Default ); } -void queueExitCloseTab( UITab* tab ) { +void App::queueExitCloseTab( UITab* tab ) { if ( tab && std::find( pendingExitCloseTabs.begin(), pendingExitCloseTabs.end(), tab ) == pendingExitCloseTabs.end() ) { pendingExitCloseTabs.emplace_back( tab ); } } -void queueExitedTabs() { +void App::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 +223,7 @@ void queueExitedTabs() { } ); } -void requestCloseTab( UITab* tab ) { +void App::requestCloseTab( UITab* tab ) { if ( !warnBeforeClose || !hasRunningChildren( tab ) ) { closeTab( tab ); return; @@ -192,14 +232,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 +248,197 @@ void requestCloseTab( UITab* tab ) { closeDialog->showWhenReady(); } -void addTabKeyBindings( UITerminal* terminal, UITab* tab ); +void App::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 App::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( []( 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 App::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 App::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" ); + addItem( i18n( "settings", "Settings" ), "settings", "open-settings" ); + + 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 if ( command == "open-settings" ) { + showSettings(); + } else { + terminal->execute( command ); + } + + if ( previous && tabSplitter->ownedWidgetExists( previous ) ) + tabSplitter->setCurrentWidget( previous ); + } ); + } ); +} + +UITerminal* App::createTerminal( UITabWidget* target ) { if ( !target && tabSplitter ) { auto* current = tabSplitter->getCurWidget(); target = current ? tabSplitter->tabWidgetFromWidget( current ) @@ -236,20 +465,50 @@ UITerminal* createTerminal( UITabWidget* target = nullptr ) { 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 ); - 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 ); } ); + 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 ); @@ -258,38 +517,50 @@ UITerminal* createTerminal( UITabWidget* target = nullptr ) { return terminal; } -UITerminal* 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 addTabKeyBindings( UITerminal* terminal, UITab* tab ) { - terminal->setCommand( "create-new-terminal", [] { createTerminal(); } ); - terminal->setCommand( "close-tab", [tab] { requestCloseTab( tab ); } ); - terminal->setCommand( "next-tab", [terminal] { +void App::addTabKeyBindings( UITerminal* terminal ) { + tabSplitter->registerSplitterCommands( *terminal ); + 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 ); } ); + 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_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" ); 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 +581,7 @@ void addTabKeyBindings( UITerminal* terminal, UITab* tab ) { "switch-to-next-split" ); } -bool closeWindow( EE::Window::Window* ) { +bool App::closeWindow( EE::Window::Window* ) { if ( closeApproved || !warnBeforeClose ) return true; bool running = false; @@ -321,13 +592,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,27 +608,30 @@ bool closeWindow( EE::Window::Window* ) { return false; } -} // namespace - -EE_MAIN_FUNC int main( int argc, char* argv[] ) { +int App::run( int argc, char* argv[] ) { #ifdef EE_DEBUG - Log::instance()->setLogToStdOut( true ); + 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 }, @@ -364,7 +639,7 @@ EE_MAIN_FUNC int main( 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 }, @@ -373,33 +648,35 @@ EE_MAIN_FUNC int main( 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" } ); @@ -407,8 +684,11 @@ 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 ); + { "tabs" }, config->terminal.initialTabs ); try { parser.ParseCLI( argc, argv ); @@ -424,58 +704,124 @@ EE_MAIN_FUNC int main( 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 ); + 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 ); @@ -488,9 +834,9 @@ EE_MAIN_FUNC int main( 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; @@ -504,34 +850,37 @@ EE_MAIN_FUNC int main( 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() ); @@ -539,21 +888,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( !config->window.alwaysShowTabBar ); + 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 ), @@ -573,17 +930,20 @@ EE_MAIN_FUNC int main( 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", - "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(); @@ -608,5 +968,15 @@ EE_MAIN_FUNC int main( 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 eterm + +EE_MAIN_FUNC int main( int argc, char* argv[] ) { + 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