From 71ede34599cefd6369cfa4fa1926e6befa4bc02d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Wed, 9 Sep 2026 00:56:03 -0300 Subject: [PATCH] eepp: add reusable UISettingsPanel - add the generic settings model and panel to EE::UI::Tools - support category navigation, filtering, and lazy materialization - add UISettingsPanel model and filtering tests ecode: migrate settings to UISettingsPanel - replace the application-specific settings model - use the generic settings panel implementation - add monitor refresh rate as a frame limit option - normalize UI labels and translations eterm: add persistent configuration and settings panel - persist application preferences and window state - use saved values as defaults for command-line options - persist command-line overrides - add graphical settings with live application - expose renderer, terminal behavior, appearance, font, and scale options --- bin/assets/i18n/en.xml | 6 +- bin/assets/i18n/zh.xml | 2 +- include/eepp/ui.hpp | 1 + include/eepp/ui/tools/uisettingspanel.hpp | 221 ++++ src/eepp/ui/tools/uisettingspanel.cpp | 976 ++++++++++++++++++ .../unit_tests/uisettingspanel_tests.cpp | 135 +++ src/tools/ecode/plugins/git/gitplugin.cpp | 1 + src/tools/ecode/settingsmodel.hpp | 148 --- src/tools/ecode/settingspage.hpp | 7 +- src/tools/ecode/settingspanel.cpp | 922 ++--------------- src/tools/ecode/settingspanel.hpp | 55 +- src/tools/eterm/appconfig.cpp | 165 +++ src/tools/eterm/appconfig.hpp | 103 ++ src/tools/eterm/eterm.cpp | 453 ++++---- src/tools/eterm/eterm.hpp | 112 ++ src/tools/eterm/settingspanel.cpp | 468 +++++++++ src/tools/eterm/settingspanel.hpp | 21 + 17 files changed, 2582 insertions(+), 1214 deletions(-) create mode 100644 include/eepp/ui/tools/uisettingspanel.hpp create mode 100644 src/eepp/ui/tools/uisettingspanel.cpp create mode 100644 src/tests/unit_tests/uisettingspanel_tests.cpp delete mode 100644 src/tools/ecode/settingsmodel.hpp create mode 100644 src/tools/eterm/appconfig.cpp create mode 100644 src/tools/eterm/appconfig.hpp create mode 100644 src/tools/eterm/eterm.hpp create mode 100644 src/tools/eterm/settingspanel.cpp create mode 100644 src/tools/eterm/settingspanel.hpp diff --git a/bin/assets/i18n/en.xml b/bin/assets/i18n/en.xml index 2ee79ecb0..586295b0e 100644 --- a/bin/assets/i18n/en.xml +++ b/bin/assets/i18n/en.xml @@ -887,14 +887,14 @@ the directory tree and in file dialogs to open a folder or file. Type Type to Locate UI Font & Size... - Ui Font Size + UI Font Size UI Language Multisample Anti-Aliasing Level - Ui Panel Font Size + UI Panel Font Size UI Prefers Color Scheme Renderer Renderer Version - Ui Scale Factor + UI Scale Factor UI Theme Copy Copy Containing Folder Path diff --git a/bin/assets/i18n/zh.xml b/bin/assets/i18n/zh.xml index 575b64cff..de3b2502c 100644 --- a/bin/assets/i18n/zh.xml +++ b/bin/assets/i18n/zh.xml @@ -681,7 +681,7 @@ file in the directory tree. Type Type to Locate 界面字体和大小... - Ui字体大小 + UI字体大小 界面语言 多重采样抗锯齿级别 界面面板字体大小 diff --git a/include/eepp/ui.hpp b/include/eepp/ui.hpp index 12b7e0186..9ad7b75f0 100644 --- a/include/eepp/ui.hpp +++ b/include/eepp/ui.hpp @@ -93,6 +93,7 @@ #include #include #include +#include #include #include #include diff --git a/include/eepp/ui/tools/uisettingspanel.hpp b/include/eepp/ui/tools/uisettingspanel.hpp new file mode 100644 index 000000000..a11d9122c --- /dev/null +++ b/include/eepp/ui/tools/uisettingspanel.hpp @@ -0,0 +1,221 @@ +#ifndef EE_UI_TOOLS_UISETTINGSPANEL_HPP +#define EE_UI_TOOLS_UISETTINGSPANEL_HPP + +#include +#include +#include +#include +#include +#include +#include + +namespace pugi { +class xml_node; +} + +namespace EE::UI { +class UICheckBox; +} + +namespace EE::UI::Tools { + +struct SettingView; + +struct EE_API SettingDescriptor { + std::string id; + std::string category; + String name; + String description; + String group; +}; + +struct EE_API BoolPointerSetting { + bool* value{ nullptr }; + std::function apply; +}; + +struct EE_API BoolSetting { + std::function get; + std::function set; +}; + +struct EE_API ChoiceSetting { + std::vector choices; + std::vector descriptions; + std::function get; + std::function set; +}; + +struct EE_API EditableChoiceSetting { + std::vector choices; + std::function get; + std::function set; +}; + +struct EE_API IntegerSetting { + int min{ 0 }; + int max{ 0 }; + std::function get; + std::function set; +}; + +struct EE_API TextSetting { + std::function get; + std::function set; + bool commitOnFocusLoss{ false }; + bool password{ false }; +}; + +struct EE_API FloatSetting { + double min{ 0 }; + double max{ 0 }; + double step{ 0 }; + std::function get; + std::function set; +}; + +struct EE_API ActionSetting { + String buttonText; + std::function action; +}; + +using SettingValue = + std::variant; + +struct EE_API SettingDefinition { + SettingDescriptor descriptor; + SettingValue value; + bool enabled{ true }; +}; + +struct EE_API SettingsCategory { + std::string id; + String parent; + String name; +}; + +struct EE_API SettingsGroup { + std::string category; + String name; + size_t beforeSetting{ 0 }; +}; + +class EE_API SettingsModel { + public: + void clear(); + + bool addCategory( SettingsCategory category ); + + bool addGroup( SettingsGroup group ); + + bool addSetting( SettingDefinition setting ); + + bool hasCategory( const std::string& id ) const; + + const std::vector& categories() const { return mCategories; } + + const std::vector& groups() const { return mGroups; } + + std::vector& settings() { return mSettings; } + + const std::vector& settings() const { return mSettings; } + + private: + std::vector mCategories; + std::vector mGroups; + std::vector mSettings; +}; + +class EE_API UISettingsPanel : public UILinearLayout { + public: + static UISettingsPanel* New( UIWidget* parent ); + + virtual ~UISettingsPanel(); + + SettingsModel& getModel(); + + const SettingsModel& getModel() const; + + bool addCategory( std::string id, String parent, String name ); + + bool addGroup( std::string category, String name ); + + bool addBool( SettingDescriptor descriptor, bool* value, + std::function apply = {} ); + + bool addBool( SettingDescriptor descriptor, std::function get, + std::function set ); + + bool addChoice( SettingDescriptor descriptor, std::vector choices, + std::function get, std::function set, + std::vector choiceDescriptions = {} ); + + bool addEditableChoice( SettingDescriptor descriptor, std::vector choices, + std::function get, std::function set ); + + bool addInteger( SettingDescriptor descriptor, int min, int max, std::function get, + std::function set ); + + bool addText( SettingDescriptor descriptor, std::function get, + std::function set, bool commitOnFocusLoss = false ); + + bool addFloat( SettingDescriptor descriptor, double min, double max, double step, + std::function get, std::function set ); + + bool addAction( SettingDescriptor descriptor, String buttonText, std::function action ); + + void build(); + + void selectCategory( const std::string& category ); + + void setCategoryEnabled( const std::string& category, bool enabled, + const std::string& excludedSetting = {} ); + + void refreshTextSetting( const std::string& id ); + + void setSearchResultsText( String text ); + + void setFilter( String filter ); + + void focusSearch(); + + void focusCategories(); + + bool isBuilt() const; + + protected: + struct Impl; + std::unique_ptr mImpl; + + explicit UISettingsPanel( UIWidget* parent ); + + void selectCategory( Impl& panel, const std::string& category ); + + void addCategory( Impl& panel, const std::string& id, const String& parent, + const String& name ); + + void addSubcategoryHeading( Impl& panel, const std::string& category, const String& name ); + + void setupCategories( Impl& panel ); + + UIWidget* createRow( Impl& panel, SettingDefinition& setting, SettingView& view, + pugi::xml_node layout ); + + UICheckBox* createBoolControl( Impl& panel, SettingDefinition& setting, SettingView& view ); + + void materializeCategory( Impl& panel, const std::string& category ); + + void materializeVisibleSettings( Impl& panel, const String& query ); + + void setCategoryEnabled( Impl& panel, const std::string& category, bool enabled, + const std::string& excludedSetting = {} ); + + void refreshTextSetting( Impl& panel, const std::string& id ); + + void filter( Impl& panel ); +}; + +} // namespace EE::UI::Tools + +#endif diff --git a/src/eepp/ui/tools/uisettingspanel.cpp b/src/eepp/ui/tools/uisettingspanel.cpp new file mode 100644 index 000000000..30d41ce24 --- /dev/null +++ b/src/eepp/ui/tools/uisettingspanel.cpp @@ -0,0 +1,976 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define PUGIXML_HEADER_ONLY +#include + +using namespace EE::UI::Models; + +namespace EE::UI::Tools { + +class UISettingsCategoryModel final : public Model { + public: + struct Node { + std::string id; + std::string text; + Node* parent{ nullptr }; + std::vector children; + std::vector visibleChildren; + }; + + static std::shared_ptr + create( const std::vector>>& categories, + const UnorderedMap& ids ) { + return std::make_shared( categories, ids ); + } + + UISettingsCategoryModel( + const std::vector>>& categories, + const UnorderedMap& ids ) { + mNodes.emplace_back(); + mRoot = &mNodes.back(); + for ( const auto& [parent, children] : categories ) { + std::string parentId; + if ( !children.empty() ) { + auto id = ids.find( parent + '/' + children.front() ); + if ( id != ids.end() ) { + auto separator = id->second.find( '.' ); + parentId = id->second.substr( 0, separator ) + ".*"; + } + } + mNodes.push_back( { std::move( parentId ), parent, mRoot } ); + auto* parentNode = &mNodes.back(); + mRoot->children.push_back( parentNode ); + for ( const auto& child : children ) { + auto id = ids.find( parent + '/' + child ); + mNodes.push_back( + { id == ids.end() ? std::string{} : id->second, child, parentNode } ); + parentNode->children.push_back( &mNodes.back() ); + } + } + filter( {}, {} ); + } + + size_t rowCount( const ModelIndex& parent = {} ) const { + auto* node = parent.isValid() ? static_cast( parent.internalData() ) : mRoot; + return node->visibleChildren.size(); + } + + size_t columnCount( const ModelIndex& = {} ) const { return 1; } + + ModelIndex index( int row, int column, const ModelIndex& parent = {} ) const { + auto* node = parent.isValid() ? static_cast( parent.internalData() ) : mRoot; + if ( row < 0 || column != 0 || static_cast( row ) >= node->visibleChildren.size() ) + return {}; + return createIndex( row, column, node->visibleChildren[row] ); + } + + ModelIndex parentIndex( const ModelIndex& index ) const { + if ( !index.isValid() ) + return {}; + auto* node = static_cast( index.internalData() ); + if ( !node->parent || node->parent == mRoot ) + return {}; + auto* parent = node->parent; + auto found = + std::find( mRoot->visibleChildren.begin(), mRoot->visibleChildren.end(), parent ); + return found == mRoot->visibleChildren.end() + ? ModelIndex{} + : createIndex( std::distance( mRoot->visibleChildren.begin(), found ), 0, + parent ); + } + + Variant data( const ModelIndex& index, ModelRole role = ModelRole::Display ) const { + if ( !index.isValid() || role != ModelRole::Display ) + return {}; + return Variant( static_cast( index.internalData() )->text ); + } + + void filter( const std::string_view query, + const UnorderedSet& matchingCategories ) { + mRoot->visibleChildren.clear(); + for ( auto* parent : mRoot->children ) { + parent->visibleChildren.clear(); + const bool parentMatches = query.empty() || String::icontains( parent->text, query ); + for ( auto* child : parent->children ) { + if ( parentMatches || String::icontains( child->text, query ) || + matchingCategories.contains( child->id ) ) + parent->visibleChildren.push_back( child ); + } + if ( !parent->visibleChildren.empty() ) + mRoot->visibleChildren.push_back( parent ); + } + invalidate( Model::UpdateFlag::InvalidateAllIndexes ); + } + + private: + std::deque mNodes; + Node* mRoot{ nullptr }; +}; + +static constexpr const char* SETTINGS_PANEL_LAYOUT = R"xml( + + + + + + + + + + + + + + +)xml"; + +static std::string settingsRowLayout( std::string_view control ) { + return R"xml( + + + + + + + +)xml" + std::string( control ) + + R"xml( + + + +)xml"; +} + +class SettingsLayoutTemplate { + public: + explicit SettingsLayoutTemplate( const std::string& layout ) { + [[maybe_unused]] auto result = + mDocument.load_string( layout.c_str(), pugi::parse_default | pugi::parse_ws_pcdata ); + eeASSERT( result ); + } + + pugi::xml_node root() const { return mDocument.first_child(); } + + private: + pugi::xml_document mDocument; +}; + +static constexpr const char* SETTINGS_CATEGORY_HEADING_LAYOUT = R"xml( + + + + +)xml"; +static constexpr const char* SETTINGS_SUBCATEGORY_HEADING_LAYOUT = R"xml( + +)xml"; +static const SettingsLayoutTemplate SETTINGS_BOOL_ROW_LAYOUT( settingsRowLayout( + R"xml()xml" ) ); +static const SettingsLayoutTemplate SETTINGS_CHOICE_ROW_LAYOUT( settingsRowLayout( + R"xml()xml" ) ); +static const SettingsLayoutTemplate SETTINGS_EDITABLE_CHOICE_ROW_LAYOUT( settingsRowLayout( + R"xml()xml" ) ); +static const SettingsLayoutTemplate SETTINGS_INTEGER_ROW_LAYOUT( settingsRowLayout( + R"xml()xml" ) ); +static const SettingsLayoutTemplate SETTINGS_TEXT_ROW_LAYOUT( settingsRowLayout( + R"xml()xml" ) ); +static const SettingsLayoutTemplate SETTINGS_ACTION_ROW_LAYOUT( settingsRowLayout( + R"xml()xml" ) ); + +static void disableTabFocusTree( Node* node ) { + if ( node->isWidget() ) + node->asType()->unsetTabFocusable(); + for ( auto* child = node->getFirstChild(); child; child = child->getNextNode() ) + disableTabFocusTree( child ); +} + +struct SettingView { + UIWidget* row{ nullptr }; +}; + +struct SubcategoryHeading { + std::string category; + String name; + UITextView* heading{ nullptr }; +}; + +struct UISettingsPanel::Impl { + EventConnectionList connections; + UITextInput* search{ nullptr }; + UITreeView* categories{ nullptr }; + UIScrollView* scroll{ nullptr }; + UILinearLayout* settings{ nullptr }; + UITextView* pageTitle{ nullptr }; + std::shared_ptr categoryModel; + UIBindingGroup bindingGroup; + SettingsModel model; + std::vector>> categoryItems; + UnorderedMap categoryIds; + UnorderedMap categorySearchText; + UnorderedMap categoryTitles; + UnorderedMap categoryHeadings; + UnorderedMap categorySections; + UnorderedMap categoryContainers; + UnorderedSet materializedCategories; + std::vector subcategoryHeadings; + std::vector settingViews; + std::string selectedCategory; + std::string categoryFilter; + String searchResultsText{ "Search Results" }; + bool built{ false }; +}; + +void SettingsModel::clear() { + mCategories.clear(); + mGroups.clear(); + mSettings.clear(); +} + +bool SettingsModel::addCategory( SettingsCategory category ) { + if ( category.id.empty() || + std::any_of( mCategories.begin(), mCategories.end(), + [&category]( const auto& item ) { return item.id == category.id; } ) ) + return false; + mCategories.emplace_back( std::move( category ) ); + return true; +} + +bool SettingsModel::addGroup( SettingsGroup group ) { + if ( !hasCategory( group.category ) ) + return false; + mGroups.emplace_back( std::move( group ) ); + return true; +} + +bool SettingsModel::addSetting( SettingDefinition setting ) { + if ( setting.descriptor.id.empty() || !hasCategory( setting.descriptor.category ) || + std::any_of( mSettings.begin(), mSettings.end(), [&setting]( const auto& item ) { + return item.descriptor.id == setting.descriptor.id; + } ) ) + return false; + mSettings.emplace_back( std::move( setting ) ); + return true; +} + +bool SettingsModel::hasCategory( const std::string& id ) const { + return std::any_of( mCategories.begin(), mCategories.end(), + [&id]( const auto& item ) { return item.id == id; } ); +} + +UISettingsPanel* UISettingsPanel::New( UIWidget* parent ) { + return eeNew( UISettingsPanel, ( parent ) ); +} + +UISettingsPanel::UISettingsPanel( UIWidget* parent ) : + UILinearLayout( "settingspanel", UIOrientation::Vertical ), mImpl( std::make_unique() ) { + setParent( parent ); + setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::MatchParent ); + auto* layout = getUISceneNode()->loadLayoutFromString( SETTINGS_PANEL_LAYOUT, this ); + mImpl->search = layout->find( "settings_filter" ); + mImpl->categories = layout->find( "settings_categories" ); + mImpl->settings = layout->find( "settings_rows" ); + mImpl->pageTitle = layout->find( "settings_page_title" ); + mImpl->scroll = layout->find( "settings_scroll" ); + mImpl->scroll->setVerticalScrollMode( ScrollBarMode::Auto ); + mImpl->scroll->setHorizontalScrollMode( ScrollBarMode::AlwaysOff ); + disableTabFocusTree( mImpl->categories->getVerticalScrollBar() ); + disableTabFocusTree( mImpl->categories->getHorizontalScrollBar() ); + disableTabFocusTree( mImpl->scroll->getVerticalScrollBar() ); + disableTabFocusTree( mImpl->scroll->getHorizontalScrollBar() ); +} + +UISettingsPanel::~UISettingsPanel() = default; + +SettingsModel& UISettingsPanel::getModel() { + return mImpl->model; +} + +const SettingsModel& UISettingsPanel::getModel() const { + return mImpl->model; +} + +bool UISettingsPanel::addCategory( std::string id, String parent, String name ) { + if ( mImpl->built || !mImpl->model.addCategory( { id, parent, name } ) ) + return false; + addCategory( *mImpl, id, parent, name ); + return true; +} + +bool UISettingsPanel::addGroup( std::string category, String name ) { + if ( mImpl->built ) + return false; + return mImpl->model.addGroup( + { std::move( category ), std::move( name ), mImpl->model.settings().size() } ); +} + +bool UISettingsPanel::addBool( SettingDescriptor descriptor, bool* value, + std::function apply ) { + return !mImpl->built && + mImpl->model.addSetting( + { std::move( descriptor ), BoolPointerSetting{ value, std::move( apply ) } } ); +} + +bool UISettingsPanel::addBool( SettingDescriptor descriptor, std::function get, + std::function set ) { + return !mImpl->built && + mImpl->model.addSetting( + { std::move( descriptor ), BoolSetting{ std::move( get ), std::move( set ) } } ); +} + +bool UISettingsPanel::addChoice( SettingDescriptor descriptor, std::vector choices, + std::function get, std::function set, + std::vector choiceDescriptions ) { + return !mImpl->built && + mImpl->model.addSetting( + { std::move( descriptor ), + ChoiceSetting{ std::move( choices ), std::move( choiceDescriptions ), + std::move( get ), std::move( set ) } } ); +} + +bool UISettingsPanel::addEditableChoice( SettingDescriptor descriptor, std::vector choices, + std::function get, + std::function set ) { + return !mImpl->built && + mImpl->model.addSetting( { std::move( descriptor ), + EditableChoiceSetting{ std::move( choices ), std::move( get ), + std::move( set ) } } ); +} + +bool UISettingsPanel::addInteger( SettingDescriptor descriptor, int min, int max, + std::function get, std::function set ) { + return !mImpl->built && min <= max && + mImpl->model.addSetting( + { std::move( descriptor ), + IntegerSetting{ min, max, std::move( get ), std::move( set ) } } ); +} + +bool UISettingsPanel::addText( SettingDescriptor descriptor, std::function get, + std::function set, + bool commitOnFocusLoss ) { + return !mImpl->built && + mImpl->model.addSetting( + { std::move( descriptor ), + TextSetting{ std::move( get ), std::move( set ), commitOnFocusLoss } } ); +} + +bool UISettingsPanel::addFloat( SettingDescriptor descriptor, double min, double max, double step, + std::function get, std::function set ) { + return !mImpl->built && min <= max && step > 0 && + mImpl->model.addSetting( + { std::move( descriptor ), + FloatSetting{ min, max, step, std::move( get ), std::move( set ) } } ); +} + +bool UISettingsPanel::addAction( SettingDescriptor descriptor, String buttonText, + std::function action ) { + return !mImpl->built && action && + mImpl->model.addSetting( + { std::move( descriptor ), + ActionSetting{ std::move( buttonText ), std::move( action ) } } ); +} + +void UISettingsPanel::build() { + if ( mImpl->built ) + return; + mImpl->built = true; + mImpl->settings->beginAttributesTransaction(); + materializeCategory( *mImpl, mImpl->selectedCategory ); + setupCategories( *mImpl ); + mImpl->settings->endAttributesTransaction(); + selectCategory( *mImpl, mImpl->selectedCategory ); + mImpl->connections += mImpl->search->connect( Event::OnTextChanged, [this]( const Event* ) { + String query = mImpl->search->getText(); + query.trim(); + const UintPtr debounceTag = reinterpret_cast( this ); + if ( query.size() < 2 ) { + mImpl->search->removeActionsByTag( debounceTag ); + filter( *mImpl ); + return; + } + mImpl->search->debounce( [this] { filter( *mImpl ); }, Milliseconds( 150 ), debounceTag ); + } ); +} + +void UISettingsPanel::selectCategory( const std::string& category ) { + selectCategory( *mImpl, category ); +} + +void UISettingsPanel::setCategoryEnabled( const std::string& category, bool enabled, + const std::string& excludedSetting ) { + setCategoryEnabled( *mImpl, category, enabled, excludedSetting ); +} + +void UISettingsPanel::refreshTextSetting( const std::string& id ) { + refreshTextSetting( *mImpl, id ); +} + +void UISettingsPanel::setSearchResultsText( String text ) { + mImpl->searchResultsText = std::move( text ); +} + +void UISettingsPanel::setFilter( String filterText ) { + const UintPtr debounceTag = reinterpret_cast( this ); + mImpl->search->setText( std::move( filterText ) ); + mImpl->search->removeActionsByTag( debounceTag ); + filter( *mImpl ); +} + +void UISettingsPanel::focusSearch() { + mImpl->search->setFocus(); + mImpl->search->getDocument().selectAll(); +} + +void UISettingsPanel::focusCategories() { + auto selected = mImpl->categories->getSelection().first(); + if ( selected.isValid() ) + mImpl->categories->setSelection( selected ); + mImpl->categories->setFocus(); +} + +bool UISettingsPanel::isBuilt() const { + return mImpl->built; +} + +void UISettingsPanel::addCategory( Impl& panel, const std::string& id, const String& parent, + const String& name ) { + const auto parentText = parent.toUtf8(); + const auto nameText = name.toUtf8(); + auto parentItems = + std::find_if( panel.categoryItems.begin(), panel.categoryItems.end(), + [&parentText]( const auto& item ) { return item.first == parentText; } ); + if ( parentItems == panel.categoryItems.end() ) { + panel.categoryItems.emplace_back( parentText, std::vector{ nameText } ); + } else { + parentItems->second.emplace_back( nameText ); + } + panel.categoryIds[parentText + '/' + nameText] = id; + panel.categorySearchText[id] = parent + " " + name; + panel.categoryTitles[id] = name; + auto* section = + getUISceneNode()->loadLayoutFromString( SETTINGS_CATEGORY_HEADING_LAYOUT, panel.settings ); + auto* heading = section->find( "settings_category_heading" ); + heading->setText( name ); + heading->setId( "settings_category_" + id ); + panel.categoryHeadings[id] = heading; + panel.categorySections[id] = section; + panel.categoryContainers[id] = section->find( "settings_category_rows" ); + if ( panel.selectedCategory.empty() ) + panel.selectedCategory = id; +} + +void UISettingsPanel::selectCategory( Impl& panel, const std::string& category ) { + if ( category.empty() || !panel.categories ) + return; + String title; + if ( String::endsWith( category, ".*" ) ) { + const std::string prefix = category.substr( 0, category.size() - 1 ); + for ( const auto& [parent, children] : panel.categoryItems ) { + if ( children.empty() ) + continue; + auto id = panel.categoryIds.find( parent + '/' + children.front() ); + if ( id != panel.categoryIds.end() && String::startsWith( id->second, prefix ) ) { + title = String::fromUtf8( parent ); + break; + } + } + } else if ( auto found = panel.categoryTitles.find( category ); + found != panel.categoryTitles.end() ) { + title = found->second; + } + if ( title.empty() ) + return; + panel.selectedCategory = category; + auto index = panel.categories->findRowWithText( + title.toUtf8(), true, UIAbstractView::FindRowWithTextMatchKind::Equals ); + if ( index.isValid() ) + panel.categories->setSelection( index ); + filter( panel ); + panel.scroll->getVerticalScrollBar()->setValue( 0, false ); +} + +void UISettingsPanel::addSubcategoryHeading( Impl& panel, const std::string& category, + const String& name ) { + panel.model.addGroup( { category, name, panel.model.settings().size() } ); +} + +void UISettingsPanel::setupCategories( Impl& panel ) { + auto model = UISettingsCategoryModel::create( panel.categoryItems, panel.categoryIds ); + panel.categoryModel = model; + panel.categories->setHeadersVisible( false ); + panel.categories->setAutoExpandOnSingleColumn( true ); + panel.categories->setFocusOnSelection( true ); + panel.categories->setModel( model ); + panel.categories->expandAll(); + panel.connections += + panel.categories->connect( Event::OnSelectionChanged, [this, &panel]( const Event* ) { + auto index = panel.categories->getSelection().first(); + if ( !index.isValid() ) + return; + auto* node = static_cast( index.internalData() ); + if ( !node || node->id.empty() ) + return; + panel.selectedCategory = node->id; + panel.pageTitle->setText( node->text ); + filter( panel ); + panel.scroll->getVerticalScrollBar()->setValue( 0, false ); + } ); +} + +UIWidget* UISettingsPanel::createRow( Impl& panel, SettingDefinition& setting, SettingView& view, + pugi::xml_node layout ) { + auto& binding = setting.descriptor; + auto container = panel.categoryContainers.find( binding.category ); + eeASSERT( container != panel.categoryContainers.end() ); + auto* row = getUISceneNode()->loadLayoutNodes( layout, container->second, 0 ); + row->setId( "setting_" + binding.id ); + row->find( "setting_name" )->setText( binding.name ); + auto* description = row->find( "setting_description" ); + description->setText( binding.description ); + view.row = row; + return row; +} + +UICheckBox* UISettingsPanel::createBoolControl( Impl& panel, SettingDefinition& setting, + SettingView& view ) { + auto* row = createRow( panel, setting, view, SETTINGS_BOOL_ROW_LAYOUT.root() ); + row->addClass( "settings_boolean_option" ); + auto* check = row->find( "setting_control_widget" ); + auto toggle = [check]( const Event* event ) { + if ( event->asMouseEvent()->getFlags() & EE_BUTTON_LMASK ) + check->setChecked( !check->isChecked() ); + }; + panel.connections += + row->find( "setting_name" )->connect( Event::MouseClick, toggle ); + panel.connections += + row->find( "setting_description" )->connect( Event::MouseClick, toggle ); + panel.connections += row->find( "setting_info" ) + ->connect( Event::MouseClick, std::move( toggle ) ); + return check; +} + +void UISettingsPanel::refreshTextSetting( Impl& panel, const std::string& id ) { + const auto& settings = panel.model.settings(); + for ( size_t i = 0; i < settings.size(); ++i ) { + if ( settings[i].descriptor.id != id || i >= panel.settingViews.size() || + !panel.settingViews[i].row ) + continue; + auto* value = std::get_if( &settings[i].value ); + auto* input = panel.settingViews[i].row->find( "setting_control_widget" ); + if ( value && input ) + input->setText( String::fromUtf8( value->get() ) ); + return; + } +} + +static void setNodeTreeEnabled( Node* node, bool enabled ); + +void UISettingsPanel::materializeCategory( Impl& panel, const std::string& category ) { + if ( category.empty() || panel.materializedCategories.contains( category ) ) + return; + auto container = panel.categoryContainers.find( category ); + if ( container == panel.categoryContainers.end() ) + return; + auto& settings = panel.model.settings(); + panel.settingViews.resize( settings.size() ); + container->second->beginAttributesTransaction(); + for ( size_t i = 0; i < settings.size(); ++i ) { + auto& setting = settings[i]; + if ( setting.descriptor.category != category ) + continue; + for ( const auto& group : panel.model.groups() ) { + if ( group.category != category || group.beforeSetting != i ) + continue; + auto* heading = + getUISceneNode() + ->loadLayoutFromString( SETTINGS_SUBCATEGORY_HEADING_LAYOUT, container->second ) + ->asType(); + heading->setText( group.name ); + panel.subcategoryHeadings.push_back( { group.category, group.name, heading } ); + } + auto& view = panel.settingViews[i]; + if ( auto* value = std::get_if( &setting.value ) ) { + auto* check = createBoolControl( panel, setting, view ); + auto binding = UIDataBind::New( value->value, check, + UIValueConverter::converterBool() ); + binding->onValueChangeCb = value->apply; + panel.bindingGroup += std::move( binding ); + } else if ( auto* value = std::get_if( &setting.value ) ) { + auto* check = createBoolControl( panel, setting, view ); + check->setChecked( value->get() ); + panel.connections += + check->connect( Event::OnValueChange, [check, value]( const Event* ) { + value->set( check->isChecked() ); + } ); + } else if ( auto* value = std::get_if( &setting.value ) ) { + auto* row = createRow( panel, setting, view, SETTINGS_CHOICE_ROW_LAYOUT.root() ); + auto* dropDown = row->find( "setting_control_widget" ); + auto model = ItemListOwnerModel::create( value->choices ); + dropDown->setModel( model ); + const size_t selected = value->get(); + if ( selected < value->choices.size() ) { + dropDown->getListView()->getSelection().set( model->index( selected, 0 ) ); + dropDown->setText( value->choices[selected] ); + } + if ( selected < value->descriptions.size() ) + dropDown->setTooltipText( value->descriptions[selected] ); + panel.connections += + dropDown->connect( Event::OnValueChange, [dropDown, value]( const Event* ) { + if ( dropDown->getListView()->getSelection().isEmpty() ) + return; + const size_t selected = dropDown->getListView()->getSelection().first().row(); + if ( selected < value->descriptions.size() ) + dropDown->setTooltipText( value->descriptions[selected] ); + value->set( selected ); + } ); + } else if ( auto* value = std::get_if( &setting.value ) ) { + auto* row = + createRow( panel, setting, view, SETTINGS_EDITABLE_CHOICE_ROW_LAYOUT.root() ); + auto* combo = row->find( "setting_control_widget" ); + for ( const auto& choice : value->choices ) + combo->getListBox()->addListBoxItem( choice ); + combo->setText( value->get() ); + panel.connections += + combo->connect( Event::OnValueChange, [combo, value]( const Event* ) { + if ( !value->set( combo->getText() ) ) { + combo->addClass( "error" ); + combo->getDropDownList()->addClass( "error" ); + return; + } + combo->removeClass( "error" ); + combo->getDropDownList()->removeClass( "error" ); + } ); + } else if ( auto* value = std::get_if( &setting.value ) ) { + auto* row = createRow( panel, setting, view, SETTINGS_INTEGER_ROW_LAYOUT.root() ); + auto* spin = row->find( "setting_control_widget" ); + spin->setMinValue( value->min )->setMaxValue( value->max ); + spin->unsetTabFocusable(); + spin->getButtonPushUp()->asType()->unsetTabFocusable(); + spin->getButtonPushDown()->asType()->unsetTabFocusable(); + spin->setValue( value->get() ); + panel.connections += + spin->connect( Event::OnValueChange, [spin, value]( const Event* ) { + value->set( static_cast( spin->getValue() ) ); + } ); + } else if ( auto* value = std::get_if( &setting.value ) ) { + auto* row = createRow( panel, setting, view, SETTINGS_TEXT_ROW_LAYOUT.root() ); + auto* input = row->find( "setting_control_widget" ); + if ( value->password ) + input->setMode( UITextInput::TextInputMode::Password ); + input->setText( String::fromUtf8( value->get() ) ); + auto commit = [input, value] { + if ( !value->set( input->getText().toUtf8() ) ) { + input->addClass( "error" ); + return; + } + input->removeClass( "error" ); + }; + if ( value->commitOnFocusLoss ) { + const auto debounceTag = reinterpret_cast( input ); + panel.connections += input->connect( + Event::OnTextChanged, [input, commit, debounceTag]( const Event* ) { + input->debounce( commit, Milliseconds( 500 ), debounceTag ); + } ); + auto flush = [input, commit, debounceTag]( const Event* ) { + input->removeActionsByTag( debounceTag ); + commit(); + }; + panel.connections += input->connect( Event::OnPressEnter, flush ); + panel.connections += input->connect( Event::OnFocusLoss, std::move( flush ) ); + } else { + panel.connections += + input->connect( Event::OnTextChanged, + [commit = std::move( commit )]( const Event* ) { commit(); } ); + } + } else if ( auto* value = std::get_if( &setting.value ) ) { + auto* row = createRow( panel, setting, view, SETTINGS_INTEGER_ROW_LAYOUT.root() ); + auto* spin = row->find( "setting_control_widget" ); + spin->setMinValue( value->min )->setMaxValue( value->max )->setClickStep( value->step ); + spin->allowFloatingPoint( true )->setValue( value->get() ); + spin->unsetTabFocusable(); + spin->getButtonPushUp()->asType()->unsetTabFocusable(); + spin->getButtonPushDown()->asType()->unsetTabFocusable(); + panel.connections += + spin->connect( Event::OnValueChange, + [spin, value]( const Event* ) { value->set( spin->getValue() ); } ); + } else if ( auto* value = std::get_if( &setting.value ) ) { + auto* row = createRow( panel, setting, view, SETTINGS_ACTION_ROW_LAYOUT.root() ); + auto* button = row->find( "setting_control_widget" ); + button->setText( value->buttonText ); + panel.connections += button->connect( Event::MouseClick, [value]( const Event* event ) { + if ( event->asMouseEvent()->getFlags() & EE_BUTTON_LMASK ) + value->action(); + } ); + } + if ( view.row && !setting.enabled ) + setNodeTreeEnabled( view.row, false ); + } + container->second->endAttributesTransaction(); + panel.materializedCategories.insert( category ); +} + +void UISettingsPanel::materializeVisibleSettings( Impl& panel, const String& query ) { + const std::string queryUtf8 = query.toUtf8(); + const bool aggregate = String::endsWith( panel.selectedCategory, ".*" ); + const std::string aggregatePrefix = + aggregate ? panel.selectedCategory.substr( 0, panel.selectedCategory.size() - 1 ) + : std::string{}; + for ( const auto& category : panel.model.categories() ) { + bool materialize = + query.empty() ? category.id == panel.selectedCategory || + ( aggregate && String::startsWith( category.id, aggregatePrefix ) ) + : String::icontains( category.parent, query ) || + String::icontains( category.name, query ); + if ( !materialize && !query.empty() ) { + for ( const auto& setting : panel.model.settings() ) { + const auto& descriptor = setting.descriptor; + if ( descriptor.category == category.id && + ( String::icontains( descriptor.name, query ) || + String::icontains( descriptor.description, query ) || + String::icontains( descriptor.group, query ) || + String::icontains( descriptor.id, queryUtf8 ) ) ) { + materialize = true; + break; + } + } + } + if ( materialize ) + materializeCategory( panel, category.id ); + } +} + +static void setNodeTreeEnabled( Node* node, bool enabled ) { + node->setEnabled( enabled ); + for ( Node* child = node->getFirstChild(); child; child = child->getNextNode() ) + setNodeTreeEnabled( child, enabled ); +} + +void UISettingsPanel::setCategoryEnabled( Impl& panel, const std::string& category, bool enabled, + const std::string& excludedSetting ) { + auto& settings = panel.model.settings(); + for ( size_t i = 0; i < settings.size(); ++i ) { + auto& setting = settings[i]; + const auto& descriptor = setting.descriptor; + if ( descriptor.category != category || descriptor.id == excludedSetting ) + continue; + setting.enabled = enabled; + if ( i < panel.settingViews.size() && panel.settingViews[i].row ) + setNodeTreeEnabled( panel.settingViews[i].row, enabled ); + } +} + +void UISettingsPanel::filter( Impl& panel ) { + String query = panel.search ? panel.search->getText() : String{}; + query.trim().toLower(); + if ( query.size() < 2 ) + query.clear(); + materializeVisibleSettings( panel, query ); + if ( !query.empty() ) { + panel.pageTitle->setText( panel.searchResultsText ); + } else if ( auto title = panel.categoryTitles.find( panel.selectedCategory ); + title != panel.categoryTitles.end() ) { + panel.pageTitle->setText( title->second ); + } + UnorderedSet matchingCategories; + const std::string queryUtf8 = query.toUtf8(); + const bool aggregate = String::endsWith( panel.selectedCategory, ".*" ); + const std::string aggregatePrefix = + aggregate ? panel.selectedCategory.substr( 0, panel.selectedCategory.size() - 1 ) + : std::string{}; + if ( !query.empty() ) { + for ( const auto& setting : panel.model.settings() ) { + const auto& binding = setting.descriptor; + if ( String::icontains( binding.name, query ) || + String::icontains( binding.description, query ) || + String::icontains( binding.group, query ) || + String::icontains( binding.id, queryUtf8 ) ) + matchingCategories.insert( binding.category ); + } + } + if ( queryUtf8 != panel.categoryFilter ) { + panel.categoryFilter = queryUtf8; + if ( auto model = std::static_pointer_cast( panel.categoryModel ) ) + model->filter( queryUtf8, matchingCategories ); + } + panel.categories->expandAll(); + for ( auto& [category, heading] : panel.categoryHeadings ) + heading->setVisible( query.empty() && aggregate && + String::startsWith( category, aggregatePrefix ) ); + const auto& settings = panel.model.settings(); + for ( size_t i = 0; i < settings.size(); ++i ) { + const auto& binding = settings[i].descriptor; + const auto categoryName = panel.categorySearchText.find( binding.category ); + const bool categoryMatches = categoryName != panel.categorySearchText.end() && + String::icontains( categoryName->second, query ); + const bool matches = + query.empty() + ? binding.category == panel.selectedCategory || + ( aggregate && String::startsWith( binding.category, aggregatePrefix ) ) + : categoryMatches || String::icontains( binding.name, query ) || + String::icontains( binding.description, query ) || + String::icontains( binding.group, query ) || + String::icontains( binding.id, queryUtf8 ); + if ( i < panel.settingViews.size() && panel.settingViews[i].row ) + panel.settingViews[i].row->setVisible( matches ); + } + for ( const auto& category : panel.model.categories() ) { + auto section = panel.categorySections.find( category.id ); + if ( section == panel.categorySections.end() ) + continue; + bool visible = false; + for ( size_t i = 0; i < settings.size(); ++i ) { + if ( settings[i].descriptor.category == category.id && i < panel.settingViews.size() && + panel.settingViews[i].row && panel.settingViews[i].row->isVisible() ) { + visible = true; + break; + } + } + section->second->setVisible( visible ); + } + for ( auto& subcategory : panel.subcategoryHeadings ) { + if ( !subcategory.heading ) + continue; + const bool hasVisibleSetting = std::any_of( + settings.begin(), settings.end(), [&panel, &subcategory]( const auto& setting ) { + const auto& binding = setting.descriptor; + const size_t index = &setting - panel.model.settings().data(); + return binding.category == subcategory.category && + binding.group == subcategory.name && index < panel.settingViews.size() && + panel.settingViews[index].row && panel.settingViews[index].row->isVisible(); + } ); + subcategory.heading->setVisible( hasVisibleSetting ); + } +} + +} // namespace EE::UI::Tools diff --git a/src/tests/unit_tests/uisettingspanel_tests.cpp b/src/tests/unit_tests/uisettingspanel_tests.cpp new file mode 100644 index 000000000..a4db1c0ee --- /dev/null +++ b/src/tests/unit_tests/uisettingspanel_tests.cpp @@ -0,0 +1,135 @@ +#include "utest.hpp" + +#include +#include +#include +#include +#include +#include +#include + +using namespace EE; +using namespace EE::System; +using namespace EE::UI; +using namespace EE::UI::Tools; +using namespace EE::Window; + +UTEST( UISettingsPanelModel, validatesCategoriesGroupsAndSettings ) { + SettingsModel model; + + EXPECT_FALSE( model.addCategory( { {}, "General", "Behavior" } ) ); + EXPECT_TRUE( model.addCategory( { "general.behavior", "General", "Behavior" } ) ); + EXPECT_FALSE( model.addCategory( { "general.behavior", "General", "Other" } ) ); + EXPECT_TRUE( model.hasCategory( "general.behavior" ) ); + EXPECT_FALSE( model.hasCategory( "general.missing" ) ); + + EXPECT_FALSE( model.addGroup( { "general.missing", "Display", 0 } ) ); + EXPECT_TRUE( model.addGroup( { "general.behavior", "Display", 0 } ) ); + + bool value = false; + EXPECT_FALSE( model.addSetting( { { {}, "general.behavior", "Empty", "Empty identifier", {} }, + BoolPointerSetting{ &value, {} } } ) ); + EXPECT_FALSE( + model.addSetting( { { "missing", "general.missing", "Missing", "Missing category", {} }, + BoolPointerSetting{ &value, {} } } ) ); + EXPECT_TRUE( model.addSetting( + { { "enabled", "general.behavior", "Enabled", "Enable the option", "Display" }, + BoolPointerSetting{ &value, {} } } ) ); + EXPECT_FALSE( model.addSetting( + { { "enabled", "general.behavior", "Duplicate", "Duplicate identifier", {} }, + BoolPointerSetting{ &value, {} } } ) ); + + EXPECT_EQ( 1u, model.categories().size() ); + EXPECT_EQ( 1u, model.groups().size() ); + EXPECT_EQ( 1u, model.settings().size() ); + EXPECT_STDSTREQ( "enabled", model.settings().front().descriptor.id ); +} + +UTEST( UISettingsPanelModel, clearRemovesAllRules ) { + SettingsModel model; + bool value = true; + EXPECT_TRUE( model.addCategory( { "editor.document", "Editor", "Document" } ) ); + EXPECT_TRUE( model.addGroup( { "editor.document", "Files", 0 } ) ); + EXPECT_TRUE( + model.addSetting( { { "trim", "editor.document", "Trim", "Trim whitespace", "Files" }, + BoolPointerSetting{ &value, {} } } ) ); + + model.clear(); + + EXPECT_TRUE( model.categories().empty() ); + EXPECT_TRUE( model.groups().empty() ); + EXPECT_TRUE( model.settings().empty() ); +} + +UTEST( UISettingsPanel, buildsAndMaterializesCategoriesLazily ) { + UIApplication app( + WindowSettings( 800, 600, "eepp - UISettingsPanel Test", WindowStyle::Default, + WindowBackend::Default, 32 ), + UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) ); + auto* panel = UISettingsPanel::New( app.getUI()->getRoot() ); + bool firstValue = false; + bool secondValue = true; + + EXPECT_TRUE( panel->addCategory( "general.behavior", "General", "Behavior" ) ); + EXPECT_TRUE( panel->addCategory( "editor.display", "Editor", "Display" ) ); + EXPECT_TRUE( panel->addBool( { "first", "general.behavior", "First", "The first setting", {} }, + &firstValue ) ); + EXPECT_TRUE( panel->addBool( { "second", "editor.display", "Second", "The second setting", {} }, + &secondValue ) ); + EXPECT_EQ( nullptr, panel->find( "setting_first" ) ); + EXPECT_EQ( nullptr, panel->find( "setting_second" ) ); + + panel->build(); + + EXPECT_TRUE( panel->isBuilt() ); + auto* firstRow = panel->find( "setting_first" ); + EXPECT_NE( nullptr, firstRow ); + EXPECT_TRUE( firstRow->isVisible() ); + EXPECT_EQ( nullptr, panel->find( "setting_second" ) ); + EXPECT_FALSE( panel->addCategory( "late.category", "Late", "Category" ) ); + + panel->selectCategory( "editor.display" ); + auto* secondRow = panel->find( "setting_second" ); + EXPECT_NE( nullptr, secondRow ); + EXPECT_FALSE( firstRow->isVisible() ); + EXPECT_TRUE( secondRow->isVisible() ); + EXPECT_STDSTREQ( "Display", + panel->find( "settings_page_title" )->getText().toUtf8() ); + + panel->setCategoryEnabled( "editor.display", false ); + EXPECT_FALSE( secondRow->isEnabled() ); + panel->setCategoryEnabled( "editor.display", true ); + EXPECT_TRUE( secondRow->isEnabled() ); +} + +UTEST( UISettingsPanel, filtersAcrossUnmaterializedCategories ) { + UIApplication app( + WindowSettings( 800, 600, "eepp - UISettingsPanel Filter Test", WindowStyle::Default, + WindowBackend::Default, 32 ), + UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) ); + auto* panel = UISettingsPanel::New( app.getUI()->getRoot() ); + bool firstValue = false; + bool secondValue = false; + panel->setSearchResultsText( "Filtered Settings" ); + EXPECT_TRUE( panel->addCategory( "general.behavior", "General", "Behavior" ) ); + EXPECT_TRUE( panel->addCategory( "editor.display", "Editor", "Display" ) ); + EXPECT_TRUE( panel->addBool( { "first", "general.behavior", "First", "Ordinary option", {} }, + &firstValue ) ); + EXPECT_TRUE( panel->addBool( + { "second", "editor.display", "Second", "Unique searchable phrase", {} }, &secondValue ) ); + panel->build(); + + panel->setFilter( "searchable" ); + + auto* firstRow = panel->find( "setting_first" ); + auto* secondRow = panel->find( "setting_second" ); + EXPECT_NE( nullptr, secondRow ); + EXPECT_FALSE( firstRow->isVisible() ); + EXPECT_TRUE( secondRow->isVisible() ); + EXPECT_STDSTREQ( "Filtered Settings", + panel->find( "settings_page_title" )->getText().toUtf8() ); + + panel->setFilter( {} ); + EXPECT_TRUE( firstRow->isVisible() ); + EXPECT_FALSE( secondRow->isVisible() ); +} diff --git a/src/tools/ecode/plugins/git/gitplugin.cpp b/src/tools/ecode/plugins/git/gitplugin.cpp index 618d7a260..6bfb22c98 100644 --- a/src/tools/ecode/plugins/git/gitplugin.cpp +++ b/src/tools/ecode/plugins/git/gitplugin.cpp @@ -31,6 +31,7 @@ #include #include #include +#include #include #include diff --git a/src/tools/ecode/settingsmodel.hpp b/src/tools/ecode/settingsmodel.hpp deleted file mode 100644 index 2a2354146..000000000 --- a/src/tools/ecode/settingsmodel.hpp +++ /dev/null @@ -1,148 +0,0 @@ -#ifndef ECODE_SETTINGSMODEL_HPP -#define ECODE_SETTINGSMODEL_HPP - -#include -#include -#include -#include -#include -#include - -namespace ecode { - -struct SettingDescriptor { - std::string id; - std::string category; - String name; - String description; - String group; -}; - -struct BoolPointerSetting { - bool* value{ nullptr }; - std::function apply; -}; - -struct BoolSetting { - std::function get; - std::function set; -}; - -struct ChoiceSetting { - std::vector choices; - std::vector descriptions; - std::function get; - std::function set; -}; - -struct EditableChoiceSetting { - std::vector choices; - std::function get; - std::function set; -}; - -struct IntegerSetting { - int min{ 0 }; - int max{ 0 }; - std::function get; - std::function set; -}; - -struct TextSetting { - std::function get; - std::function set; - bool commitOnFocusLoss{ false }; - bool password{ false }; -}; - -struct FloatSetting { - double min{ 0 }; - double max{ 0 }; - double step{ 0 }; - std::function get; - std::function set; -}; - -struct ActionSetting { - String buttonText; - std::function action; -}; - -using SettingValue = - std::variant; - -struct SettingDefinition { - SettingDescriptor descriptor; - SettingValue value; - bool enabled{ true }; -}; - -struct SettingsCategory { - std::string id; - String parent; - String name; -}; - -struct SettingsGroup { - std::string category; - String name; - size_t beforeSetting{ 0 }; -}; - -class SettingsModel { - public: - void clear() { - mCategories.clear(); - mGroups.clear(); - mSettings.clear(); - } - - bool addCategory( SettingsCategory category ) { - if ( category.id.empty() || - std::any_of( mCategories.begin(), mCategories.end(), - [&category]( const auto& item ) { return item.id == category.id; } ) ) - return false; - mCategories.emplace_back( std::move( category ) ); - return true; - } - - bool addGroup( SettingsGroup group ) { - if ( !hasCategory( group.category ) ) - return false; - mGroups.emplace_back( std::move( group ) ); - return true; - } - - bool addSetting( SettingDefinition setting ) { - if ( setting.descriptor.id.empty() || !hasCategory( setting.descriptor.category ) || - std::any_of( mSettings.begin(), mSettings.end(), [&setting]( const auto& item ) { - return item.descriptor.id == setting.descriptor.id; - } ) ) - return false; - mSettings.emplace_back( std::move( setting ) ); - return true; - } - - bool hasCategory( const std::string& id ) const { - return std::any_of( mCategories.begin(), mCategories.end(), - [&id]( const auto& item ) { return item.id == id; } ); - } - - const std::vector& categories() const { return mCategories; } - - const std::vector& groups() const { return mGroups; } - - std::vector& settings() { return mSettings; } - - const std::vector& settings() const { return mSettings; } - - private: - std::vector mCategories; - std::vector mGroups; - std::vector mSettings; -}; - -} // namespace ecode - -#endif diff --git a/src/tools/ecode/settingspage.hpp b/src/tools/ecode/settingspage.hpp index b12d96f6b..634a927ee 100644 --- a/src/tools/ecode/settingspage.hpp +++ b/src/tools/ecode/settingspage.hpp @@ -2,7 +2,12 @@ #define ECODE_SETTINGSPAGE_HPP #include "settingsdocument.hpp" -#include "settingsmodel.hpp" + +#include + +using namespace EE; +using namespace EE::System; +using namespace EE::UI::Tools; namespace ecode { diff --git a/src/tools/ecode/settingspanel.cpp b/src/tools/ecode/settingspanel.cpp index d1d910da0..412f68724 100644 --- a/src/tools/ecode/settingspanel.cpp +++ b/src/tools/ecode/settingspanel.cpp @@ -6,308 +6,10 @@ #include "settingsdocument.hpp" #include "settingspage.hpp" #include "uitreeviewfs.hpp" -#include #include -#define PUGIXML_HEADER_ONLY -#include - -using namespace EE::UI::Models; namespace ecode { -class SettingsCategoryModel final : public Model { - public: - struct Node { - std::string id; - std::string text; - Node* parent{ nullptr }; - std::vector children; - std::vector visibleChildren; - }; - - static std::shared_ptr - create( const std::vector>>& categories, - const UnorderedMap& ids ) { - return std::make_shared( categories, ids ); - } - - SettingsCategoryModel( - const std::vector>>& categories, - const UnorderedMap& ids ) { - mNodes.emplace_back(); - mRoot = &mNodes.back(); - for ( const auto& [parent, children] : categories ) { - std::string parentId; - if ( !children.empty() ) { - auto id = ids.find( parent + '/' + children.front() ); - if ( id != ids.end() ) { - auto separator = id->second.find( '.' ); - parentId = id->second.substr( 0, separator ) + ".*"; - } - } - mNodes.push_back( { std::move( parentId ), parent, mRoot } ); - auto* parentNode = &mNodes.back(); - mRoot->children.push_back( parentNode ); - for ( const auto& child : children ) { - auto id = ids.find( parent + '/' + child ); - mNodes.push_back( - { id == ids.end() ? std::string{} : id->second, child, parentNode } ); - parentNode->children.push_back( &mNodes.back() ); - } - } - filter( {}, {} ); - } - - size_t rowCount( const ModelIndex& parent = {} ) const { - auto* node = parent.isValid() ? static_cast( parent.internalData() ) : mRoot; - return node->visibleChildren.size(); - } - - size_t columnCount( const ModelIndex& = {} ) const { return 1; } - - ModelIndex index( int row, int column, const ModelIndex& parent = {} ) const { - auto* node = parent.isValid() ? static_cast( parent.internalData() ) : mRoot; - if ( row < 0 || column != 0 || static_cast( row ) >= node->visibleChildren.size() ) - return {}; - return createIndex( row, column, node->visibleChildren[row] ); - } - - ModelIndex parentIndex( const ModelIndex& index ) const { - if ( !index.isValid() ) - return {}; - auto* node = static_cast( index.internalData() ); - if ( !node->parent || node->parent == mRoot ) - return {}; - auto* parent = node->parent; - auto found = - std::find( mRoot->visibleChildren.begin(), mRoot->visibleChildren.end(), parent ); - return found == mRoot->visibleChildren.end() - ? ModelIndex{} - : createIndex( std::distance( mRoot->visibleChildren.begin(), found ), 0, - parent ); - } - - Variant data( const ModelIndex& index, ModelRole role = ModelRole::Display ) const { - if ( !index.isValid() || role != ModelRole::Display ) - return {}; - return Variant( static_cast( index.internalData() )->text ); - } - - void filter( const std::string_view query, - const UnorderedSet& matchingCategories ) { - mRoot->visibleChildren.clear(); - for ( auto* parent : mRoot->children ) { - parent->visibleChildren.clear(); - const bool parentMatches = query.empty() || String::icontains( parent->text, query ); - for ( auto* child : parent->children ) { - if ( parentMatches || String::icontains( child->text, query ) || - matchingCategories.contains( child->id ) ) - parent->visibleChildren.push_back( child ); - } - if ( !parent->visibleChildren.empty() ) - mRoot->visibleChildren.push_back( parent ); - } - invalidate( Model::UpdateFlag::InvalidateAllIndexes ); - } - - private: - std::deque mNodes; - Node* mRoot{ nullptr }; -}; - -static constexpr const char* SETTINGS_PANEL_LAYOUT = R"xml( - - - - - - - - - - - - - - -)xml"; - -static std::string settingsRowLayout( std::string_view control ) { - return R"xml( - - - - - - - -)xml" + std::string( control ) + - R"xml( - - - -)xml"; -} - -class SettingsLayoutTemplate { - public: - explicit SettingsLayoutTemplate( const std::string& layout ) { - [[maybe_unused]] auto result = - mDocument.load_string( layout.c_str(), pugi::parse_default | pugi::parse_ws_pcdata ); - eeASSERT( result ); - } - - pugi::xml_node root() const { return mDocument.first_child(); } - - private: - pugi::xml_document mDocument; -}; - -static constexpr const char* SETTINGS_CATEGORY_HEADING_LAYOUT = R"xml( - - - - -)xml"; -static constexpr const char* SETTINGS_SUBCATEGORY_HEADING_LAYOUT = R"xml( - -)xml"; -static const SettingsLayoutTemplate SETTINGS_BOOL_ROW_LAYOUT( settingsRowLayout( - R"xml()xml" ) ); -static const SettingsLayoutTemplate SETTINGS_CHOICE_ROW_LAYOUT( settingsRowLayout( - R"xml()xml" ) ); -static const SettingsLayoutTemplate SETTINGS_EDITABLE_CHOICE_ROW_LAYOUT( settingsRowLayout( - R"xml()xml" ) ); -static const SettingsLayoutTemplate SETTINGS_INTEGER_ROW_LAYOUT( settingsRowLayout( - R"xml()xml" ) ); -static const SettingsLayoutTemplate SETTINGS_TEXT_ROW_LAYOUT( settingsRowLayout( - R"xml()xml" ) ); -static const SettingsLayoutTemplate SETTINGS_ACTION_ROW_LAYOUT( settingsRowLayout( - R"xml()xml" ) ); - -static void disableTabFocusTree( Node* node ) { - if ( node->isWidget() ) - node->asType()->unsetTabFocusable(); - for ( auto* child = node->getFirstChild(); child; child = child->getNextNode() ) - disableTabFocusTree( child ); -} - SettingsPanel::SettingsPanel( App* app ) : mApp( app ), mLifetime( this, app ? app->getUISceneNode() : nullptr ) {} @@ -316,161 +18,70 @@ SettingsPanel::PanelState& SettingsPanel::state( Scope scope ) { } void SettingsPanel::PanelState::reset() { - bindingGroup.clear(); connections.clear(); window = nullptr; - search = nullptr; - categories = nullptr; - scroll = nullptr; - settings = nullptr; - pageTitle = nullptr; - categoryModel.reset(); - categoryItems.clear(); - categoryIds.clear(); - categorySearchText.clear(); - categoryTitles.clear(); - categoryHeadings.clear(); - categorySections.clear(); - categoryContainers.clear(); - materializedCategories.clear(); - subcategoryHeadings.clear(); - model.clear(); + panel = nullptr; documents.clear(); - settingViews.clear(); - selectedCategory.clear(); - categoryFilter.clear(); } void SettingsPanel::show( Scope scope, const std::string& category ) { - auto& panel = state( scope ); - if ( panel.window ) { - panel.window->show(); - panel.window->toFront(); - selectCategory( panel, category ); - panel.search->runOnMainThread( [search = panel.search] { search->setFocus(); } ); + auto& state = this->state( scope ); + if ( state.window ) { + state.window->show(); + state.window->toFront(); + selectCategory( state, category ); + state.panel->runOnMainThread( [panel = state.panel] { panel->focusSearch(); } ); return; } - Clock c; + Clock clock; create( scope ); - selectCategory( panel, category ); + selectCategory( state, category ); Log::info( "Settings Panel %s created in %s", scope == Scope::User ? "User" : "Project", - c.getElapsedTime().toString() ); -} - -void SettingsPanel::selectCategory( PanelState& panel, const std::string& category ) { - if ( category.empty() || !panel.categories ) - return; - String title; - if ( String::endsWith( category, ".*" ) ) { - const std::string prefix = category.substr( 0, category.size() - 1 ); - for ( const auto& [parent, children] : panel.categoryItems ) { - if ( !children.empty() ) { - auto id = panel.categoryIds.find( parent + '/' + children.front() ); - if ( id != panel.categoryIds.end() && String::startsWith( id->second, prefix ) ) { - title = String::fromUtf8( parent ); - break; - } - } - } - } else if ( auto found = panel.categoryTitles.find( category ); - found != panel.categoryTitles.end() ) { - title = found->second; - } - if ( title.empty() ) - return; - panel.selectedCategory = category; - auto index = panel.categories->findRowWithText( - title.toUtf8(), true, UIAbstractView::FindRowWithTextMatchKind::Equals ); - if ( index.isValid() ) - panel.categories->setSelection( index ); - filter( panel ); - panel.scroll->getVerticalScrollBar()->setValue( 0, false ); + clock.getElapsedTime().toString() ); } void SettingsPanel::create( Scope scope ) { - auto& panel = state( scope ); + auto& state = this->state( scope ); UIWindow::StyleConfig config{ UI_WIN_DEFAULT_FLAGS | UI_WIN_MAXIMIZE_BUTTON | UI_WIN_MODAL }; - panel.window = UIWindow::NewOpt( UIWindow::SIMPLE_LAYOUT, config ); - panel.window->setId( scope == Scope::User ? "settings_panel" : "project_settings_panel" ); - panel.window->setTitle( scope == Scope::User + state.window = UIWindow::NewOpt( UIWindow::SIMPLE_LAYOUT, config ); + state.window->setId( scope == Scope::User ? "settings_panel" : "project_settings_panel" ); + state.window->setTitle( scope == Scope::User ? mApp->i18n( "settings", "Settings" ) : mApp->i18n( "project_settings", "Project Settings" ) ); const auto sceneSize = mApp->getUISceneNode()->getPixelsSize(); - panel.window->setPixelsSize( { eeclamp( sceneSize.getWidth() * 0.82f, 720.f, 1200.f ), + state.window->setPixelsSize( { eeclamp( sceneSize.getWidth() * 0.82f, 720.f, 1200.f ), eeclamp( sceneSize.getHeight() * 0.82f, 520.f, 850.f ) } ); - panel.window->setMinWindowSize( 640, 440 ); - panel.window->setKeyBindingCommand( "closeWindow", [window = panel.window, this] { + state.window->setMinWindowSize( 640, 440 ); + state.window->setKeyBindingCommand( "closeWindow", [window = state.window, this] { if ( !SceneManager::instance()->isShuttingDown() ) { window->closeWindow(); if ( mApp->getSplitter() && mApp->getSplitter()->getCurWidget() ) mApp->getSplitter()->getCurWidget()->setFocus(); } } ); - panel.window->getKeyBindings().addKeybind( { KEY_ESCAPE }, "closeWindow" ); - auto* layout = mApp->getUISceneNode()->loadLayoutFromString( SETTINGS_PANEL_LAYOUT, - panel.window->getContainer() ); - panel.search = layout->find( "settings_filter" ); - panel.categories = layout->find( "settings_categories" ); - panel.settings = layout->find( "settings_rows" ); - panel.pageTitle = layout->find( "settings_page_title" ); - panel.scroll = layout->find( "settings_scroll" ); - panel.scroll->setVerticalScrollMode( ScrollBarMode::Auto ); - panel.scroll->setHorizontalScrollMode( ScrollBarMode::AlwaysOff ); - disableTabFocusTree( panel.categories->getVerticalScrollBar() ); - disableTabFocusTree( panel.categories->getHorizontalScrollBar() ); - disableTabFocusTree( panel.scroll->getVerticalScrollBar() ); - disableTabFocusTree( panel.scroll->getHorizontalScrollBar() ); - panel.window->setKeyBindingCommand( "focusSettingsFilter", [&panel] { - panel.search->setFocus(); - panel.search->getDocument().selectAll(); - } ); - panel.window->setKeyBindingCommand( "focusSettingsCategories", [&panel] { - auto selected = panel.categories->getSelection().first(); - if ( selected.isValid() ) - panel.categories->setSelection( selected ); - panel.categories->setFocus(); - } ); - panel.window->getKeyBindings().addKeybind( { KEY_F, KeyMod::getDefaultModifier() }, + state.window->getKeyBindings().addKeybind( { KEY_ESCAPE }, "closeWindow" ); + state.panel = UISettingsPanel::New( state.window->getContainer() ); + state.panel->setSearchResultsText( mApp->i18n( "search_results", "Search Results" ) ); + state.window->setKeyBindingCommand( "focusSettingsFilter", + [panel = state.panel] { panel->focusSearch(); } ); + state.window->setKeyBindingCommand( "focusSettingsCategories", + [panel = state.panel] { panel->focusCategories(); } ); + state.window->getKeyBindings().addKeybind( { KEY_F, KeyMod::getDefaultModifier() }, "focusSettingsFilter" ); - panel.window->getKeyBindings().addKeybind( + state.window->getKeyBindings().addKeybind( { KEY_E, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "focusSettingsCategories" ); - panel.settings->beginAttributesTransaction(); if ( scope == Scope::User ) { - addUserSettings( panel ); - addPluginSettings( panel ); + addUserSettings( state ); + addPluginSettings( state ); } else { - addProjectSettings( panel ); + addProjectSettings( state ); } - materializeCategory( panel, panel.selectedCategory ); - setupCategories( panel ); - panel.settings->endAttributesTransaction(); - filter( panel ); - panel.connections += - panel.search->connect( Event::OnTextChanged, [this, &panel]( const Event* ) { - String query = panel.search->getText(); - query.trim(); - const UintPtr debounceTag = reinterpret_cast( &panel ); - if ( query.size() < 2 ) { - panel.search->removeActionsByTag( debounceTag ); - filter( panel ); - return; - } - panel.search->debounce( [this, &panel] { filter( panel ); }, Milliseconds( 150 ), - debounceTag ); - } ); - panel.connections += panel.window->connect( Event::OnWindowReady, [&panel]( const Event* ) { - auto title = panel.categoryTitles.find( panel.selectedCategory ); - if ( title != panel.categoryTitles.end() ) { - auto index = panel.categories->findRowWithText( - title->second.toUtf8(), true, UIAbstractView::FindRowWithTextMatchKind::Equals ); - if ( index.isValid() ) - panel.categories->setSelection( index ); - } - panel.search->runOnMainThread( [search = panel.search] { search->setFocus(); } ); - } ); - panel.connections += - panel.window->connect( Event::OnWindowClose, [this, &panel, scope]( const Event* ) { - for ( const auto& document : panel.documents ) { + state.panel->build(); + state.connections += state.window->connect( + Event::OnWindowReady, [&state]( const Event* ) { state.panel->focusSearch(); } ); + state.connections += + state.window->connect( Event::OnWindowClose, [this, &state, scope]( const Event* ) { + for ( const auto& document : state.documents ) { if ( !document->save() ) Log::error( "Could not save settings document: %s", document->path() ); } @@ -478,368 +89,84 @@ void SettingsPanel::create( Scope scope ) { mApp->saveConfig(); else mApp->saveProject(); - panel.reset(); + state.reset(); } ); - panel.window->center(); - panel.window->showWhenReady(); - panel.search->setFocus(); + state.window->center(); + state.window->showWhenReady(); + state.panel->focusSearch(); } -void SettingsPanel::addCategory( PanelState& panel, const std::string& id, const String& parent, - const String& name ) { - panel.model.addCategory( { id, parent, name } ); - const auto parentText = parent.toUtf8(); - const auto nameText = name.toUtf8(); - auto parentItems = - std::find_if( panel.categoryItems.begin(), panel.categoryItems.end(), - [&parentText]( const auto& item ) { return item.first == parentText; } ); - if ( parentItems == panel.categoryItems.end() ) { - panel.categoryItems.emplace_back( parentText, std::vector{ nameText } ); - } else { - parentItems->second.emplace_back( nameText ); - } - panel.categoryIds[parentText + '/' + nameText] = id; - panel.categorySearchText[id] = parent + " " + name; - panel.categoryTitles[id] = name; - auto* section = mApp->getUISceneNode()->loadLayoutFromString( SETTINGS_CATEGORY_HEADING_LAYOUT, - panel.settings ); - auto* heading = section->find( "settings_category_heading" ); - heading->setText( name ); - heading->setId( "settings_category_" + id ); - panel.categoryHeadings[id] = heading; - panel.categorySections[id] = section; - panel.categoryContainers[id] = section->find( "settings_category_rows" ); - if ( panel.selectedCategory.empty() ) - panel.selectedCategory = id; +void SettingsPanel::selectCategory( PanelState& state, const std::string& category ) { + state.panel->selectCategory( category ); } -void SettingsPanel::addSubcategoryHeading( PanelState& panel, const std::string& category, - const String& name ) { - panel.model.addGroup( { category, name, panel.model.settings().size() } ); +void SettingsPanel::addCategory( PanelState& state, std::string id, String parent, String name ) { + state.panel->addCategory( std::move( id ), std::move( parent ), std::move( name ) ); } -void SettingsPanel::setupCategories( PanelState& panel ) { - auto model = SettingsCategoryModel::create( panel.categoryItems, panel.categoryIds ); - panel.categoryModel = model; - panel.categories->setHeadersVisible( false ); - panel.categories->setAutoExpandOnSingleColumn( true ); - panel.categories->setFocusOnSelection( true ); - panel.categories->setModel( model ); - panel.categories->expandAll(); - panel.connections += - panel.categories->connect( Event::OnSelectionChanged, [this, &panel]( const Event* ) { - auto index = panel.categories->getSelection().first(); - if ( !index.isValid() ) - return; - auto* node = static_cast( index.internalData() ); - if ( !node || node->id.empty() ) - return; - panel.selectedCategory = node->id; - panel.pageTitle->setText( node->text ); - filter( panel ); - panel.scroll->getVerticalScrollBar()->setValue( 0, false ); - } ); +void SettingsPanel::addSubcategoryHeading( PanelState& state, std::string category, String name ) { + state.panel->addGroup( std::move( category ), std::move( name ) ); } -UIWidget* SettingsPanel::createRow( PanelState& panel, SettingDefinition& setting, - SettingView& view, pugi::xml_node layout ) { - auto& binding = setting.descriptor; - auto container = panel.categoryContainers.find( binding.category ); - eeASSERT( container != panel.categoryContainers.end() ); - auto* row = mApp->getUISceneNode()->loadLayoutNodes( layout, container->second, 0 ); - row->setId( "setting_" + binding.id ); - row->find( "setting_name" )->setText( binding.name ); - auto* description = row->find( "setting_description" ); - description->setText( binding.description ); - view.row = row; - return row; -} - -void SettingsPanel::addBool( PanelState& panel, SettingDescriptor binding, bool* value, +void SettingsPanel::addBool( PanelState& state, SettingDescriptor binding, bool* value, std::function apply ) { - panel.model.addSetting( - { std::move( binding ), BoolPointerSetting{ value, std::move( apply ) } } ); + state.panel->addBool( std::move( binding ), value, std::move( apply ) ); } -void SettingsPanel::addBool( PanelState& panel, SettingDescriptor binding, +void SettingsPanel::addBool( PanelState& state, SettingDescriptor binding, std::function get, std::function set ) { - panel.model.addSetting( - { std::move( binding ), BoolSetting{ std::move( get ), std::move( set ) } } ); + state.panel->addBool( std::move( binding ), std::move( get ), std::move( set ) ); } -UICheckBox* SettingsPanel::createBoolControl( PanelState& panel, SettingDefinition& setting, - SettingView& view ) { - auto* row = createRow( panel, setting, view, SETTINGS_BOOL_ROW_LAYOUT.root() ); - row->addClass( "settings_boolean_option" ); - auto* check = row->find( "setting_control_widget" ); - auto toggle = [check]( const Event* event ) { - if ( event->asMouseEvent()->getFlags() & EE_BUTTON_LMASK ) - check->setChecked( !check->isChecked() ); - }; - panel.connections += - row->find( "setting_name" )->connect( Event::MouseClick, toggle ); - panel.connections += - row->find( "setting_description" )->connect( Event::MouseClick, toggle ); - panel.connections += row->find( "setting_info" ) - ->connect( Event::MouseClick, std::move( toggle ) ); - return check; -} - -void SettingsPanel::addChoice( PanelState& panel, SettingDescriptor binding, +void SettingsPanel::addChoice( PanelState& state, SettingDescriptor binding, const std::vector& choices, std::function get, std::function set, std::vector choiceDescriptions ) { - panel.model.addSetting( - { std::move( binding ), ChoiceSetting{ choices, std::move( choiceDescriptions ), - std::move( get ), std::move( set ) } } ); + state.panel->addChoice( std::move( binding ), choices, std::move( get ), std::move( set ), + std::move( choiceDescriptions ) ); } -void SettingsPanel::addEditableChoice( PanelState& panel, SettingDescriptor binding, +void SettingsPanel::addEditableChoice( PanelState& state, SettingDescriptor binding, const std::vector& choices, std::function get, std::function set ) { - panel.model.addSetting( - { std::move( binding ), - EditableChoiceSetting{ choices, std::move( get ), std::move( set ) } } ); + state.panel->addEditableChoice( std::move( binding ), choices, std::move( get ), + std::move( set ) ); } -void SettingsPanel::addInteger( PanelState& panel, SettingDescriptor binding, int min, int max, +void SettingsPanel::addInteger( PanelState& state, SettingDescriptor binding, int min, int max, std::function get, std::function set ) { - panel.model.addSetting( - { std::move( binding ), IntegerSetting{ min, max, std::move( get ), std::move( set ) } } ); + state.panel->addInteger( std::move( binding ), min, max, std::move( get ), std::move( set ) ); } -void SettingsPanel::addText( PanelState& panel, SettingDescriptor binding, +void SettingsPanel::addText( PanelState& state, SettingDescriptor binding, std::function get, std::function set, bool commitOnFocusLoss ) { - panel.model.addSetting( { std::move( binding ), TextSetting{ std::move( get ), std::move( set ), - commitOnFocusLoss } } ); + state.panel->addText( std::move( binding ), std::move( get ), std::move( set ), + commitOnFocusLoss ); } -void SettingsPanel::addFloat( PanelState& panel, SettingDescriptor binding, double min, double max, +void SettingsPanel::addFloat( PanelState& state, SettingDescriptor binding, double min, double max, double step, std::function get, std::function set ) { - panel.model.addSetting( { std::move( binding ), FloatSetting{ min, max, step, std::move( get ), - std::move( set ) } } ); + state.panel->addFloat( std::move( binding ), min, max, step, std::move( get ), + std::move( set ) ); } -void SettingsPanel::addAction( PanelState& panel, SettingDescriptor binding, +void SettingsPanel::addAction( PanelState& state, SettingDescriptor binding, const String& buttonText, std::function action ) { - panel.model.addSetting( - { std::move( binding ), ActionSetting{ buttonText, std::move( action ) } } ); + state.panel->addAction( std::move( binding ), buttonText, std::move( action ) ); } -void SettingsPanel::refreshTextSetting( PanelState& panel, const std::string& id ) { - const auto& settings = panel.model.settings(); - for ( size_t i = 0; i < settings.size(); ++i ) { - if ( settings[i].descriptor.id != id || i >= panel.settingViews.size() || - !panel.settingViews[i].row ) - continue; - auto* value = std::get_if( &settings[i].value ); - auto* input = panel.settingViews[i].row->find( "setting_control_widget" ); - if ( value && input ) - input->setText( String::fromUtf8( value->get() ) ); - return; - } +void SettingsPanel::refreshTextSetting( PanelState& state, const std::string& id ) { + state.panel->refreshTextSetting( id ); } -static void setNodeTreeEnabled( Node* node, bool enabled ); - -void SettingsPanel::materializeCategory( PanelState& panel, const std::string& category ) { - if ( category.empty() || panel.materializedCategories.contains( category ) ) - return; - auto container = panel.categoryContainers.find( category ); - if ( container == panel.categoryContainers.end() ) - return; - auto& settings = panel.model.settings(); - panel.settingViews.resize( settings.size() ); - container->second->beginAttributesTransaction(); - for ( size_t i = 0; i < settings.size(); ++i ) { - auto& setting = settings[i]; - if ( setting.descriptor.category != category ) - continue; - for ( const auto& group : panel.model.groups() ) { - if ( group.category != category || group.beforeSetting != i ) - continue; - auto* heading = - mApp->getUISceneNode() - ->loadLayoutFromString( SETTINGS_SUBCATEGORY_HEADING_LAYOUT, container->second ) - ->asType(); - heading->setText( group.name ); - panel.subcategoryHeadings.push_back( { group.category, group.name, heading } ); - } - auto& view = panel.settingViews[i]; - if ( auto* value = std::get_if( &setting.value ) ) { - auto* check = createBoolControl( panel, setting, view ); - auto binding = UIDataBind::New( value->value, check, - UIValueConverter::converterBool() ); - binding->onValueChangeCb = value->apply; - panel.bindingGroup += std::move( binding ); - } else if ( auto* value = std::get_if( &setting.value ) ) { - auto* check = createBoolControl( panel, setting, view ); - check->setChecked( value->get() ); - panel.connections += - check->connect( Event::OnValueChange, [check, value]( const Event* ) { - value->set( check->isChecked() ); - } ); - } else if ( auto* value = std::get_if( &setting.value ) ) { - auto* row = createRow( panel, setting, view, SETTINGS_CHOICE_ROW_LAYOUT.root() ); - auto* dropDown = row->find( "setting_control_widget" ); - auto model = ItemListOwnerModel::create( value->choices ); - dropDown->setModel( model ); - const size_t selected = value->get(); - if ( selected < value->choices.size() ) { - dropDown->getListView()->getSelection().set( model->index( selected, 0 ) ); - dropDown->setText( value->choices[selected] ); - } - if ( selected < value->descriptions.size() ) - dropDown->setTooltipText( value->descriptions[selected] ); - panel.connections += - dropDown->connect( Event::OnValueChange, [dropDown, value]( const Event* ) { - if ( dropDown->getListView()->getSelection().isEmpty() ) - return; - const size_t selected = dropDown->getListView()->getSelection().first().row(); - if ( selected < value->descriptions.size() ) - dropDown->setTooltipText( value->descriptions[selected] ); - value->set( selected ); - } ); - } else if ( auto* value = std::get_if( &setting.value ) ) { - auto* row = - createRow( panel, setting, view, SETTINGS_EDITABLE_CHOICE_ROW_LAYOUT.root() ); - auto* combo = row->find( "setting_control_widget" ); - for ( const auto& choice : value->choices ) - combo->getListBox()->addListBoxItem( choice ); - combo->setText( value->get() ); - panel.connections += - combo->connect( Event::OnValueChange, [combo, value]( const Event* ) { - if ( !value->set( combo->getText() ) ) { - combo->addClass( "error" ); - combo->getDropDownList()->addClass( "error" ); - return; - } - combo->removeClass( "error" ); - combo->getDropDownList()->removeClass( "error" ); - } ); - } else if ( auto* value = std::get_if( &setting.value ) ) { - auto* row = createRow( panel, setting, view, SETTINGS_INTEGER_ROW_LAYOUT.root() ); - auto* spin = row->find( "setting_control_widget" ); - spin->setMinValue( value->min )->setMaxValue( value->max ); - spin->unsetTabFocusable(); - spin->getButtonPushUp()->asType()->unsetTabFocusable(); - spin->getButtonPushDown()->asType()->unsetTabFocusable(); - spin->setValue( value->get() ); - panel.connections += - spin->connect( Event::OnValueChange, [spin, value]( const Event* ) { - value->set( static_cast( spin->getValue() ) ); - } ); - } else if ( auto* value = std::get_if( &setting.value ) ) { - auto* row = createRow( panel, setting, view, SETTINGS_TEXT_ROW_LAYOUT.root() ); - auto* input = row->find( "setting_control_widget" ); - if ( value->password ) - input->setMode( UITextInput::TextInputMode::Password ); - input->setText( String::fromUtf8( value->get() ) ); - auto commit = [input, value] { - if ( !value->set( input->getText().toUtf8() ) ) { - input->addClass( "error" ); - return; - } - input->removeClass( "error" ); - }; - if ( value->commitOnFocusLoss ) { - const auto debounceTag = reinterpret_cast( input ); - panel.connections += input->connect( - Event::OnTextChanged, [input, commit, debounceTag]( const Event* ) { - input->debounce( commit, Milliseconds( 500 ), debounceTag ); - } ); - auto flush = [input, commit, debounceTag]( const Event* ) { - input->removeActionsByTag( debounceTag ); - commit(); - }; - panel.connections += input->connect( Event::OnPressEnter, flush ); - panel.connections += input->connect( Event::OnFocusLoss, std::move( flush ) ); - } else { - panel.connections += - input->connect( Event::OnTextChanged, - [commit = std::move( commit )]( const Event* ) { commit(); } ); - } - } else if ( auto* value = std::get_if( &setting.value ) ) { - auto* row = createRow( panel, setting, view, SETTINGS_INTEGER_ROW_LAYOUT.root() ); - auto* spin = row->find( "setting_control_widget" ); - spin->setMinValue( value->min )->setMaxValue( value->max )->setClickStep( value->step ); - spin->allowFloatingPoint( true )->setValue( value->get() ); - spin->unsetTabFocusable(); - spin->getButtonPushUp()->asType()->unsetTabFocusable(); - spin->getButtonPushDown()->asType()->unsetTabFocusable(); - panel.connections += - spin->connect( Event::OnValueChange, - [spin, value]( const Event* ) { value->set( spin->getValue() ); } ); - } else if ( auto* value = std::get_if( &setting.value ) ) { - auto* row = createRow( panel, setting, view, SETTINGS_ACTION_ROW_LAYOUT.root() ); - auto* button = row->find( "setting_control_widget" ); - button->setText( value->buttonText ); - panel.connections += button->connect( Event::MouseClick, [value]( const Event* event ) { - if ( event->asMouseEvent()->getFlags() & EE_BUTTON_LMASK ) - value->action(); - } ); - } - if ( view.row && !setting.enabled ) - setNodeTreeEnabled( view.row, false ); - } - container->second->endAttributesTransaction(); - panel.materializedCategories.insert( category ); -} - -void SettingsPanel::materializeVisibleSettings( PanelState& panel, const String& query ) { - const std::string queryUtf8 = query.toUtf8(); - const bool aggregate = String::endsWith( panel.selectedCategory, ".*" ); - const std::string aggregatePrefix = - aggregate ? panel.selectedCategory.substr( 0, panel.selectedCategory.size() - 1 ) - : std::string{}; - for ( const auto& category : panel.model.categories() ) { - bool materialize = - query.empty() ? category.id == panel.selectedCategory || - ( aggregate && String::startsWith( category.id, aggregatePrefix ) ) - : String::icontains( category.parent, query ) || - String::icontains( category.name, query ); - if ( !materialize && !query.empty() ) { - for ( const auto& setting : panel.model.settings() ) { - const auto& descriptor = setting.descriptor; - if ( descriptor.category == category.id && - ( String::icontains( descriptor.name, query ) || - String::icontains( descriptor.description, query ) || - String::icontains( descriptor.group, query ) || - String::icontains( descriptor.id, queryUtf8 ) ) ) { - materialize = true; - break; - } - } - } - if ( materialize ) - materializeCategory( panel, category.id ); - } -} - -static void setNodeTreeEnabled( Node* node, bool enabled ) { - node->setEnabled( enabled ); - for ( Node* child = node->getFirstChild(); child; child = child->getNextNode() ) - setNodeTreeEnabled( child, enabled ); -} - -void SettingsPanel::setCategoryEnabled( PanelState& panel, const std::string& category, +void SettingsPanel::setCategoryEnabled( PanelState& state, const std::string& category, bool enabled, const std::string& excludedSetting ) { - auto& settings = panel.model.settings(); - for ( size_t i = 0; i < settings.size(); ++i ) { - auto& setting = settings[i]; - const auto& descriptor = setting.descriptor; - if ( descriptor.category != category || descriptor.id == excludedSetting ) - continue; - setting.enabled = enabled; - if ( i < panel.settingViews.size() && panel.settingViews[i].row ) - setNodeTreeEnabled( panel.settingViews[i].row, enabled ); - } + state.panel->setCategoryEnabled( category, enabled, excludedSetting ); } - void SettingsPanel::addUserSettings( PanelState& panel ) { addCategory( panel, "general.behavior", mApp->i18n( "general", "General" ), mApp->i18n( "behavior", "Behavior" ) ); @@ -1819,18 +1146,34 @@ void SettingsPanel::addUserSettings( PanelState& panel ) { "Vsync configuration changed.\nRestart ecode to see the changes." ) .unescape() ); } ); - addInteger( + const String monitorRefreshRate = mApp->i18n( "monitor_refresh_rate", "Monitor Refresh Rate" ); + const String unlimitedFrameRate = mApp->i18n( "unlimited", "Unlimited" ); + addEditableChoice( panel, { "frameRateLimit", "window.renderer", mApp->i18n( "frame_rate_limit", "Frame Rate Limit" ), - mApp->i18n( "frame_rate_limit_desc", - "Limit rendered frames per second. Set 0 to disable." ) }, - 0, 1000, [this] { return mApp->getConfig().context.FrameRateLimit; }, - [this]( int value ) { + mApp->i18n( "frame_rate_limit_desc", "Limit rendered frames per second, follow the " + "monitor refresh rate, or disable the limit." ) }, + { monitorRefreshRate, unlimitedFrameRate, "30", "60", "75", "120", "144", "165", "240" }, + [this, monitorRefreshRate, unlimitedFrameRate] { + const auto value = mApp->getConfig().context.FrameRateLimit; + return value == ContextSettings::FrameRateLimitScreenRefreshRate ? monitorRefreshRate + : value == 0 ? unlimitedFrameRate + : String( String::toString( value ) ); + }, + [this, monitorRefreshRate, unlimitedFrameRate]( const String& selection ) { + Int32 value; + if ( selection == monitorRefreshRate ) + value = ContextSettings::FrameRateLimitScreenRefreshRate; + else if ( selection == unlimitedFrameRate ) + value = 0; + else if ( !String::fromString( value, selection ) || value < 0 || value > 1000 ) + return false; mApp->getConfig().context.FrameRateLimit = value; mApp->saveConfig(); mApp->getWindow()->setFrameRateLimit( value ); mApp->getNotificationCenter()->addNotification( mApp->i18n( "frame_rate_limit_applied", "Frame Rate Limit Applied" ) ); + return true; } ); std::vector rendererVersions = Renderer::getAvailableGraphicsLibraryVersions(); @@ -2096,7 +1439,7 @@ void SettingsPanel::addPluginSettings( PanelState& panel ) { } const std::string category = "plugins." + plugin->getId(); addCategory( panel, category, mApp->i18n( "plugins", "Plugins" ), plugin->getTitle() ); - SettingsPage page( panel.model, document, category, plugin->getId() ); + SettingsPage page( panel.panel->getModel(), document, category, plugin->getId() ); plugin->registerSettings( page ); const std::string path = document->path(); addAction( panel, @@ -2256,87 +1599,4 @@ void SettingsPanel::addProjectSettings( PanelState& panel ) { } ); } -void SettingsPanel::filter( PanelState& panel ) { - String query = panel.search ? panel.search->getText() : String{}; - query.trim().toLower(); - if ( query.size() < 2 ) - query.clear(); - materializeVisibleSettings( panel, query ); - if ( !query.empty() ) { - panel.pageTitle->setText( mApp->i18n( "search_results", "Search Results" ) ); - } else if ( auto title = panel.categoryTitles.find( panel.selectedCategory ); - title != panel.categoryTitles.end() ) { - panel.pageTitle->setText( title->second ); - } - UnorderedSet matchingCategories; - const std::string queryUtf8 = query.toUtf8(); - const bool aggregate = String::endsWith( panel.selectedCategory, ".*" ); - const std::string aggregatePrefix = - aggregate ? panel.selectedCategory.substr( 0, panel.selectedCategory.size() - 1 ) - : std::string{}; - if ( !query.empty() ) { - for ( const auto& setting : panel.model.settings() ) { - const auto& binding = setting.descriptor; - if ( String::icontains( binding.name, query ) || - String::icontains( binding.description, query ) || - String::icontains( binding.group, query ) || - String::icontains( binding.id, queryUtf8 ) ) - matchingCategories.insert( binding.category ); - } - } - if ( queryUtf8 != panel.categoryFilter ) { - panel.categoryFilter = queryUtf8; - if ( auto model = std::static_pointer_cast( panel.categoryModel ) ) - model->filter( queryUtf8, matchingCategories ); - } - panel.categories->expandAll(); - for ( auto& [category, heading] : panel.categoryHeadings ) - heading->setVisible( query.empty() && aggregate && - String::startsWith( category, aggregatePrefix ) ); - const auto& settings = panel.model.settings(); - for ( size_t i = 0; i < settings.size(); ++i ) { - const auto& binding = settings[i].descriptor; - const auto categoryName = panel.categorySearchText.find( binding.category ); - const bool categoryMatches = categoryName != panel.categorySearchText.end() && - String::icontains( categoryName->second, query ); - const bool matches = - query.empty() - ? binding.category == panel.selectedCategory || - ( aggregate && String::startsWith( binding.category, aggregatePrefix ) ) - : categoryMatches || String::icontains( binding.name, query ) || - String::icontains( binding.description, query ) || - String::icontains( binding.group, query ) || - String::icontains( binding.id, queryUtf8 ); - if ( i < panel.settingViews.size() && panel.settingViews[i].row ) - panel.settingViews[i].row->setVisible( matches ); - } - for ( const auto& category : panel.model.categories() ) { - auto section = panel.categorySections.find( category.id ); - if ( section == panel.categorySections.end() ) - continue; - bool visible = false; - for ( size_t i = 0; i < settings.size(); ++i ) { - if ( settings[i].descriptor.category == category.id && i < panel.settingViews.size() && - panel.settingViews[i].row && panel.settingViews[i].row->isVisible() ) { - visible = true; - break; - } - } - section->second->setVisible( visible ); - } - for ( auto& subcategory : panel.subcategoryHeadings ) { - if ( !subcategory.heading ) - continue; - const bool hasVisibleSetting = std::any_of( - settings.begin(), settings.end(), [&panel, &subcategory]( const auto& setting ) { - const auto& binding = setting.descriptor; - const size_t index = &setting - panel.model.settings().data(); - return binding.category == subcategory.category && - binding.group == subcategory.name && index < panel.settingViews.size() && - panel.settingViews[index].row && panel.settingViews[index].row->isVisible(); - } ); - subcategory.heading->setVisible( hasVisibleSetting ); - } -} - } // namespace ecode diff --git a/src/tools/ecode/settingspanel.hpp b/src/tools/ecode/settingspanel.hpp index 8463030a1..cdfdebac4 100644 --- a/src/tools/ecode/settingspanel.hpp +++ b/src/tools/ecode/settingspanel.hpp @@ -1,11 +1,9 @@ #ifndef ECODE_SETTINGSPANEL_HPP #define ECODE_SETTINGSPANEL_HPP -#include "settingsmodel.hpp" #include #include -#include -#include +#include #include namespace ecode { @@ -22,39 +20,11 @@ class SettingsPanel { void show( Scope scope, const std::string& category = {} ); protected: - struct SettingView { - UIWidget* row{ nullptr }; - }; - struct SubcategoryHeading { - std::string category; - String name; - UITextView* heading{ nullptr }; - }; - struct PanelState { UIWindow* window{ nullptr }; + UISettingsPanel* panel{ nullptr }; EventConnectionList connections; - UITextInput* search{ nullptr }; - UITreeView* categories{ nullptr }; - UIScrollView* scroll{ nullptr }; - UILinearLayout* settings{ nullptr }; - UITextView* pageTitle{ nullptr }; - std::shared_ptr categoryModel; - UIBindingGroup bindingGroup; - SettingsModel model; std::vector> documents; - std::vector>> categoryItems; - UnorderedMap categoryIds; - UnorderedMap categorySearchText; - UnorderedMap categoryTitles; - UnorderedMap categoryHeadings; - UnorderedMap categorySections; - UnorderedMap categoryContainers; - UnorderedSet materializedCategories; - std::vector subcategoryHeadings; - std::vector settingViews; - std::string selectedCategory; - std::string categoryFilter; void reset(); }; @@ -76,13 +46,9 @@ class SettingsPanel { void addProjectSettings( PanelState& state ); - void addCategory( PanelState& state, const std::string& id, const String& parent, - const String& name ); + void addCategory( PanelState& state, std::string id, String parent, String name ); - void addSubcategoryHeading( PanelState& state, const std::string& category, - const String& name ); - - void setupCategories( PanelState& state ); + void addSubcategoryHeading( PanelState& state, std::string category, String name ); void addBool( PanelState& state, SettingDescriptor binding, bool* value, std::function apply = {} ); @@ -110,22 +76,11 @@ class SettingsPanel { void addAction( PanelState& state, SettingDescriptor binding, const String& buttonText, std::function action ); + void refreshTextSetting( PanelState& state, const std::string& id ); - UIWidget* createRow( PanelState& state, SettingDefinition& setting, SettingView& view, - pugi::xml_node layout ); - - UICheckBox* createBoolControl( PanelState& state, SettingDefinition& setting, - SettingView& view ); - - void materializeCategory( PanelState& state, const std::string& category ); - - void materializeVisibleSettings( PanelState& state, const String& query ); - void setCategoryEnabled( PanelState& state, const std::string& category, bool enabled, const std::string& excludedSetting = {} ); - - void filter( PanelState& state ); }; } // namespace ecode diff --git a/src/tools/eterm/appconfig.cpp b/src/tools/eterm/appconfig.cpp new file mode 100644 index 000000000..085fcdace --- /dev/null +++ b/src/tools/eterm/appconfig.cpp @@ -0,0 +1,165 @@ +#include "appconfig.hpp" +#include +#include + +using namespace EE::Graphics; +using namespace EE::System; +using namespace EE::UI; +using namespace eterm::Terminal; +using EE::eemax; + +namespace eterm { + +AppConfig::AppConfig( std::string configPath ) : mConfigPath( std::move( configPath ) ) { + FileSystem::dirAddSlashAtEnd( mConfigPath ); + mIni.path( mConfigPath + "eterm.ini" ); + mState.path( mConfigPath + "eterm.state.ini" ); +} + +void AppConfig::load() { + mIni.readFile(); + mState.readFile(); + terminal.shell = mIni.getValue( "terminal", "shell", terminal.shell ); + terminal.shellArguments = + mIni.getValue( "terminal", "shell_arguments", terminal.shellArguments ); + terminal.workingDirectory = + mIni.getValue( "terminal", "working_directory", terminal.workingDirectory ); + terminal.executeInShell = + mIni.getValue( "terminal", "execute_in_shell", terminal.executeInShell ); + terminal.historySize = mIni.getValueU( "terminal", "scrollback", terminal.historySize ); + terminal.cursorStyle = TerminalCursorHelper::modeFromString( mIni.getValue( + "terminal", "cursor_style", TerminalCursorHelper::modeToString( terminal.cursorStyle ) ) ); + terminal.useFrameBuffer = mIni.getValueB( "terminal", "framebuffer", terminal.useFrameBuffer ); + terminal.closeOnExit = mIni.getValueB( "terminal", "close_on_exit", terminal.closeOnExit ); + terminal.initialTabs = mIni.getValueU( "terminal", "initial_tabs", terminal.initialTabs ); + terminal.exclusiveMode = mIni.getValueB( "terminal", "exclusive_mode", terminal.exclusiveMode ); + const auto newTerminalBehavior = + mIni.getValue( "terminal", "new_terminal_behavior", "current" ); + terminal.newTerminalBehavior = + newTerminalBehavior == "vertical" ? NewTerminalBehavior::VerticalSplit + : newTerminalBehavior == "horizontal" ? NewTerminalBehavior::HorizontalSplit + : NewTerminalBehavior::CurrentTabBar; + terminal.scrollBarType = mIni.getValue( "terminal", "scrollbar_type", "overlay" ) == "outside" + ? ScrollViewType::Outside + : ScrollViewType::Overlay; + const auto scrollBarMode = mIni.getValue( "terminal", "scrollbar_mode", "auto" ); + terminal.scrollBarMode = scrollBarMode == "always_on" ? ScrollBarMode::AlwaysOn + : scrollBarMode == "always_off" ? ScrollBarMode::AlwaysOff + : ScrollBarMode::Auto; + font.path = mIni.getValue( "font", "path", font.path ); + font.fallbackPath = mIni.getValue( "font", "fallback_path", font.fallbackPath ); + font.size = mIni.getValueF( "font", "size", font.size ); + font.hinting = Font::fontHintingFromString( + mIni.getValue( "font", "hinting", Font::fontHintingToString( font.hinting ) ) ); + font.antialiasing = Font::fontAntialiasingFromString( mIni.getValue( + "font", "antialiasing", Font::fontAntialiasingToString( font.antialiasing ) ) ); + font.uiPath = mIni.getValue( "font", "ui_path", font.uiPath ); + font.uiSize = mIni.getValueF( "font", "ui_size", font.uiSize ); + + window.pixelDensity = mIni.getValueF( "window", "pixel_density", window.pixelDensity ); + window.maxFPS = mIni.getValueU( "window", "frame_rate_limit", window.maxFPS ); + window.vsync = mIni.getValueB( "window", "vsync", window.vsync ); + window.benchmarkMode = mIni.getValueB( "window", "benchmark_mode", window.benchmarkMode ); + window.warnBeforeClose = + mIni.getValueB( "window", "warn_before_closing", window.warnBeforeClose ); + window.alwaysShowTabBar = + mIni.getValueB( "window", "always_show_tab_bar", window.alwaysShowTabBar ); + window.rendererVersion = Renderer::glVersionFromString( + mIni.getValue( "window", "renderer_version", + Renderer::graphicsLibraryVersionToString( window.rendererVersion ) ) ); + window.multisamples = mIni.getValueU( "window", "multisamples", window.multisamples ); + theme.colorScheme = mIni.getValue( "theme", "color_scheme", theme.colorScheme ); + theme.uiColorScheme = ColorSchemePreferences::fromStringExt( mIni.getValue( + "theme", "ui_color_scheme", ColorSchemePreferences::toString( theme.uiColorScheme ) ) ); + + windowState.size.setWidth( mState.getValueI( "window", "width", windowState.size.getWidth() ) ); + windowState.size.setHeight( + mState.getValueI( "window", "height", windowState.size.getHeight() ) ); + windowState.position.x = mState.getValueI( "window", "x", windowState.position.x ); + windowState.position.y = mState.getValueI( "window", "y", windowState.position.y ); + windowState.displayIndex = + mState.getValueI( "window", "display_index", windowState.displayIndex ); + windowState.maximized = mState.getValueB( "window", "maximized", windowState.maximized ); +} + +bool AppConfig::save() { + if ( !FileSystem::isDirectory( mConfigPath ) && !FileSystem::makeDir( mConfigPath, true ) ) + return false; + return savePreferences() && saveWindowState(); +} + +bool AppConfig::savePreferences() { + if ( !FileSystem::isDirectory( mConfigPath ) && !FileSystem::makeDir( mConfigPath, true ) ) + return false; + mIni.deleteValue( "theme", "language" ); + mIni.deleteValue( "window", "max_fps" ); + mIni.setValue( "terminal", "shell", terminal.shell ); + mIni.setValue( "terminal", "shell_arguments", terminal.shellArguments ); + mIni.setValue( "terminal", "working_directory", terminal.workingDirectory ); + mIni.setValue( "terminal", "execute_in_shell", terminal.executeInShell ); + mIni.setValueU( "terminal", "scrollback", terminal.historySize ); + mIni.setValue( "terminal", "cursor_style", + TerminalCursorHelper::modeToString( terminal.cursorStyle ) ); + mIni.setValueB( "terminal", "framebuffer", terminal.useFrameBuffer ); + mIni.setValueB( "terminal", "close_on_exit", terminal.closeOnExit ); + mIni.setValueU( "terminal", "initial_tabs", terminal.initialTabs ); + mIni.setValueB( "terminal", "exclusive_mode", terminal.exclusiveMode ); + mIni.setValue( "terminal", "new_terminal_behavior", + terminal.newTerminalBehavior == NewTerminalBehavior::VerticalSplit ? "vertical" + : terminal.newTerminalBehavior == NewTerminalBehavior::HorizontalSplit + ? "horizontal" + : "current" ); + mIni.setValue( "terminal", "scrollbar_type", + terminal.scrollBarType == ScrollViewType::Overlay ? "overlay" : "outside" ); + mIni.setValue( "terminal", "scrollbar_mode", + terminal.scrollBarMode == ScrollBarMode::AlwaysOn ? "always_on" + : terminal.scrollBarMode == ScrollBarMode::AlwaysOff ? "always_off" + : "auto" ); + mIni.setValue( "font", "path", font.path ); + mIni.setValue( "font", "fallback_path", font.fallbackPath ); + mIni.setValueF( "font", "size", font.size ); + mIni.setValue( "font", "hinting", Font::fontHintingToString( font.hinting ) ); + mIni.setValue( "font", "antialiasing", Font::fontAntialiasingToString( font.antialiasing ) ); + mIni.setValue( "font", "ui_path", font.uiPath ); + mIni.setValueF( "font", "ui_size", font.uiSize ); + mIni.setValueF( "window", "pixel_density", window.pixelDensity ); + mIni.setValueU( "window", "frame_rate_limit", window.maxFPS ); + mIni.setValueB( "window", "vsync", window.vsync ); + mIni.setValueB( "window", "benchmark_mode", window.benchmarkMode ); + mIni.setValueB( "window", "warn_before_closing", window.warnBeforeClose ); + mIni.setValueB( "window", "always_show_tab_bar", window.alwaysShowTabBar ); + mIni.setValue( "window", "renderer_version", + Renderer::graphicsLibraryVersionToString( window.rendererVersion ) ); + mIni.setValueU( "window", "multisamples", window.multisamples ); + mIni.setValue( "theme", "color_scheme", theme.colorScheme ); + mIni.setValue( "theme", "ui_color_scheme", + ColorSchemePreferences::toString( theme.uiColorScheme ) ); + return mIni.writeFile(); +} + +bool AppConfig::saveWindowState() { + if ( !FileSystem::isDirectory( mConfigPath ) && !FileSystem::makeDir( mConfigPath, true ) ) + return false; + mState.setValueI( "window", "width", windowState.size.getWidth() ); + mState.setValueI( "window", "height", windowState.size.getHeight() ); + mState.setValueI( "window", "x", windowState.position.x ); + mState.setValueI( "window", "y", windowState.position.y ); + mState.setValueI( "window", "display_index", windowState.displayIndex ); + mState.setValueB( "window", "maximized", windowState.maximized ); + return mState.writeFile(); +} + +void AppConfig::captureWindowState( EE::Window::Window* window ) { + if ( !window ) + return; + windowState.size = Sys::getPlatformType() == Sys::PlatformType::macOS + ? window->getSizeInScreenCoordinates() + : window->getLastWindowedSizeInScreenCoordinates(); + windowState.position = window->getPosition(); + windowState.position.x = eemax( 0, windowState.position.x ); + windowState.position.y = eemax( 0, windowState.position.y ); + windowState.displayIndex = window->getCurrentDisplayIndex(); + windowState.maximized = window->isMaximized(); +} + +} // namespace eterm diff --git a/src/tools/eterm/appconfig.hpp b/src/tools/eterm/appconfig.hpp new file mode 100644 index 000000000..cc2df5461 --- /dev/null +++ b/src/tools/eterm/appconfig.hpp @@ -0,0 +1,103 @@ +#ifndef ETERM_CONFIG_HPP +#define ETERM_CONFIG_HPP + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace eterm { + +using namespace EE; +using namespace EE::Graphics; +using namespace EE::Math; +using namespace EE::System; +using namespace EE::UI; + +enum class NewTerminalBehavior { CurrentTabBar, VerticalSplit, HorizontalSplit }; + +struct TerminalConfig { + std::string shell; + std::string shellArguments; + std::string workingDirectory; + std::string executeInShell; + size_t historySize{ 10000 }; + size_t initialTabs{ 1 }; + Terminal::TerminalCursorMode cursorStyle{ Terminal::TerminalCursorMode::SteadyUnderline }; + bool useFrameBuffer{ false }; + bool closeOnExit{ false }; + bool exclusiveMode{ false }; + NewTerminalBehavior newTerminalBehavior{ NewTerminalBehavior::CurrentTabBar }; + ScrollViewType scrollBarType{ ScrollViewType::Overlay }; + ScrollBarMode scrollBarMode{ ScrollBarMode::Auto }; +}; + +struct FontConfig { + std::string path; + std::string fallbackPath; + FontHinting hinting{ FontHinting::Full }; + FontAntialiasing antialiasing{ FontAntialiasing::Grayscale }; + Float size{ 11 }; + std::string uiPath; + Float uiSize{ 11 }; +}; + +struct WindowConfig { + Float pixelDensity{ 0 }; + Uint32 maxFPS{ static_cast( ContextSettings::FrameRateLimitScreenRefreshRate ) }; + bool vsync{ false }; + bool benchmarkMode{ false }; + bool warnBeforeClose{ false }; + bool alwaysShowTabBar{ false }; + GraphicsLibraryVersion rendererVersion{ GLv_default }; + Uint32 multisamples{ 0 }; +}; + +struct ThemeConfig { + std::string colorScheme; + ColorSchemeExtPreference uiColorScheme{ ColorSchemeExtPreference::System }; +}; + +struct WindowStateConfig { + Sizei size{ 1280, 720 }; + Vector2i position{ -1, -1 }; + int displayIndex{ 0 }; + bool maximized{ false }; +}; + +struct AppConfig { + TerminalConfig terminal; + FontConfig font; + WindowConfig window; + ThemeConfig theme; + WindowStateConfig windowState; + + explicit AppConfig( std::string configPath ); + + void load(); + + bool save(); + + bool savePreferences(); + + bool saveWindowState(); + + void captureWindowState( EE::Window::Window* window ); + + const std::string& getConfigPath() const { return mConfigPath; } + + private: + std::string mConfigPath; + IniFile mIni; + IniFile mState; +}; + +} // namespace eterm + +#endif diff --git a/src/tools/eterm/eterm.cpp b/src/tools/eterm/eterm.cpp index 3bd8b7e5f..3a6722794 100644 --- a/src/tools/eterm/eterm.cpp +++ b/src/tools/eterm/eterm.cpp @@ -1,116 +1,21 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include +#include "eterm.hpp" +#include "settingspanel.hpp" -#include #include -#include -#include -#include -using namespace EE; -using namespace EE::Graphics; -using namespace EE::Scene; -using namespace EE::System; -using namespace EE::UI; -using namespace EE::UI::Tools; -using namespace EE::Window; -using namespace eterm::Terminal; -using namespace eterm::UI; +namespace eterm { -namespace { - -struct TerminalLaunchConfig { - std::string program; - std::vector arguments; - std::string workingDirectory; - std::string executeInShell; - size_t historySize{ 10000 }; - TerminalCursorMode cursorStyle{ TerminalCursorMode::SteadyUnderline }; - FontHinting fontHinting{ FontHinting::Full }; - FontAntialiasing fontAntialiasing{ FontAntialiasing::Grayscale }; - bool useFrameBuffer{ false }; - bool keepAlive{ true }; - bool closeOnExit{ false }; -}; - -class EtermApp { - public: - int run( int argc, char* argv[] ); - - class TerminalSplitterClient : public UITabWidgetSplitter::Client { - public: - explicit TerminalSplitterClient( EtermApp& app ) : mApp( app ) {} - - void onTabCreated( UITab* tab, UIWidget* ) override; - void onWidgetFocusChange( UIWidget* ) override; - - private: - EtermApp& mApp; - }; - - private: - std::string getResourcePath() const; - String i18n( const std::string& key, const String& defaultValue ) const; - void loadColorSchemes( const std::string& resPath ); - static UITerminal* terminalFromTab( UITab* tab ); - void updateWindowTitle(); - bool hasTerminals() const; - static bool hasRunningChildren( UITab* tab ); - void closeTab( UITab* tab ); - void queueExitCloseTab( UITab* tab ); - void queueExitedTabs(); - void requestCloseTab( UITab* tab ); - void renameSession( UITerminal* terminal ); - void maximizeTabWidget( UITabWidget* tabWidget ); - void restoreMaximizedTabWidget(); - void configureTab( UITab* tab ); - UITerminal* createTerminal( UITabWidget* target = nullptr ); - UITerminal* createTerminalSplit( SplitDirection direction, UITerminal* terminal ); - void addTabKeyBindings( UITerminal* terminal ); - bool closeWindow( EE::Window::Window* ); - - EE::Window::Window* appWindow{ nullptr }; - UISceneNode* scene{ nullptr }; - UILinearLayout* mainLayout{ nullptr }; - UITabWidgetSplitter* tabSplitter{ nullptr }; - FontTrueType* terminalFont{ nullptr }; - UIIcon* terminalIcon{ nullptr }; - UIMessageBox* closeDialog{ nullptr }; - UIWidget* closeDialogWidget{ nullptr }; - UIWindow* maximizedTabWidgetWindow{ nullptr }; - UITabWidget* maximizedTabWidget{ nullptr }; - UINodeLink* maximizedTabWidgetLink{ nullptr }; - TerminalLaunchConfig terminalConfig; - std::map terminalColorSchemes; - const TerminalColorScheme* selectedColorScheme{ nullptr }; - Float terminalFontSize{ 12 }; - bool warnBeforeClose{ false }; - bool closeApproved{ false }; - bool benchmarkMode{ false }; - Clock secondsCounter; - SmallVector pendingExitCloseTabs; - TerminalSplitterClient splitterClient{ *this }; -}; - -void EtermApp::TerminalSplitterClient::onTabCreated( UITab* tab, UIWidget* ) { +void App::TerminalSplitterClient::onTabCreated( UITab* tab, UIWidget* ) { if ( mApp.terminalIcon ) tab->setIcon( mApp.terminalIcon->createDrawable( PixelDensity::dpToPxI( 12 ) ) ); mApp.configureTab( tab ); } -void EtermApp::TerminalSplitterClient::onWidgetFocusChange( UIWidget* ) { +void App::TerminalSplitterClient::onWidgetFocusChange( UIWidget* ) { mApp.updateWindowTitle(); } -std::string EtermApp::getResourcePath() const { +std::string App::getResourcePath() const { std::string resPath = Sys::getProcessPath(); #if EE_PLATFORM == EE_PLATFORM_MACOS if ( String::contains( resPath, "ecode.app" ) ) { @@ -130,11 +35,11 @@ std::string EtermApp::getResourcePath() const { return resPath; } -String EtermApp::i18n( const std::string& key, const String& defaultValue ) const { +String App::i18n( const std::string& key, const String& defaultValue ) const { return scene ? scene->i18n( key, defaultValue ) : defaultValue; } -void EtermApp::loadColorSchemes( const std::string& resPath ) { +void App::loadColorSchemes( const std::string& resPath ) { auto colorSchemes = TerminalColorScheme::loadFromFile( resPath + "colorschemes/terminalcolorschemes.conf" ); const std::string configColorSchemesPath = @@ -153,13 +58,106 @@ void EtermApp::loadColorSchemes( const std::string& resPath ) { } } -UITerminal* EtermApp::terminalFromTab( UITab* tab ) { +UITerminal* App::terminalFromTab( UITab* tab ) { return tab && tab->getOwnedWidget() && tab->getOwnedWidget()->isType( UI_TYPE_TERMINAL ) ? tab->getOwnedWidget()->asType() : nullptr; } -void EtermApp::updateWindowTitle() { +void App::savePreferences() { + if ( config && !config->savePreferences() ) + Log::error( "Could not save eterm configuration to %s", config->getConfigPath() ); +} + +void App::forEachTerminal( const std::function& fn ) { + if ( !tabSplitter ) + return; + tabSplitter->forEachWidgetType( + UI_TYPE_TERMINAL, [&fn]( UIWidget* widget ) { fn( widget->asType() ); } ); +} + +void App::createNewTerminal() { + auto* current = tabSplitter ? tabSplitter->getCurWidget() : nullptr; + auto* terminal = + current && current->isType( UI_TYPE_TERMINAL ) ? current->asType() : nullptr; + switch ( config->terminal.newTerminalBehavior ) { + case NewTerminalBehavior::VerticalSplit: + createTerminalSplit( SplitDirection::Right, terminal ); + break; + case NewTerminalBehavior::HorizontalSplit: + createTerminalSplit( SplitDirection::Bottom, terminal ); + break; + default: + createTerminal(); + break; + } +} + +void App::openFontPicker( bool uiFont, bool fallbackFont ) { + const Uint32 flags = UIFontPickerDialog::ShowStyle | + ( fallbackFont ? 0 : UIFontPickerDialog::ShowSize ) | + ( !uiFont && !fallbackFont ? UIFontPickerDialog::MonospaceOnly : 0 ); + auto* dialog = UIFontPickerDialog::New( flags ); + dialog->setTitle( i18n( "select_font", "Select Font" ) ); + dialog->setCloseShortcut( KEY_ESCAPE ); + std::string currentPath = uiFont ? config->font.uiPath + : fallbackFont ? config->font.fallbackPath + : config->font.path; + if ( !currentPath.empty() ) + dialog->setSelectedFont( currentPath ); + if ( !fallbackFont ) { + auto selection = dialog->getSelection(); + selection.size = static_cast( uiFont ? config->font.uiSize : config->font.size ); + dialog->setSelection( selection ); + } + dialog->setOnFontPicked( [this, uiFont, fallbackFont]( const UIFontSelection& selection ) { + if ( selection.font.path.empty() ) + return; + auto& resourceScope = *scene->getResourceScope(); + if ( uiFont ) { + auto font = FontTrueType::New( "eterm-ui-font", resourceScope ); + if ( font->loadFromFile( selection.font.path ) ) { + config->font.uiPath = selection.font.path; + config->font.uiSize = selection.size; + scene->getUIThemeManager()->setDefaultFont( font.get() ); + scene->getUIThemeManager()->setDefaultFontSize( config->font.uiSize ); + scene->getRoot()->reloadStyle( true, true, true, true, true ); + } + } else if ( fallbackFont ) { + auto font = FontTrueType::New( "eterm-fallback-font", resourceScope ); + if ( font->loadFromFile( selection.font.path ) ) { + config->font.fallbackPath = selection.font.path; + resourceScope.getFontService().addFallbackFont( std::move( font ) ); + } + } else { + auto font = FontTrueType::New( "eterm-monospace", resourceScope ); + if ( font->loadFromFile( selection.font.path ) ) { + config->font.path = selection.font.path; + config->font.size = selection.size; + terminalFont = font.get(); + terminalFontSize = PixelDensity::dpToPx( config->font.size ); + FontFamily::loadFromRegular( terminalFont ); + forEachTerminal( [this]( UITerminal* terminal ) { + terminal->setFont( terminalFont ); + terminal->setFontSize( terminalFontSize ); + } ); + } + } + savePreferences(); + } ); + dialog->show(); +} + +void App::showSettings() { + if ( settingsWindow ) { + settingsWindow->show(); + settingsWindow->toFront(); + return; + } + settingsWindow = eterm::SettingsPanel::create( *this ); +} + +void App::updateWindowTitle() { if ( !appWindow ) return; String title{ "eterm" }; @@ -181,7 +179,7 @@ void EtermApp::updateWindowTitle() { appWindow->setTitle( title ); } -bool EtermApp::hasTerminals() const { +bool App::hasTerminals() const { bool found = false; if ( tabSplitter ) tabSplitter->forEachWidgetStoppable( [&found]( UIWidget* ) { @@ -191,27 +189,27 @@ bool EtermApp::hasTerminals() const { return found; } -bool EtermApp::hasRunningChildren( UITab* tab ) { +bool App::hasRunningChildren( UITab* tab ) { auto* terminal = terminalFromTab( tab ); return terminal && terminal->getTerm() && Sys::processHasChildren( terminal->getTerm()->getProcessId() ); } -void EtermApp::closeTab( UITab* tab ) { +void App::closeTab( UITab* tab ) { if ( !tabSplitter || !tab || !tab->getOwnedWidget() ) return; tabSplitter->closeTab( tab->getOwnedWidget()->asType(), UITabWidget::FocusTabBehavior::Default ); } -void EtermApp::queueExitCloseTab( UITab* tab ) { +void App::queueExitCloseTab( UITab* tab ) { if ( tab && std::find( pendingExitCloseTabs.begin(), pendingExitCloseTabs.end(), tab ) == pendingExitCloseTabs.end() ) { pendingExitCloseTabs.emplace_back( tab ); } } -void EtermApp::queueExitedTabs() { +void App::queueExitedTabs() { if ( !terminalConfig.closeOnExit ) return; tabSplitter->forEachTab( [this]( UITab* tab ) { @@ -225,7 +223,7 @@ void EtermApp::queueExitedTabs() { } ); } -void EtermApp::requestCloseTab( UITab* tab ) { +void App::requestCloseTab( UITab* tab ) { if ( !warnBeforeClose || !hasRunningChildren( tab ) ) { closeTab( tab ); return; @@ -250,7 +248,7 @@ void EtermApp::requestCloseTab( UITab* tab ) { closeDialog->showWhenReady(); } -void EtermApp::renameSession( UITerminal* terminal ) { +void App::renameSession( UITerminal* terminal ) { if ( !terminal ) return; auto* msgBox = @@ -266,7 +264,7 @@ void EtermApp::renameSession( UITerminal* terminal ) { msgBox->showWhenReady(); } -void EtermApp::maximizeTabWidget( UITabWidget* tabWidget ) { +void App::maximizeTabWidget( UITabWidget* tabWidget ) { if ( !tabWidget || scene->getRoot()->hasChild( "detached_tab_widget_win" ) ) return; @@ -284,7 +282,7 @@ void EtermApp::maximizeTabWidget( UITabWidget* tabWidget ) { win->setKeyBindingCommand( "close-maximized-tab-widget", [this] { restoreMaximizedTabWidget(); } ); win->getKeyBindings().addKeybind( { KEY_ESCAPE }, "close-maximized-tab-widget" ); - win->setCheckEphemeralCloseFn( [this]( Node* focusNode ) { + win->setCheckEphemeralCloseFn( []( Node* focusNode ) { if ( focusNode->isType( UI_TYPE_POPUPMENU ) ) return false; if ( focusNode->getSceneNode()->isUISceneNode() ) { @@ -335,7 +333,7 @@ void EtermApp::maximizeTabWidget( UITabWidget* tabWidget ) { } ); } -void EtermApp::restoreMaximizedTabWidget() { +void App::restoreMaximizedTabWidget() { if ( !scene || !SceneManager::isActive() ) return; if ( !maximizedTabWidgetLink || !maximizedTabWidget ) @@ -359,7 +357,7 @@ void EtermApp::restoreMaximizedTabWidget() { win->close(); } -void EtermApp::configureTab( UITab* tab ) { +void App::configureTab( UITab* tab ) { tab->on( Event::OnCreateContextMenu, [this]( const Event* event ) { const auto* menuEvent = static_cast( event ); auto* menu = menuEvent->getMenu(); @@ -392,6 +390,7 @@ void EtermApp::configureTab( UITab* tab ) { terminal->getExclusiveMode() ); exclusiveMode->setId( UITerminal::getExclusiveModeToggleCommandName() ); addItem( i18n( "rename_session", "Rename Session" ), "", "terminal-rename" ); + addItem( i18n( "settings", "Settings" ), "settings", "open-settings" ); menu->addSeparator(); const bool canMove = clickedTab->getTabWidget()->getTabCount() > 1; @@ -427,6 +426,8 @@ void EtermApp::configureTab( UITab* tab ) { maximizeTabWidget( clickedTab->getTabWidget() ); } else if ( command == "restore-maximized-tab-widget" ) { restoreMaximizedTabWidget(); + } else if ( command == "open-settings" ) { + showSettings(); } else { terminal->execute( command ); } @@ -437,7 +438,7 @@ void EtermApp::configureTab( UITab* tab ) { } ); } -UITerminal* EtermApp::createTerminal( UITabWidget* target ) { +UITerminal* App::createTerminal( UITabWidget* target ) { if ( !target && tabSplitter ) { auto* current = tabSplitter->getCurWidget(); target = current ? tabSplitter->tabWidgetFromWidget( current ) @@ -464,6 +465,9 @@ UITerminal* EtermApp::createTerminal( UITabWidget* target ) { terminal->getTerm()->setCursorMode( terminalConfig.cursorStyle ); terminal->getTerm()->setFontHinting( terminalConfig.fontHinting ); terminal->getTerm()->setFontAntialiasing( terminalConfig.fontAntialiasing ); + terminal->setExclusiveMode( config->terminal.exclusiveMode ); + terminal->setScrollViewType( config->terminal.scrollBarType ); + terminal->setVerticalScrollMode( config->terminal.scrollBarMode ); if ( selectedColorScheme ) terminal->setColorScheme( *selectedColorScheme ); @@ -481,6 +485,30 @@ UITerminal* EtermApp::createTerminal( UITabWidget* target ) { if ( terminalConfig.closeOnExit && event.type == TerminalDisplay::EventType::PROCESS_EXIT ) queueExitCloseTab( tab ); } ); + terminal->on( Event::OnCreateContextMenu, [this, terminal]( const Event* event ) { + auto menu = static_cast( event )->getMenu(); + const auto addItem = [this, menu]( const String& text, const std::string& icon, + const std::string& command ) { + DrawablePtr drawable; + if ( auto* menuIcon = scene->findIcon( icon ) ) + drawable = menuIcon->createDrawable( PixelDensity::dpToPxI( 12 ) ); + auto* item = menu->add( text, std::move( drawable ) ); + item->setId( command ); + return item; + }; + menu->addSeparator(); + addItem( i18n( "settings", "Settings" ), "settings", "open-settings" ); + menu->on( Event::OnItemClicked, [this, terminal]( const Event* itemEvent ) { + if ( !itemEvent->getNode()->isType( UI_TYPE_MENUITEM ) ) + return; + const auto& command = itemEvent->getNode()->getId(); + auto* previous = tabSplitter->getCurWidget(); + tabSplitter->setCurrentWidget( terminal ); + terminal->execute( command ); + if ( previous && tabSplitter->ownedWidgetExists( previous ) ) + tabSplitter->setCurrentWidget( previous ); + } ); + } ); tabSplitter->setCurrentWidget( terminal ); if ( !terminalConfig.executeInShell.empty() ) terminal->executeFile( terminalConfig.executeInShell ); @@ -489,15 +517,16 @@ UITerminal* EtermApp::createTerminal( UITabWidget* target ) { return terminal; } -UITerminal* EtermApp::createTerminalSplit( SplitDirection direction, UITerminal* terminal ) { +UITerminal* App::createTerminalSplit( SplitDirection direction, UITerminal* terminal ) { auto* source = terminal ? tabSplitter->tabWidgetFromWidget( terminal ) : nullptr; auto* target = source ? tabSplitter->splitTabWidget( direction, source ) : nullptr; return target ? createTerminal( target ) : nullptr; } -void EtermApp::addTabKeyBindings( UITerminal* terminal ) { +void App::addTabKeyBindings( UITerminal* terminal ) { tabSplitter->registerSplitterCommands( *terminal ); - terminal->setCommand( "create-new-terminal", [this] { createTerminal(); } ); + terminal->setCommand( "create-new-terminal", [this] { createNewTerminal(); } ); + terminal->setCommand( "open-settings", [this] { showSettings(); } ); terminal->setCommand( "debug-widget-tree-view", [this] { UIWidgetInspector::create( scene ); } ); terminal->setCommand( "terminal-rename", [this, terminal] { renameSession( terminal ); } ); @@ -528,6 +557,7 @@ void EtermApp::addTabKeyBindings( UITerminal* terminal ) { [this, terminal] { tabSplitter->switchNextSplit( terminal ); } ); terminal->addKeyBinding( { KEY_T, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "create-new-terminal" ); + terminal->addKeyBinding( { KEY_COMMA, KeyMod::getDefaultModifier() }, "open-settings" ); terminal->addKeyBinding( { KEY_W, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "close-tab" ); terminal->addKeyBinding( { KEY_F11, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "debug-widget-tree-view" ); @@ -551,7 +581,7 @@ void EtermApp::addTabKeyBindings( UITerminal* terminal ) { "switch-to-next-split" ); } -bool EtermApp::closeWindow( EE::Window::Window* ) { +bool App::closeWindow( EE::Window::Window* ) { if ( closeApproved || !warnBeforeClose ) return true; bool running = false; @@ -578,25 +608,30 @@ bool EtermApp::closeWindow( EE::Window::Window* ) { return false; } -int EtermApp::run( int argc, char* argv[] ) { +int App::run( int argc, char* argv[] ) { #ifdef EE_DEBUG Log::instance()->setLogToStdOut( !Runtime::isOffscreen() ); Log::instance()->setLiveWrite( true ); #endif + config = std::make_unique( Sys::getConfigPath( "eterm" ) ); + config->load(); args::ArgumentParser parser( "eterm" ); args::HelpFlag help( parser, "help", "Display this help menu", { 'h', "help" } ); args::ValueFlag shell( parser, "shell", "Shell name or path", { 's', "shell" }, - "" ); + config->terminal.shell ); args::ValueFlag shellArgs( parser, "shell-args", "Shell command line arguments", - { "shell-args" }, "" ); + { "shell-args" }, config->terminal.shellArguments ); args::ValueFlag historySize( parser, "scrollback", "Maximum history size (lines)", - { 'l', "scrollback" }, 10000 ); + { 'l', "scrollback" }, config->terminal.historySize ); args::Flag fb( parser, "framebuffer", "Use frame buffer (more memory usage, less CPU usage)", { "fb", "framebuffer" } ); - args::ValueFlag fontPath( parser, "fontpath", "Font path", { 'f', "font" } ); + args::ValueFlag fontPath( parser, "fontpath", "Font path", { 'f', "font" }, + config->font.path ); args::ValueFlag fallbackFontPath( parser, "fallback-fontpath", - "Fallback Font path", { "fallback-font" } ); - args::ValueFlag fontSize( parser, "fontsize", "Font size (in dp)", { "fontsize" }, 11 ); + "Fallback Font path", { "fallback-font" }, + config->font.fallbackPath ); + args::ValueFlag fontSize( parser, "fontsize", "Font size (in dp)", { "fontsize" }, + config->font.size ); const std::unordered_map fontHintingMap{ { "none", FontHinting::None }, { "slight", FontHinting::Slight }, @@ -604,7 +639,7 @@ int EtermApp::run( int argc, char* argv[] ) { }; args::MapFlag fontHinting( parser, "font-hinting", "Font hinting mode (accepted values: none, slight, full)", - { "font-hinting" }, fontHintingMap, FontHinting::Full ); + { "font-hinting" }, fontHintingMap, config->font.hinting ); const std::unordered_map fontAntialiasingMap{ { "none", FontAntialiasing::None }, { "grayscale", FontAntialiasing::Grayscale }, @@ -613,33 +648,35 @@ int EtermApp::run( int argc, char* argv[] ) { args::MapFlag fontAntialiasing( parser, "font-antialiasing", "Font antialiasing mode (accepted values: none, grayscale, subpixel)", - { "font-antialiasing" }, fontAntialiasingMap, FontAntialiasing::Grayscale ); - args::ValueFlag width( parser, "winwidth", "Window width (in dp)", { "width" }, 1280 ); + { "font-antialiasing" }, fontAntialiasingMap, config->font.antialiasing ); + args::ValueFlag width( parser, "winwidth", "Window width (in dp)", { "width" }, + config->windowState.size.getWidth() ); args::ValueFlag height( parser, "winheight", "Window height (in dp)", { "height" }, - 720 ); + config->windowState.size.getHeight() ); args::ValueFlag pixelDensity( parser, "pixel-density", "Set default application pixel density", { 'd', "pixel-density" } ); args::Positional wd( parser, "wording-dir", "Working Directory / executable" ); args::Flag closeOnExit( parser, "close-on-exit", "close the application when the executable exits", { 'c', "close" } ); - args::ValueFlag executeInShell( - parser, "execute-in-shell", "execute program in shell", { 'e', "execute" }, "" ); + args::ValueFlag executeInShell( parser, "execute-in-shell", + "execute program in shell", { 'e', "execute" }, + config->terminal.executeInShell ); args::Flag vsync( parser, "vsync", "Enable vsync", { "vsync" } ); args::ValueFlag colorScheme( parser, "color-scheme", "Load color scheme", - { "color-scheme" }, "" ); + { "color-scheme" }, config->theme.colorScheme ); args::Flag listColorSchemes( parser, "color-schemes", "Lists color schemes", { "list-color-schemes" } ); args::ValueFlag maxFPS( parser, "max-fps", "Maximum rendering frames per second of the terminal. Default " "value will be the refresh rate of the screen.", - { "max-fps" }, 0 ); + { "max-fps" }, config->window.maxFPS ); args::MapFlag cursorStyle( parser, "cursor-style", "Sets the cursor-style (accepted values: blinking_block, steady_block, blink_underline, " "steady_underline, blink_bar, steady_bar)", { "cursor-style" }, TerminalCursorHelper::getTerminalCursorModeMap(), - TerminalCursorMode::SteadyUnderline ); + config->terminal.cursorStyle ); args::Flag benchmarkModeFlag( parser, "benchmark-mode", "Render as much as possible to measure the rendering performance.", { "benchmark-mode" } ); @@ -651,7 +688,7 @@ int EtermApp::run( int argc, char* argv[] ) { "Always show the tab bar, even with a single tab.", { "always-show-tab-bar" } ); args::ValueFlag initialTabs( parser, "tabs", "Number of initial terminal tabs", - { "tabs" }, 1 ); + { "tabs" }, config->terminal.initialTabs ); try { parser.ParseCLI( argc, argv ); @@ -667,56 +704,105 @@ int EtermApp::run( int argc, char* argv[] ) { std::cerr << parser; return EXIT_FAILURE; } + if ( !config->savePreferences() ) + Log::error( "Could not save eterm configuration to %s", config->getConfigPath() ); + + config->terminal.shell = shell.Get(); + config->terminal.shellArguments = shellArgs.Get(); + config->terminal.historySize = historySize.Get(); + config->font.path = fontPath.Get(); + config->font.fallbackPath = fallbackFontPath.Get(); + config->font.size = fontSize.Get(); + config->font.hinting = fontHinting.Get(); + config->font.antialiasing = fontAntialiasing.Get(); + config->windowState.size = { static_cast( width.Get() ), + static_cast( height.Get() ) }; + if ( pixelDensity ) + config->window.pixelDensity = pixelDensity.Get(); + if ( wd ) + config->terminal.workingDirectory = wd.Get(); + config->terminal.executeInShell = executeInShell.Get(); + config->theme.colorScheme = colorScheme.Get(); + config->window.maxFPS = maxFPS.Get(); + config->terminal.cursorStyle = cursorStyle.Get(); + config->terminal.initialTabs = initialTabs.Get(); + config->terminal.useFrameBuffer |= fb.Get(); + config->terminal.closeOnExit |= closeOnExit.Get(); + config->window.vsync |= vsync.Get(); + config->window.benchmarkMode |= benchmarkModeFlag.Get(); + config->window.warnBeforeClose |= warnBeforeCloseFlag.Get(); + config->window.alwaysShowTabBar |= alwaysShowTabBar.Get(); const std::string initialWorkingDirectory = FileSystem::getCurrentWorkingDirectory(); const std::string resPath = getResourcePath(); - if ( listColorSchemes.Get() || colorScheme ) - loadColorSchemes( resPath ); + loadColorSchemes( resPath ); if ( listColorSchemes.Get() ) { std::cout << "Color schemes:\n"; for ( const auto& colorSchemeEntry : terminalColorSchemes ) std::cout << "\t" << colorSchemeEntry.first << "\n"; return EXIT_SUCCESS; } - if ( colorScheme ) { - auto colorSchemeIt = terminalColorSchemes.find( colorScheme.Get() ); + if ( !config->theme.colorScheme.empty() ) { + auto colorSchemeIt = terminalColorSchemes.find( config->theme.colorScheme ); if ( colorSchemeIt != terminalColorSchemes.end() ) selectedColorScheme = &colorSchemeIt->second; } DisplayManager* displayManager = Engine::instance()->getDisplayManager(); - Display* currentDisplay = displayManager->getDisplayIndex( 0 ); + Display* currentDisplay = displayManager->getDisplayIndex( + config->windowState.displayIndex >= 0 && + config->windowState.displayIndex < displayManager->getDisplayCount() + ? config->windowState.displayIndex + : 0 ); if ( !currentDisplay ) { std::cerr << "Display not found, exiting" << std::endl; return EXIT_FAILURE; } - Sizei windowSize( width.Get(), height.Get() ); + Sizei windowSize( config->windowState.size ); const auto displaySize = currentDisplay->getUsableBounds().getSize(); if ( displaySize.getWidth() > 0 && windowSize.getWidth() >= displaySize.getWidth() ) windowSize.setWidth( static_cast( displaySize.getWidth() * 0.8f ) ); if ( displaySize.getHeight() > 0 && windowSize.getHeight() >= displaySize.getHeight() ) windowSize.setHeight( static_cast( displaySize.getHeight() * 0.75f ) ); + FontTrueTypePtr uiFont; + if ( !config->font.uiPath.empty() && FileSystem::fileExists( config->font.uiPath ) ) { + uiFont = FontTrueType::New( "eterm-ui-font" ); + if ( !uiFont->loadFromFile( config->font.uiPath ) ) + uiFont.reset(); + } UIApplication::Settings appSettings; appSettings.basePath = FileSystem::removeLastFolderFromPath( resPath ); - appSettings.pixelDensity = - pixelDensity ? pixelDensity.Get() : currentDisplay->getPixelDensity(); - appSettings.fontHinting = fontHinting.Get(); - appSettings.fontAntialiasing = fontAntialiasing.Get(); + appSettings.pixelDensity = config->window.pixelDensity > 0 ? config->window.pixelDensity + : currentDisplay->getPixelDensity(); + appSettings.fontHinting = config->font.hinting; + appSettings.fontAntialiasing = config->font.antialiasing; + appSettings.baseFont = uiFont.get(); const Int32 frameRateLimit = - benchmarkModeFlag.Get() - ? 0 - : static_cast( maxFPS.Get() ? maxFPS.Get() : currentDisplay->getRefreshRate() ); - UIApplication app( WindowSettings( windowSize.getWidth(), windowSize.getHeight(), "eterm", - WindowStyle::Default, WindowBackend::Default, 32, - resPath + "icon/eterm.png", - appSettings.pixelDensity.value() ), - appSettings, ContextSettings( vsync.Get(), frameRateLimit ) ); + config->window.benchmarkMode ? 0 : static_cast( config->window.maxFPS ); + UIApplication app( + WindowSettings( windowSize.getWidth(), windowSize.getHeight(), "eterm", + WindowStyle::Default, WindowBackend::Default, 32, + resPath + "icon/eterm.png", appSettings.pixelDensity.value() ), + appSettings, + ContextSettings( config->window.vsync, frameRateLimit, config->window.multisamples, + config->window.rendererVersion ) ); appWindow = app.getWindow(); scene = app.getUI(); if ( !appWindow || !appWindow->isOpen() || !scene ) return EXIT_FAILURE; + scene->setColorSchemePreference( config->theme.uiColorScheme ); + scene->getUIThemeManager()->setDefaultFontSize( config->font.uiSize ); + if ( config->windowState.position != Vector2i( -1, -1 ) ) { + appWindow->setPosition( config->windowState.position.x + + ( config->windowState.maximized ? -1 : 0 ), + config->windowState.position.y ); + } +#if EE_PLATFORM != EE_PLATFORM_EMSCRIPTEN && EE_PLATFORM != EE_PLATFORM_MACOS + if ( config->windowState.maximized ) + appWindow->maximize(); +#endif FileSystem::changeWorkingDirectory( initialWorkingDirectory ); appWindow->setClearColor( RGB( 0, 0, 0 ) ); scene->getUIThemeManager()->setDefaultEffectsEnabled( false ); @@ -748,9 +834,9 @@ int EtermApp::run( int argc, char* argv[] ) { "eterm", remixIconFont.get(), noniconsFont.get(), codIconFont.get() ) ); terminalIcon = scene->findIcon( "terminal" ); } - if ( fontPath && FileSystem::fileExists( fontPath.Get() ) ) { + if ( !config->font.path.empty() && FileSystem::fileExists( config->font.path ) ) { terminalFont = FontTrueType::New( "eterm-monospace", resourceScope ).get(); - if ( terminalFont->loadFromFile( fontPath.Get() ) ) + if ( terminalFont->loadFromFile( config->font.path ) ) FontFamily::loadFromRegular( terminalFont ); else terminalFont = nullptr; @@ -764,34 +850,37 @@ int EtermApp::run( int argc, char* argv[] ) { FontFamily::loadFromRegular( terminalFont, "DejaVuSansMono" ); } - if ( fallbackFontPath ) { - if ( FileSystem::fileExists( fallbackFontPath.Get() ) ) { + if ( !config->font.fallbackPath.empty() ) { + if ( FileSystem::fileExists( config->font.fallbackPath ) ) { auto fallback = FontTrueType::New( "eterm-fallback-font", resourceScope ); - if ( fallback->loadFromFile( fallbackFontPath.Get() ) ) + if ( fallback->loadFromFile( config->font.fallbackPath ) ) resourceScope.getFontService().addFallbackFont( std::move( fallback ) ); } } else if ( auto fallback = resourceScope.findFont( "DroidSansFallbackFull" ) ) { resourceScope.getFontService().addFallbackFont( std::move( fallback ) ); } - const std::string launchPath = wd ? wd.Get() : initialWorkingDirectory; + const std::string launchPath = config->terminal.workingDirectory.empty() + ? initialWorkingDirectory + : config->terminal.workingDirectory; FileInfo launchFile( launchPath ); const bool launchExecutable = launchFile.isRegularFile() && launchFile.isExecutable(); - terminalConfig.program = launchExecutable ? launchFile.getFilepath() : shell.Get(); - terminalConfig.arguments = - shellArgs ? String::split( shellArgs.Get() ) : std::vector{}; + terminalConfig.program = launchExecutable ? launchFile.getFilepath() : config->terminal.shell; + terminalConfig.arguments = config->terminal.shellArguments.empty() + ? std::vector{} + : String::split( config->terminal.shellArguments ); terminalConfig.workingDirectory = launchFile.getDirectoryPath(); - terminalConfig.executeInShell = executeInShell.Get(); - terminalConfig.historySize = historySize.Get(); - terminalConfig.cursorStyle = cursorStyle.Get(); - terminalConfig.fontHinting = fontHinting.Get(); - terminalConfig.fontAntialiasing = fontAntialiasing.Get(); - terminalConfig.useFrameBuffer = fb.Get(); - terminalConfig.keepAlive = !launchExecutable && !shell; - terminalConfig.closeOnExit = closeOnExit.Get(); - warnBeforeClose = warnBeforeCloseFlag.Get(); - benchmarkMode = benchmarkModeFlag.Get(); - terminalFontSize = PixelDensity::dpToPx( fontSize.Get() ); + terminalConfig.executeInShell = config->terminal.executeInShell; + terminalConfig.historySize = config->terminal.historySize; + terminalConfig.cursorStyle = config->terminal.cursorStyle; + terminalConfig.fontHinting = config->font.hinting; + terminalConfig.fontAntialiasing = config->font.antialiasing; + terminalConfig.useFrameBuffer = config->terminal.useFrameBuffer; + terminalConfig.keepAlive = !launchExecutable && config->terminal.shell.empty(); + terminalConfig.closeOnExit = config->terminal.closeOnExit; + warnBeforeClose = config->window.warnBeforeClose; + benchmarkMode = config->window.benchmarkMode; + terminalFontSize = PixelDensity::dpToPx( config->font.size ); mainLayout = UILinearLayout::NewVertical(); mainLayout->setParent( scene->getRoot() ); @@ -799,7 +888,7 @@ int EtermApp::run( int argc, char* argv[] ) { mainLayout->setPixelsSize( appWindow->getSize().asFloat() ); tabSplitter = UITabWidgetSplitter::New( &splitterClient, scene ); - tabSplitter->setHideTabBarOnSingleTab( !alwaysShowTabBar.Get() ); + tabSplitter->setHideTabBarOnSingleTab( !config->window.alwaysShowTabBar ); tabSplitter->setCanCreateSplitFn( [this]( SplitDirection, UIWidget* ) { restoreMaximizedTabWidget(); return true; @@ -841,7 +930,8 @@ int EtermApp::run( int argc, char* argv[] ) { } mainLayout->updateLayout(); - for ( size_t tab = 0; tab < eemax( static_cast( 1 ), initialTabs.Get() ); ++tab ) { + for ( size_t tab = 0; tab < eemax( static_cast( 1 ), config->terminal.initialTabs ); + ++tab ) { if ( !createTerminal( tabs ) ) { appWindow->showMessageBox( EE::Window::Window::MessageBoxType::Error, "eterm", @@ -878,12 +968,15 @@ int EtermApp::run( int argc, char* argv[] ) { secondsCounter.restart(); } } ); + config->captureWindowState( appWindow ); + if ( !config->saveWindowState() ) + Log::error( "Could not save eterm window state to %s", config->getConfigPath() ); return EXIT_SUCCESS; } -} // namespace +} // namespace eterm EE_MAIN_FUNC int main( int argc, char* argv[] ) { - EtermApp app; + App app; return app.run( argc, argv ); } diff --git a/src/tools/eterm/eterm.hpp b/src/tools/eterm/eterm.hpp new file mode 100644 index 000000000..41161f7e3 --- /dev/null +++ b/src/tools/eterm/eterm.hpp @@ -0,0 +1,112 @@ +#pragma once + +#include "appconfig.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace EE; +using namespace EE::Graphics; +using namespace EE::Scene; +using namespace EE::System; +using namespace EE::UI; +using namespace EE::UI::Tools; +using namespace EE::Window; +using namespace eterm::Terminal; +using namespace eterm::UI; +using namespace eterm; + +namespace eterm { + +struct TerminalLaunchConfig { + std::string program; + std::vector arguments; + std::string workingDirectory; + std::string executeInShell; + size_t historySize{ 10000 }; + TerminalCursorMode cursorStyle{ TerminalCursorMode::SteadyUnderline }; + FontHinting fontHinting{ FontHinting::Full }; + FontAntialiasing fontAntialiasing{ FontAntialiasing::Grayscale }; + bool useFrameBuffer{ false }; + bool keepAlive{ true }; + bool closeOnExit{ false }; +}; + +class App { + public: + int run( int argc, char* argv[] ); + + class TerminalSplitterClient : public UITabWidgetSplitter::Client { + public: + explicit TerminalSplitterClient( App& app ) : mApp( app ) {} + + void onTabCreated( UITab* tab, UIWidget* ) override; + void onWidgetFocusChange( UIWidget* ) override; + + private: + App& mApp; + }; + + private: + friend struct SettingsPanel; + + std::string getResourcePath() const; + String i18n( const std::string& key, const String& defaultValue ) const; + void loadColorSchemes( const std::string& resPath ); + static UITerminal* terminalFromTab( UITab* tab ); + void updateWindowTitle(); + bool hasTerminals() const; + static bool hasRunningChildren( UITab* tab ); + void closeTab( UITab* tab ); + void queueExitCloseTab( UITab* tab ); + void queueExitedTabs(); + void requestCloseTab( UITab* tab ); + void renameSession( UITerminal* terminal ); + void maximizeTabWidget( UITabWidget* tabWidget ); + void restoreMaximizedTabWidget(); + void configureTab( UITab* tab ); + UITerminal* createTerminal( UITabWidget* target = nullptr ); + UITerminal* createTerminalSplit( SplitDirection direction, UITerminal* terminal ); + void addTabKeyBindings( UITerminal* terminal ); + void showSettings(); + void openFontPicker( bool uiFont, bool fallbackFont = false ); + void forEachTerminal( const std::function& fn ); + void savePreferences(); + void createNewTerminal(); + bool closeWindow( EE::Window::Window* ); + + EE::Window::Window* appWindow{ nullptr }; + UISceneNode* scene{ nullptr }; + UILinearLayout* mainLayout{ nullptr }; + UITabWidgetSplitter* tabSplitter{ nullptr }; + FontTrueType* terminalFont{ nullptr }; + UIIcon* terminalIcon{ nullptr }; + UIMessageBox* closeDialog{ nullptr }; + UIWindow* settingsWindow{ nullptr }; + UIWidget* closeDialogWidget{ nullptr }; + UIWindow* maximizedTabWidgetWindow{ nullptr }; + UITabWidget* maximizedTabWidget{ nullptr }; + UINodeLink* maximizedTabWidgetLink{ nullptr }; + TerminalLaunchConfig terminalConfig; + std::unique_ptr config; + std::map terminalColorSchemes; + const TerminalColorScheme* selectedColorScheme{ nullptr }; + Float terminalFontSize{ 12 }; + bool warnBeforeClose{ false }; + bool closeApproved{ false }; + bool benchmarkMode{ false }; + Clock secondsCounter; + SmallVector pendingExitCloseTabs; + TerminalSplitterClient splitterClient{ *this }; +}; + +} // namespace eterm diff --git a/src/tools/eterm/settingspanel.cpp b/src/tools/eterm/settingspanel.cpp new file mode 100644 index 000000000..817bea06c --- /dev/null +++ b/src/tools/eterm/settingspanel.cpp @@ -0,0 +1,468 @@ +#include "settingspanel.hpp" +#include "eterm.hpp" + +#include +#include +#include + +#include + +using namespace EE::UI::Tools; + +namespace eterm { + +UIWindow* SettingsPanel::create( App& app ) { + UIWindow::StyleConfig windowStyle{ UI_WIN_DEFAULT_FLAGS | UI_WIN_MAXIMIZE_BUTTON | + UI_WIN_MODAL }; + auto settingsWindow = UIWindow::NewOpt( UIWindow::SIMPLE_LAYOUT, windowStyle ); + settingsWindow->setId( "settings_panel" ); + settingsWindow->setTitle( app.i18n( "settings", "Settings" ) ); + const auto sceneSize = app.scene->getPixelsSize(); + settingsWindow->setPixelsSize( { eeclamp( sceneSize.getWidth() * 0.82f, 720.f, 1200.f ), + eeclamp( sceneSize.getHeight() * 0.82f, 520.f, 850.f ) } ); + settingsWindow->setMinWindowSize( 640, 440 ); + auto* panel = UISettingsPanel::New( settingsWindow->getContainer() ); + panel->setSearchResultsText( app.i18n( "search_results", "Search Results" ) ); + + panel->addCategory( "appearance.theme", app.i18n( "appearance", "Appearance" ), + app.i18n( "theme_and_language", "Theme & Language" ) ); + panel->addChoice( + { "uiColorScheme", "appearance.theme", + app.i18n( "ui_prefes_color_scheme", "UI Prefers Color Scheme" ), + app.i18n( + "ui_prefers_color_scheme_desc", + "Choose whether the interface follows the system, light, or dark appearance." ) }, + { app.i18n( "system", "System" ), app.i18n( "light", "Light" ), + app.i18n( "dark", "Dark" ) }, + [&app] { + return app.config->theme.uiColorScheme == ColorSchemeExtPreference::System ? size_t{ 0 } + : app.config->theme.uiColorScheme == ColorSchemeExtPreference::Light + ? size_t{ 1 } + : size_t{ 2 }; + }, + [&app]( size_t selected ) { + static constexpr ColorSchemeExtPreference values[] = { ColorSchemeExtPreference::System, + ColorSchemeExtPreference::Light, + ColorSchemeExtPreference::Dark }; + app.config->theme.uiColorScheme = values[std::min( selected, size_t{ 2 } )]; + app.scene->setColorSchemePreference( app.config->theme.uiColorScheme ); + app.savePreferences(); + } ); + panel->addCategory( "appearance.fonts", app.i18n( "appearance", "Appearance" ), + app.i18n( "fonts_and_scale", "Fonts & Scale" ) ); + panel->addAction( + { "uiFont", "appearance.fonts", + app.i18n( "ui_font_and_size_ellipsis", "UI Font & Size..." ), + app.i18n( "ui_font_desc", "Choose the proportional font used by the interface." ) }, + app.i18n( "choose_font", "Choose Font..." ), [&app] { app.openFontPicker( true ); } ); + panel->addAction( + { "terminalFont", "appearance.fonts", + app.i18n( "terminal_font_and_size_ellipsis", "Terminal Font & Size..." ), + app.i18n( "terminal_font_desc", "Choose the monospace font used by terminals." ) }, + app.i18n( "choose_font", "Choose Font..." ), [&app] { app.openFontPicker( false ); } ); + panel->addAction( + { "fallbackFont", "appearance.fonts", + app.i18n( "fallback_font_ellipsis", "Fallback Font..." ), + app.i18n( "fallback_font_desc", "Choose the font used for missing glyphs." ) }, + app.i18n( "choose_font", "Choose Font..." ), + [&app] { app.openFontPicker( false, true ); } ); + panel->addFloat( + { "uiFontSize", "appearance.fonts", app.i18n( "ui_font_size", "UI Font Size" ), + app.i18n( "ui_font_size_desc", "Set the font size used by the application UI." ) }, + 6, 72, 0.5, [&app] { return app.config->font.uiSize; }, + [&app]( double value ) { + app.config->font.uiSize = value; + app.scene->getUIThemeManager()->setDefaultFontSize( value ); + app.scene->getRoot()->reloadStyle( true, true, true, true, true ); + app.savePreferences(); + } ); + panel->addFloat( + { "terminalFontSize", "appearance.fonts", + app.i18n( "terminal_font_size", "Terminal Font Size" ), + app.i18n( "terminal_font_size_desc", "Set the default terminal font size." ) }, + 6, 72, 0.5, [&app] { return app.config->font.size; }, + [&app]( double value ) { + app.config->font.size = value; + app.terminalFontSize = PixelDensity::dpToPx( value ); + app.forEachTerminal( + [&app]( UITerminal* terminal ) { terminal->setFontSize( app.terminalFontSize ); } ); + app.savePreferences(); + } ); + panel->addFloat( + { "uiScaleFactor", "appearance.fonts", app.i18n( "ui_scale_factor", "UI Scale Factor" ), + app.i18n( "ui_scale_factor_desc", + "Scale the complete user interface. Restart required." ) }, + 1, 6, 0.1, + [&app] { + return app.config->window.pixelDensity > 0 ? app.config->window.pixelDensity + : PixelDensity::getPixelDensity(); + }, + [&app]( double value ) { + app.config->window.pixelDensity = value; + app.savePreferences(); + } ); + panel->addChoice( + { "fontHinting", "appearance.fonts", app.i18n( "ui_font_hint", "Font Hinting" ), + app.i18n( "ui_font_hint_desc", "Control glyph alignment to the pixel grid." ) }, + { app.i18n( "none", "None" ), app.i18n( "slight", "Slight" ), app.i18n( "full", "Full" ) }, + [&app] { return static_cast( app.config->font.hinting ); }, + [&app]( size_t selected ) { + static constexpr FontHinting values[] = { FontHinting::None, FontHinting::Slight, + FontHinting::Full }; + app.config->font.hinting = values[std::min( selected, size_t{ 2 } )]; + app.scene->getResourceScope()->getFontService().setHinting( app.config->font.hinting ); + app.forEachTerminal( + []( UITerminal* terminal ) { terminal->syncFontRenderingConfig(); } ); + app.savePreferences(); + } ); + panel->addChoice( + { "fontAntialiasing", "appearance.fonts", + app.i18n( "ui_font_antialiasing", "Font Anti-Aliasing" ), + app.i18n( "ui_font_antialiasing_desc", "Choose how glyph edges are smoothed." ) }, + { app.i18n( "none", "None" ), app.i18n( "grayscale", "Grayscale" ), + app.i18n( "subpixel", "Subpixel" ) }, + [&app] { return static_cast( app.config->font.antialiasing ); }, + [&app]( size_t selected ) { + static constexpr FontAntialiasing values[] = { + FontAntialiasing::None, FontAntialiasing::Grayscale, FontAntialiasing::Subpixel }; + app.config->font.antialiasing = values[std::min( selected, size_t{ 2 } )]; + app.scene->getResourceScope()->getFontService().setAntialiasing( + app.config->font.antialiasing ); + app.forEachTerminal( + []( UITerminal* terminal ) { terminal->syncFontRenderingConfig(); } ); + app.savePreferences(); + } ); + + panel->addCategory( "window.renderer", app.i18n( "window", "Window" ), + app.i18n( "renderer", "Renderer" ) ); + panel->addBool( + { "vsync", "window.renderer", app.i18n( "vsync", "VSync" ), + app.i18n( "vsync_desc", "Synchronize rendering with the display. Restart required." ) }, + &app.config->window.vsync, [&app]( bool ) { app.savePreferences(); } ); + const String monitorRefreshRate = app.i18n( "monitor_refresh_rate", "Monitor Refresh Rate" ); + const String unlimitedFrameRate = app.i18n( "unlimited", "Unlimited" ); + panel->addEditableChoice( + { "frameRateLimit", "window.renderer", app.i18n( "frame_rate_limit", "Frame Rate Limit" ), + app.i18n( "frame_rate_limit_desc", "Limit rendered frames per second, follow the monitor " + "refresh rate, or disable the limit." ) }, + { monitorRefreshRate, unlimitedFrameRate, "30", "60", "75", "120", "144", "165", "240" }, + [&app, monitorRefreshRate, unlimitedFrameRate] { + return app.config->window.maxFPS == + static_cast( ContextSettings::FrameRateLimitScreenRefreshRate ) + ? monitorRefreshRate + : app.config->window.maxFPS == 0 + ? unlimitedFrameRate + : String( String::toString( app.config->window.maxFPS ) ); + }, + [&app, monitorRefreshRate, unlimitedFrameRate]( const String& selection ) { + Uint32 value; + if ( selection == monitorRefreshRate ) + value = ContextSettings::FrameRateLimitScreenRefreshRate; + else if ( selection == unlimitedFrameRate ) + value = 0; + else if ( !String::fromString( value, selection ) || value > 1000 ) + return false; + app.config->window.maxFPS = value; + app.appWindow->setFrameRateLimit( value ); + app.savePreferences(); + return true; + } ); + auto rendererVersions = Renderer::getAvailableGraphicsLibraryVersions(); + std::vector rendererNames; + for ( auto version : rendererVersions ) + rendererNames.emplace_back( Renderer::graphicsLibraryVersionToString( version ) ); + if ( !rendererVersions.empty() ) + panel->addChoice( + { "rendererVersion", "window.renderer", + app.i18n( "ui_renderer_version", "Renderer Version" ), + app.i18n( "ui_renderer_version_desc", + "Select the graphics API. Restart required." ) }, + rendererNames, + [&app, rendererVersions] { + auto found = std::find( rendererVersions.begin(), rendererVersions.end(), + app.config->window.rendererVersion ); + return found == rendererVersions.end() + ? size_t{ 0 } + : static_cast( found - rendererVersions.begin() ); + }, + [&app, rendererVersions]( size_t selected ) { + app.config->window.rendererVersion = + rendererVersions[std::min( selected, rendererVersions.size() - 1 )]; + app.savePreferences(); + } ); + panel->addChoice( + { "multisamples", "window.renderer", + app.i18n( "ui_multisamples_level", "Multisample Anti-Aliasing Level" ), + app.i18n( "ui_multisamples_level_desc", + "Set renderer multisampling. Restart required." ) }, + { "0", "2", "4", "8", "16" }, + [&app] { + static constexpr Uint32 values[] = { 0, 2, 4, 8, 16 }; + auto found = std::find( std::begin( values ), std::end( values ), + app.config->window.multisamples ); + return found == std::end( values ) + ? size_t{ 0 } + : static_cast( found - std::begin( values ) ); + }, + [&app]( size_t selected ) { + static constexpr Uint32 values[] = { 0, 2, 4, 8, 16 }; + app.config->window.multisamples = values[std::min( selected, size_t{ 4 } )]; + app.savePreferences(); + } ); + panel->addBool( + { "benchmarkMode", "window.renderer", app.i18n( "benchmark_mode", "Benchmark Mode" ), + app.i18n( "benchmark_mode_desc", "Render continuously to measure performance." ) }, + &app.config->window.benchmarkMode, [&app]( bool value ) { + app.benchmarkMode = value; + app.savePreferences(); + } ); + + panel->addCategory( "terminal.behavior", app.i18n( "terminal", "Terminal" ), + app.i18n( "behavior", "Behavior" ) ); + panel->addChoice( + { "newTerminalBehavior", "terminal.behavior", + app.i18n( "new_terminal_behavior", "New Terminal Behavior" ), + app.i18n( "new_terminal_behavior_desc", + "Choose where new terminal sessions are opened." ) }, + { app.i18n( "open_in_same_tabbar", "Open in Current Tab Bar" ), + app.i18n( "open_in_vertical_split", "Open in New Vertical Split" ), + app.i18n( "open_in_horizontal_split", "Open in New Horizontal Split" ) }, + [&app] { return static_cast( app.config->terminal.newTerminalBehavior ); }, + [&app]( size_t selected ) { + app.config->terminal.newTerminalBehavior = + static_cast( std::min( selected, size_t{ 2 } ) ); + app.savePreferences(); + } ); + panel->addBool( + { "terminalExclusiveMode", "terminal.behavior", + app.i18n( "enable_exclusive_mode_by_default", "Enable Exclusive Mode by Default" ), + app.i18n( "enable_exclusive_mode_by_default_tooltip", + "Disable global keybindings in newly created terminals." ) }, + &app.config->terminal.exclusiveMode, [&app]( bool value ) { + app.forEachTerminal( + [value]( UITerminal* terminal ) { terminal->setExclusiveMode( value ); } ); + app.savePreferences(); + } ); + panel->addBool( + { "closeTerminalTabOnExit", "terminal.behavior", + app.i18n( "close_terminal_tab_on_exit", "Close Terminal Tab on Exit" ), + app.i18n( "close_terminal_tab_on_exit_tooltip", "Close a tab when its process exits." ) }, + &app.config->terminal.closeOnExit, [&app]( bool value ) { + app.terminalConfig.closeOnExit = value; + app.forEachTerminal( + [value]( UITerminal* terminal ) { terminal->getTerm()->setKeepAlive( !value ); } ); + app.savePreferences(); + } ); + panel->addBool( { "warnBeforeClosingTerminal", "terminal.behavior", + app.i18n( "warn_before_closing_tab", "Warn Before Closing Tab" ), + app.i18n( "warn_before_closing_tab_tooltip", + "Ask before closing a terminal while a program is running." ) }, + &app.config->window.warnBeforeClose, [&app]( bool value ) { + app.warnBeforeClose = value; + app.savePreferences(); + } ); + const std::vector knownShells{ "bash", "sh", "zsh", "fish", "nu", "csh", + "tcsh", "ksh", "dash", "cmd", "powershell" }; + std::vector installedShells; + for ( const auto& shell : knownShells ) { + auto path = Sys::which( shell ); + if ( !path.empty() ) + installedShells.emplace_back( std::move( path ) ); + } + auto currentShell = app.config->terminal.shell; + if ( currentShell.empty() ) { + if ( const char* shell = std::getenv( "SHELL" ); shell ) + currentShell = FileSystem::fileExists( shell ) ? shell : Sys::which( shell ); + if ( currentShell.empty() ) + currentShell = Sys::which( + Sys::getPlatformType() == Sys::PlatformType::Windows ? "powershell" : "bash" ); + } + if ( !currentShell.empty() && std::find( installedShells.begin(), installedShells.end(), + String( currentShell ) ) == installedShells.end() ) + installedShells.emplace_back( currentShell ); + panel->addEditableChoice( + { "terminalShell", "terminal.behavior", app.i18n( "terminal_shell", "Terminal Shell" ), + app.i18n( "terminal_shell_desc", "Set the shell executable used by new terminals." ) }, + installedShells, [currentShell] { return String( currentShell ); }, + [&app]( const String& selection ) { + auto shell = selection.toUtf8(); + if ( Sys::which( shell ).empty() && !FileSystem::fileExists( shell ) ) + return false; + app.config->terminal.shell = shell; + app.terminalConfig.program = shell; + app.savePreferences(); + return true; + } ); + panel->addText( + { "terminalShellArguments", "terminal.behavior", + app.i18n( "terminal_shell_arguments", "Terminal Shell Arguments" ), + app.i18n( "terminal_shell_arguments_desc", + "Set shell arguments used by new terminals." ) }, + [&app] { return app.config->terminal.shellArguments; }, + [&app]( const std::string& value ) { + app.config->terminal.shellArguments = value; + app.terminalConfig.arguments = String::split( value ); + app.savePreferences(); + return true; + }, + true ); + panel->addInteger( + { "terminalScrollback", "terminal.behavior", + app.i18n( "terminal_scrollback", "Terminal Scrollback" ), + app.i18n( "configure_terminal_scrollback_desc", + "Set retained terminal history lines." ) }, + 0, std::numeric_limits::max(), + [&app] { + return static_cast( std::min( app.config->terminal.historySize, + std::numeric_limits::max() ) ); + }, + [&app]( int value ) { + app.config->terminal.historySize = value; + app.terminalConfig.historySize = value; + app.savePreferences(); + } ); + panel->addBool( + { "terminalFrameBuffer", "terminal.behavior", app.i18n( "framebuffer", "Use Framebuffer" ), + app.i18n( + "framebuffer_desc", + "Render terminals through an FBO instead of the default VBO path. This can improve " + "rendering performance by using a different invalidation strategy." ) }, + &app.config->terminal.useFrameBuffer, [&app]( bool value ) { + app.terminalConfig.useFrameBuffer = value; + app.savePreferences(); + } ); + + panel->addCategory( "terminal.appearance", app.i18n( "terminal", "Terminal" ), + app.i18n( "appearance", "Appearance" ) ); + std::vector schemeNames{ app.i18n( "default", "Default" ) }; + std::vector schemeIds{ "" }; + for ( const auto& [name, scheme] : app.terminalColorSchemes ) { + schemeNames.emplace_back( name ); + schemeIds.emplace_back( name ); + } + if ( !schemeIds.empty() ) + panel->addChoice( + { "terminalColorScheme", "terminal.appearance", + app.i18n( "terminal_color_scheme", "Terminal Color Scheme" ), + app.i18n( "terminal_color_scheme_desc", "Choose the colors used by terminals." ) }, + schemeNames, + [&app, schemeIds] { + auto found = + std::find( schemeIds.begin(), schemeIds.end(), app.config->theme.colorScheme ); + return found == schemeIds.end() ? size_t{ 0 } + : static_cast( found - schemeIds.begin() ); + }, + [&app, schemeIds]( size_t selected ) { + app.config->theme.colorScheme = + schemeIds[std::min( selected, schemeIds.size() - 1 )]; + if ( app.config->theme.colorScheme.empty() ) { + app.selectedColorScheme = nullptr; + auto defaultScheme = TerminalColorScheme::getDefault(); + app.forEachTerminal( [&defaultScheme]( UITerminal* terminal ) { + terminal->setColorScheme( defaultScheme ); + } ); + app.savePreferences(); + return; + } + auto found = app.terminalColorSchemes.find( app.config->theme.colorScheme ); + if ( found != app.terminalColorSchemes.end() ) { + app.selectedColorScheme = &found->second; + app.forEachTerminal( [&app]( UITerminal* terminal ) { + terminal->setColorScheme( *app.selectedColorScheme ); + } ); + } + app.savePreferences(); + } ); + panel->addChoice( + { "terminalScrollbarType", "terminal.appearance", + app.i18n( "scrollbar_type", "Scrollbar Type" ), + app.i18n( "scrollbar_type_desc", + "Place the scrollbar over or outside terminal content." ) }, + { app.i18n( "overlay", "Overlay" ), app.i18n( "outside", "Outside" ) }, + [&app] { return app.config->terminal.scrollBarType == ScrollViewType::Overlay ? 0 : 1; }, + [&app]( size_t selected ) { + app.config->terminal.scrollBarType = + selected == 0 ? ScrollViewType::Overlay : ScrollViewType::Outside; + app.forEachTerminal( [&app]( UITerminal* terminal ) { + terminal->setScrollViewType( app.config->terminal.scrollBarType ); + } ); + app.savePreferences(); + } ); + panel->addChoice( + { "terminalCursorStyle", "terminal.appearance", app.i18n( "cursor_style", "Cursor Style" ), + app.i18n( "cursor_style_desc", "Choose the terminal cursor shape and animation." ) }, + { app.i18n( "blinking_block", "Blinking Block" ), + app.i18n( "steady_block", "Steady Block" ), + app.i18n( "blink_underline", "Blink Underline" ), + app.i18n( "steady_underline", "Steady Underline" ), app.i18n( "blink_bar", "Blink Bar" ), + app.i18n( "steady_bar", "Steady Bar" ) }, + [&app] { + static constexpr TerminalCursorMode modes[] = { + TerminalCursorMode::BlinkingBlock, TerminalCursorMode::SteadyBlock, + TerminalCursorMode::BlinkUnderline, TerminalCursorMode::SteadyUnderline, + TerminalCursorMode::BlinkBar, TerminalCursorMode::SteadyBar }; + auto found = std::find( std::begin( modes ), std::end( modes ), + app.config->terminal.cursorStyle ); + return found == std::end( modes ) ? size_t{ 0 } + : static_cast( found - std::begin( modes ) ); + }, + [&app]( size_t selected ) { + static constexpr TerminalCursorMode modes[] = { + TerminalCursorMode::BlinkingBlock, TerminalCursorMode::SteadyBlock, + TerminalCursorMode::BlinkUnderline, TerminalCursorMode::SteadyUnderline, + TerminalCursorMode::BlinkBar, TerminalCursorMode::SteadyBar }; + app.config->terminal.cursorStyle = modes[std::min( selected, size_t{ 5 } )]; + app.terminalConfig.cursorStyle = app.config->terminal.cursorStyle; + app.forEachTerminal( [&app]( UITerminal* terminal ) { + terminal->getTerm()->setCursorMode( app.config->terminal.cursorStyle ); + } ); + app.savePreferences(); + } ); + panel->addChoice( + { "terminalScrollbarMode", "terminal.appearance", + app.i18n( "scrollbar_mode", "Scrollbar Mode" ), + app.i18n( "scrollbar_mode_desc", "Control when the terminal scrollbar is visible." ) }, + { app.i18n( "auto", "Auto" ), app.i18n( "always_visible", "Always Visible" ), + app.i18n( "always_hidden", "Always Hidden" ) }, + [&app] { + return app.config->terminal.scrollBarMode == ScrollBarMode::Auto ? 0 + : app.config->terminal.scrollBarMode == ScrollBarMode::AlwaysOn ? 1 + : 2; + }, + [&app]( size_t selected ) { + app.config->terminal.scrollBarMode = selected == 0 ? ScrollBarMode::Auto + : selected == 1 ? ScrollBarMode::AlwaysOn + : ScrollBarMode::AlwaysOff; + app.forEachTerminal( [&app]( UITerminal* terminal ) { + terminal->setVerticalScrollMode( app.config->terminal.scrollBarMode ); + } ); + app.savePreferences(); + } ); + panel->addBool( { "alwaysShowTabBar", "terminal.appearance", + app.i18n( "always_show_tab_bar", "Always Show Tab Bar" ), + app.i18n( "always_show_tab_bar_desc", + "Show the tab bar even when it contains one tab." ) }, + &app.config->window.alwaysShowTabBar, [&app]( bool value ) { + app.tabSplitter->setHideTabBarOnSingleTab( !value ); + app.savePreferences(); + } ); + + panel->build(); + settingsWindow->setKeyBindingCommand( "closeWindow", [&app] { + if ( app.settingsWindow ) + app.settingsWindow->closeWindow(); + } ); + settingsWindow->getKeyBindings().addKeybind( { KEY_ESCAPE }, "closeWindow" ); + settingsWindow->on( Event::OnWindowClose, [&app]( const Event* ) { + app.savePreferences(); + app.settingsWindow = nullptr; + if ( app.tabSplitter && app.tabSplitter->getCurWidget() ) + app.tabSplitter->getCurWidget()->setFocus(); + } ); + settingsWindow->center(); + settingsWindow->showWhenReady(); + panel->focusSearch(); + return settingsWindow; +} + +} // namespace eterm diff --git a/src/tools/eterm/settingspanel.hpp b/src/tools/eterm/settingspanel.hpp new file mode 100644 index 000000000..376954f67 --- /dev/null +++ b/src/tools/eterm/settingspanel.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include + +namespace EE::UI { +class UIWindow; +class UISceneNode; +} // namespace EE::UI + +using namespace EE; +using namespace EE::UI; + +namespace eterm { + +class App; + +struct SettingsPanel { + static UIWindow* create( App& ); +}; + +} // namespace eterm