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
This commit is contained in:
Martín Lucas Golini
2026-09-09 00:56:03 -03:00
parent 64512ab44d
commit 71ede34599
17 changed files with 2582 additions and 1214 deletions

View File

@@ -887,14 +887,14 @@ the directory tree and in file dialogs to open a folder or file.</string>
<string name="type">Type</string>
<string name="type_to_locate">Type to Locate</string>
<string name="ui_font_and_size_ellipsis">UI Font &amp; Size...</string>
<string name="ui_font_size">Ui Font Size</string>
<string name="ui_font_size">UI Font Size</string>
<string name="ui_language">UI Language</string>
<string name="ui_multisamples_level">Multisample Anti-Aliasing Level</string>
<string name="ui_panel_font_size">Ui Panel Font Size</string>
<string name="ui_panel_font_size">UI Panel Font Size</string>
<string name="ui_prefes_color_scheme">UI Prefers Color Scheme</string>
<string name="ui_renderer">Renderer</string>
<string name="ui_renderer_version">Renderer Version</string>
<string name="ui_scale_factor">Ui Scale Factor</string>
<string name="ui_scale_factor">UI Scale Factor</string>
<string name="ui_thene">UI Theme</string>
<string name="uicodeeditor_copy">Copy</string>
<string name="uicodeeditor_copy_containing_folder_path">Copy Containing Folder Path</string>

View File

@@ -681,7 +681,7 @@ file in the directory tree.</string>
<string name="type">Type</string>
<string name="type_to_locate">Type to Locate</string>
<string name="ui_font_and_size_ellipsis">界面字体和大小...</string>
<string name="ui_font_size">Ui字体大小</string>
<string name="ui_font_size">UI字体大小</string>
<string name="ui_language">界面语言</string>
<string name="ui_multisamples_level">多重采样抗锯齿级别</string>
<string name="ui_panel_font_size">界面面板字体大小</string>

View File

@@ -93,6 +93,7 @@
#include <eepp/ui/tools/uidocfindreplace.hpp>
#include <eepp/ui/tools/uifontpickerdialog.hpp>
#include <eepp/ui/tools/uiimageviewer.hpp>
#include <eepp/ui/tools/uisettingspanel.hpp>
#include <eepp/ui/tools/uitabwidgetsplitter.hpp>
#include <eepp/ui/tools/uiwidgetinspector.hpp>
#include <eepp/ui/uiapplication.hpp>

View File

@@ -0,0 +1,221 @@
#ifndef EE_UI_TOOLS_UISETTINGSPANEL_HPP
#define EE_UI_TOOLS_UISETTINGSPANEL_HPP
#include <eepp/core/string.hpp>
#include <eepp/ui/uilinearlayout.hpp>
#include <functional>
#include <memory>
#include <string>
#include <variant>
#include <vector>
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<void( bool )> apply;
};
struct EE_API BoolSetting {
std::function<bool()> get;
std::function<void( bool )> set;
};
struct EE_API ChoiceSetting {
std::vector<String> choices;
std::vector<String> descriptions;
std::function<size_t()> get;
std::function<void( size_t )> set;
};
struct EE_API EditableChoiceSetting {
std::vector<String> choices;
std::function<String()> get;
std::function<bool( const String& )> set;
};
struct EE_API IntegerSetting {
int min{ 0 };
int max{ 0 };
std::function<int()> get;
std::function<void( int )> set;
};
struct EE_API TextSetting {
std::function<std::string()> get;
std::function<bool( const std::string& )> set;
bool commitOnFocusLoss{ false };
bool password{ false };
};
struct EE_API FloatSetting {
double min{ 0 };
double max{ 0 };
double step{ 0 };
std::function<double()> get;
std::function<void( double )> set;
};
struct EE_API ActionSetting {
String buttonText;
std::function<void()> action;
};
using SettingValue =
std::variant<BoolPointerSetting, BoolSetting, ChoiceSetting, EditableChoiceSetting,
IntegerSetting, TextSetting, FloatSetting, ActionSetting>;
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<SettingsCategory>& categories() const { return mCategories; }
const std::vector<SettingsGroup>& groups() const { return mGroups; }
std::vector<SettingDefinition>& settings() { return mSettings; }
const std::vector<SettingDefinition>& settings() const { return mSettings; }
private:
std::vector<SettingsCategory> mCategories;
std::vector<SettingsGroup> mGroups;
std::vector<SettingDefinition> 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<void( bool )> apply = {} );
bool addBool( SettingDescriptor descriptor, std::function<bool()> get,
std::function<void( bool )> set );
bool addChoice( SettingDescriptor descriptor, std::vector<String> choices,
std::function<size_t()> get, std::function<void( size_t )> set,
std::vector<String> choiceDescriptions = {} );
bool addEditableChoice( SettingDescriptor descriptor, std::vector<String> choices,
std::function<String()> get, std::function<bool( const String& )> set );
bool addInteger( SettingDescriptor descriptor, int min, int max, std::function<int()> get,
std::function<void( int )> set );
bool addText( SettingDescriptor descriptor, std::function<std::string()> get,
std::function<bool( const std::string& )> set, bool commitOnFocusLoss = false );
bool addFloat( SettingDescriptor descriptor, double min, double max, double step,
std::function<double()> get, std::function<void( double )> set );
bool addAction( SettingDescriptor descriptor, String buttonText, std::function<void()> 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<Impl> 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

View File

@@ -0,0 +1,976 @@
#include <algorithm>
#include <deque>
#include <eepp/system/log.hpp>
#include <eepp/ui/databinding/uibindinggroup.hpp>
#include <eepp/ui/databinding/uidatabind.hpp>
#include <eepp/ui/models/itemlistmodel.hpp>
#include <eepp/ui/tools/uisettingspanel.hpp>
#include <eepp/ui/uicheckbox.hpp>
#include <eepp/ui/uicombobox.hpp>
#include <eepp/ui/uidropdownmodellist.hpp>
#include <eepp/ui/uipushbutton.hpp>
#include <eepp/ui/uiscenenode.hpp>
#include <eepp/ui/uiscrollview.hpp>
#include <eepp/ui/uispinbox.hpp>
#include <eepp/ui/uitextinput.hpp>
#include <eepp/ui/uitextview.hpp>
#include <eepp/ui/uitreeview.hpp>
#define PUGIXML_HEADER_ONLY
#include <pugixml/pugixml.hpp>
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<Node*> children;
std::vector<Node*> visibleChildren;
};
static std::shared_ptr<UISettingsCategoryModel>
create( const std::vector<std::pair<std::string, std::vector<std::string>>>& categories,
const UnorderedMap<std::string, std::string>& ids ) {
return std::make_shared<UISettingsCategoryModel>( categories, ids );
}
UISettingsCategoryModel(
const std::vector<std::pair<std::string, std::vector<std::string>>>& categories,
const UnorderedMap<std::string, std::string>& 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<Node*>( 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<Node*>( parent.internalData() ) : mRoot;
if ( row < 0 || column != 0 || static_cast<size_t>( 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<Node*>( 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<Node*>( index.internalData() )->text );
}
void filter( const std::string_view query,
const UnorderedSet<std::string>& 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<Node> mNodes;
Node* mRoot{ nullptr };
};
static constexpr const char* SETTINGS_PANEL_LAYOUT = R"xml(
<style>
<![CDATA[
.settings_panel #settings_sidebar {
background-color: var(--list-back);
padding: 10dp 8dp 8dp 8dp;
}
.settings_panel #settings_filter {
margin-bottom: 10dp;
}
.settings_panel #settings_categories * {
focusable: false;
}
.settings_panel #settings_categories {
background-color: var(--list-back);
}
.settings_panel #settings_categories treeview::row {
border-left: 0dp solid var(--primary);
transition: border-left-width 0.1s;
}
.settings_panel #settings_categories treeview::row:selected {
background-color: var(--tab-hover);
border-left: 2dp solid var(--primary);
}
.settings_panel #settings_categories treeview::row:selected treeview::cell {
color: var(--font);
}
.settings_panel #settings_rows {
max-width: 820dp;
padding: 20dp 28dp 28dp 28dp;
layout-gravity: center_horizontal;
}
.settings_panel #settings_page_title {
font-size: 18dp;
margin-bottom: 18dp;
font-weight: bold;
}
.settings_panel .settings_category_heading {
font-size: 14dp;
font-weight: bold;
margin: 12dp 0dp 6dp 0dp;
padding-bottom: 6dp;
border-bottom: 1dp solid var(--tab-line);
}
.settings_panel .settings_subcategory_heading {
font-size: 12dp;
font-weight: bold;
margin: 18dp 4dp 3dp 4dp;
padding-bottom: 6dp;
border-bottom: 1dp solid var(--tab-line);
}
.settings_panel .settings_option {
border-bottom: 1dp solid var(--disabled-border);
padding: 9dp 4dp 11dp 4dp;
margin-bottom: 0dp;
}
.settings_panel .settings_option:disabled {
opacity: 0.5;
}
.settings_panel .settings_option_name {
font-style: normal;
}
.settings_panel .settings_option_description {
color: var(--disabled-color);
font-size: 10dp;
margin-top: 3dp;
word-wrap: true;
font-style: normal;
}
@media (prefers-color-scheme: dark) {
.settings_panel .settings_option_name,
.settings_panel .settings_option_description {
font-style: shadow;
}
.settings_panel .settings_option_description {
color: var(--font-hint);
}
}
.settings_panel .settings_boolean_option #setting_info,
.settings_panel .settings_boolean_option .settings_option_name,
.settings_panel .settings_boolean_option .settings_option_description {
cursor: pointer;
}
.settings_panel .settings_option_control {
layout-width: 210dp;
gravity: right|center_vertical;
margin-left: 20dp;
layout-gravity: center_vertical;
}
.settings_panel .settings_bool,
.settings_panel .settings_action {
layout-width: wrap_content;
layout-height: wrap_content;
}
.settings_panel .settings_bool {
check-mode: button;
}
.settings_panel .settings_choice {
layout-width: 190dp;
layout-height: wrap_content;
}
.settings_panel .settings_editable_choice {
layout-width: 210dp;
layout-height: wrap_content;
}
.settings_panel .settings_text {
layout-width: 210dp;
layout-height: wrap_content;
}
.settings_panel .settings_text.error {
border-color: var(--theme-error);
}
.settings_panel .settings_integer {
layout-width: 110dp;
layout-height: wrap_content;
}
]]>
</style>
<vbox lw="mp" lh="mp" class="settings_panel">
<Splitter id="settings_splitter" lw="mp" lh="mp" orientation="horizontal" splitter-partition="220dp">
<vbox id="settings_sidebar" lw="0" lh="0" min-width="160dp">
<TextInput id="settings_filter" lw="mp" lh="wc" hint="@string(search_settings, Search settings...)" />
<TreeView id="settings_categories" lw="mp" lh="o" lw8="1" />
</vbox>
<ScrollView id="settings_scroll" lw="0" lw8="1" lh="mp" focusable="false">
<vbox id="settings_rows" lw="mp" lh="wc">
<TextView id="settings_page_title" lw="mp" lh="wc" focusable="false" />
</vbox>
</ScrollView>
</Splitter>
</vbox>
)xml";
static std::string settingsRowLayout( std::string_view control ) {
return R"xml(
<vbox lw="mp" lh="wc" class="settings_option">
<hbox lw="mp" lh="wc" class="settings_option_content">
<vbox id="setting_info" lw="0" lw8="1" lh="wc">
<TextView id="setting_name" lw="mp" lh="wc" class="settings_option_name" focusable="false" />
<TextView id="setting_description" lw="mp" lh="wc" class="settings_option_description" focusable="false" />
</vbox>
<hbox id="setting_control" lw="wc" lh="wc" class="settings_option_control">
)xml" + std::string( control ) +
R"xml(
</hbox>
</hbox>
</vbox>
)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(
<vbox lw="mp" lh="wc" visible="false">
<TextView id="settings_category_heading" lw="mp" lh="wc" class="settings_category_heading" visible="false" focusable="false" />
<vbox id="settings_category_rows" lw="mp" lh="wc" />
</vbox>
)xml";
static constexpr const char* SETTINGS_SUBCATEGORY_HEADING_LAYOUT = R"xml(
<TextView lw="mp" lh="wc" class="settings_subcategory_heading" visible="false" focusable="false" />
)xml";
static const SettingsLayoutTemplate SETTINGS_BOOL_ROW_LAYOUT( settingsRowLayout(
R"xml(<CheckBox id="setting_control_widget" class="settings_bool" />)xml" ) );
static const SettingsLayoutTemplate SETTINGS_CHOICE_ROW_LAYOUT( settingsRowLayout(
R"xml(<DropDownModelList id="setting_control_widget" class="settings_choice" />)xml" ) );
static const SettingsLayoutTemplate SETTINGS_EDITABLE_CHOICE_ROW_LAYOUT( settingsRowLayout(
R"xml(<ComboBox id="setting_control_widget" class="settings_editable_choice" popup-to-root="true" />)xml" ) );
static const SettingsLayoutTemplate SETTINGS_INTEGER_ROW_LAYOUT( settingsRowLayout(
R"xml(<SpinBox id="setting_control_widget" class="settings_integer" />)xml" ) );
static const SettingsLayoutTemplate SETTINGS_TEXT_ROW_LAYOUT( settingsRowLayout(
R"xml(<TextInput id="setting_control_widget" class="settings_text" />)xml" ) );
static const SettingsLayoutTemplate SETTINGS_ACTION_ROW_LAYOUT( settingsRowLayout(
R"xml(<PushButton id="setting_control_widget" class="settings_action" />)xml" ) );
static void disableTabFocusTree( Node* node ) {
if ( node->isWidget() )
node->asType<UIWidget>()->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<Model> categoryModel;
UIBindingGroup bindingGroup;
SettingsModel model;
std::vector<std::pair<std::string, std::vector<std::string>>> categoryItems;
UnorderedMap<std::string, std::string> categoryIds;
UnorderedMap<std::string, String> categorySearchText;
UnorderedMap<std::string, String> categoryTitles;
UnorderedMap<std::string, UITextView*> categoryHeadings;
UnorderedMap<std::string, UIWidget*> categorySections;
UnorderedMap<std::string, UILinearLayout*> categoryContainers;
UnorderedSet<std::string> materializedCategories;
std::vector<SubcategoryHeading> subcategoryHeadings;
std::vector<SettingView> 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<Impl>() ) {
setParent( parent );
setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::MatchParent );
auto* layout = getUISceneNode()->loadLayoutFromString( SETTINGS_PANEL_LAYOUT, this );
mImpl->search = layout->find<UITextInput>( "settings_filter" );
mImpl->categories = layout->find<UITreeView>( "settings_categories" );
mImpl->settings = layout->find<UILinearLayout>( "settings_rows" );
mImpl->pageTitle = layout->find<UITextView>( "settings_page_title" );
mImpl->scroll = layout->find<UIScrollView>( "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<void( bool )> apply ) {
return !mImpl->built &&
mImpl->model.addSetting(
{ std::move( descriptor ), BoolPointerSetting{ value, std::move( apply ) } } );
}
bool UISettingsPanel::addBool( SettingDescriptor descriptor, std::function<bool()> get,
std::function<void( bool )> set ) {
return !mImpl->built &&
mImpl->model.addSetting(
{ std::move( descriptor ), BoolSetting{ std::move( get ), std::move( set ) } } );
}
bool UISettingsPanel::addChoice( SettingDescriptor descriptor, std::vector<String> choices,
std::function<size_t()> get, std::function<void( size_t )> set,
std::vector<String> 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<String> choices,
std::function<String()> get,
std::function<bool( const String& )> 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<int()> get, std::function<void( int )> 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<std::string()> get,
std::function<bool( const std::string& )> 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<double()> get, std::function<void( double )> 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<void()> 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<UintPtr>( 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<UintPtr>( 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<std::string>{ 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<UITextView>( "settings_category_heading" );
heading->setText( name );
heading->setId( "settings_category_" + id );
panel.categoryHeadings[id] = heading;
panel.categorySections[id] = section;
panel.categoryContainers[id] = section->find<UILinearLayout>( "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<UISettingsCategoryModel::Node*>( 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<UITextView>( "setting_name" )->setText( binding.name );
auto* description = row->find<UITextView>( "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<UICheckBox>( "setting_control_widget" );
auto toggle = [check]( const Event* event ) {
if ( event->asMouseEvent()->getFlags() & EE_BUTTON_LMASK )
check->setChecked( !check->isChecked() );
};
panel.connections +=
row->find<UITextView>( "setting_name" )->connect( Event::MouseClick, toggle );
panel.connections +=
row->find<UITextView>( "setting_description" )->connect( Event::MouseClick, toggle );
panel.connections += row->find<UILinearLayout>( "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<TextSetting>( &settings[i].value );
auto* input = panel.settingViews[i].row->find<UITextInput>( "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<UITextView>();
heading->setText( group.name );
panel.subcategoryHeadings.push_back( { group.category, group.name, heading } );
}
auto& view = panel.settingViews[i];
if ( auto* value = std::get_if<BoolPointerSetting>( &setting.value ) ) {
auto* check = createBoolControl( panel, setting, view );
auto binding = UIDataBind<bool>::New( value->value, check,
UIValueConverter<bool>::converterBool() );
binding->onValueChangeCb = value->apply;
panel.bindingGroup += std::move( binding );
} else if ( auto* value = std::get_if<BoolSetting>( &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<ChoiceSetting>( &setting.value ) ) {
auto* row = createRow( panel, setting, view, SETTINGS_CHOICE_ROW_LAYOUT.root() );
auto* dropDown = row->find<UIDropDownModelList>( "setting_control_widget" );
auto model = ItemListOwnerModel<String>::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<EditableChoiceSetting>( &setting.value ) ) {
auto* row =
createRow( panel, setting, view, SETTINGS_EDITABLE_CHOICE_ROW_LAYOUT.root() );
auto* combo = row->find<UIComboBox>( "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<IntegerSetting>( &setting.value ) ) {
auto* row = createRow( panel, setting, view, SETTINGS_INTEGER_ROW_LAYOUT.root() );
auto* spin = row->find<UISpinBox>( "setting_control_widget" );
spin->setMinValue( value->min )->setMaxValue( value->max );
spin->unsetTabFocusable();
spin->getButtonPushUp()->asType<UIWidget>()->unsetTabFocusable();
spin->getButtonPushDown()->asType<UIWidget>()->unsetTabFocusable();
spin->setValue( value->get() );
panel.connections +=
spin->connect( Event::OnValueChange, [spin, value]( const Event* ) {
value->set( static_cast<int>( spin->getValue() ) );
} );
} else if ( auto* value = std::get_if<TextSetting>( &setting.value ) ) {
auto* row = createRow( panel, setting, view, SETTINGS_TEXT_ROW_LAYOUT.root() );
auto* input = row->find<UITextInput>( "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<Action::UniqueID>( 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<FloatSetting>( &setting.value ) ) {
auto* row = createRow( panel, setting, view, SETTINGS_INTEGER_ROW_LAYOUT.root() );
auto* spin = row->find<UISpinBox>( "setting_control_widget" );
spin->setMinValue( value->min )->setMaxValue( value->max )->setClickStep( value->step );
spin->allowFloatingPoint( true )->setValue( value->get() );
spin->unsetTabFocusable();
spin->getButtonPushUp()->asType<UIWidget>()->unsetTabFocusable();
spin->getButtonPushDown()->asType<UIWidget>()->unsetTabFocusable();
panel.connections +=
spin->connect( Event::OnValueChange,
[spin, value]( const Event* ) { value->set( spin->getValue() ); } );
} else if ( auto* value = std::get_if<ActionSetting>( &setting.value ) ) {
auto* row = createRow( panel, setting, view, SETTINGS_ACTION_ROW_LAYOUT.root() );
auto* button = row->find<UIPushButton>( "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<std::string> 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<UISettingsCategoryModel>( 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

View File

@@ -0,0 +1,135 @@
#include "utest.hpp"
#include <eepp/system/filesystem.hpp>
#include <eepp/system/sys.hpp>
#include <eepp/ui/tools/uisettingspanel.hpp>
#include <eepp/ui/uiapplication.hpp>
#include <eepp/ui/uicheckbox.hpp>
#include <eepp/ui/uiscenenode.hpp>
#include <eepp/ui/uitextview.hpp>
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<UIWidget>( "setting_first" ) );
EXPECT_EQ( nullptr, panel->find<UIWidget>( "setting_second" ) );
panel->build();
EXPECT_TRUE( panel->isBuilt() );
auto* firstRow = panel->find<UIWidget>( "setting_first" );
EXPECT_NE( nullptr, firstRow );
EXPECT_TRUE( firstRow->isVisible() );
EXPECT_EQ( nullptr, panel->find<UIWidget>( "setting_second" ) );
EXPECT_FALSE( panel->addCategory( "late.category", "Late", "Category" ) );
panel->selectCategory( "editor.display" );
auto* secondRow = panel->find<UIWidget>( "setting_second" );
EXPECT_NE( nullptr, secondRow );
EXPECT_FALSE( firstRow->isVisible() );
EXPECT_TRUE( secondRow->isVisible() );
EXPECT_STDSTREQ( "Display",
panel->find<UITextView>( "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<UIWidget>( "setting_first" );
auto* secondRow = panel->find<UIWidget>( "setting_second" );
EXPECT_NE( nullptr, secondRow );
EXPECT_FALSE( firstRow->isVisible() );
EXPECT_TRUE( secondRow->isVisible() );
EXPECT_STDSTREQ( "Filtered Settings",
panel->find<UITextView>( "settings_page_title" )->getText().toUtf8() );
panel->setFilter( {} );
EXPECT_TRUE( firstRow->isVisible() );
EXPECT_FALSE( secondRow->isVisible() );
}

View File

@@ -31,6 +31,7 @@
#include <eepp/ui/uitooltip.hpp>
#include <eepp/ui/uitreeview.hpp>
#include <eepp/ui/uiwidgetcreator.hpp>
#include <eepp/window/clipboard.hpp>
#include <eepp/window/engine.hpp>
#include <nlohmann/json.hpp>

View File

@@ -1,148 +0,0 @@
#ifndef ECODE_SETTINGSMODEL_HPP
#define ECODE_SETTINGSMODEL_HPP
#include <algorithm>
#include <eepp/ee.hpp>
#include <functional>
#include <string>
#include <variant>
#include <vector>
namespace ecode {
struct SettingDescriptor {
std::string id;
std::string category;
String name;
String description;
String group;
};
struct BoolPointerSetting {
bool* value{ nullptr };
std::function<void( bool )> apply;
};
struct BoolSetting {
std::function<bool()> get;
std::function<void( bool )> set;
};
struct ChoiceSetting {
std::vector<String> choices;
std::vector<String> descriptions;
std::function<size_t()> get;
std::function<void( size_t )> set;
};
struct EditableChoiceSetting {
std::vector<String> choices;
std::function<String()> get;
std::function<bool( const String& )> set;
};
struct IntegerSetting {
int min{ 0 };
int max{ 0 };
std::function<int()> get;
std::function<void( int )> set;
};
struct TextSetting {
std::function<std::string()> get;
std::function<bool( const std::string& )> set;
bool commitOnFocusLoss{ false };
bool password{ false };
};
struct FloatSetting {
double min{ 0 };
double max{ 0 };
double step{ 0 };
std::function<double()> get;
std::function<void( double )> set;
};
struct ActionSetting {
String buttonText;
std::function<void()> action;
};
using SettingValue =
std::variant<BoolPointerSetting, BoolSetting, ChoiceSetting, EditableChoiceSetting,
IntegerSetting, TextSetting, FloatSetting, ActionSetting>;
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<SettingsCategory>& categories() const { return mCategories; }
const std::vector<SettingsGroup>& groups() const { return mGroups; }
std::vector<SettingDefinition>& settings() { return mSettings; }
const std::vector<SettingDefinition>& settings() const { return mSettings; }
private:
std::vector<SettingsCategory> mCategories;
std::vector<SettingsGroup> mGroups;
std::vector<SettingDefinition> mSettings;
};
} // namespace ecode
#endif

View File

@@ -2,7 +2,12 @@
#define ECODE_SETTINGSPAGE_HPP
#include "settingsdocument.hpp"
#include "settingsmodel.hpp"
#include <eepp/ui/tools/uisettingspanel.hpp>
using namespace EE;
using namespace EE::System;
using namespace EE::UI::Tools;
namespace ecode {

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,9 @@
#ifndef ECODE_SETTINGSPANEL_HPP
#define ECODE_SETTINGSPANEL_HPP
#include "settingsmodel.hpp"
#include <eepp/ee.hpp>
#include <eepp/scene/mainthreadlifetime.hpp>
#include <functional>
#include <string>
#include <memory>
#include <vector>
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<Model> categoryModel;
UIBindingGroup bindingGroup;
SettingsModel model;
std::vector<std::shared_ptr<SettingsDocument>> documents;
std::vector<std::pair<std::string, std::vector<std::string>>> categoryItems;
UnorderedMap<std::string, std::string> categoryIds;
UnorderedMap<std::string, String> categorySearchText;
UnorderedMap<std::string, String> categoryTitles;
UnorderedMap<std::string, UITextView*> categoryHeadings;
UnorderedMap<std::string, UIWidget*> categorySections;
UnorderedMap<std::string, UILinearLayout*> categoryContainers;
UnorderedSet<std::string> materializedCategories;
std::vector<SubcategoryHeading> subcategoryHeadings;
std::vector<SettingView> 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<void( bool )> apply = {} );
@@ -110,22 +76,11 @@ class SettingsPanel {
void addAction( PanelState& state, SettingDescriptor binding, const String& buttonText,
std::function<void()> 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

View File

@@ -0,0 +1,165 @@
#include "appconfig.hpp"
#include <eepp/system/filesystem.hpp>
#include <eepp/window/window.hpp>
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

View File

@@ -0,0 +1,103 @@
#ifndef ETERM_CONFIG_HPP
#define ETERM_CONFIG_HPP
#include <eepp/graphics/font.hpp>
#include <eepp/graphics/renderer/renderer.hpp>
#include <eepp/math/size.hpp>
#include <eepp/math/vector2.hpp>
#include <eepp/system/inifile.hpp>
#include <eepp/ui/colorschemepreferences.hpp>
#include <eepp/ui/uiscrollview.hpp>
#include <eepp/window/window.hpp>
#include <eterm/terminal/terminaltypes.hpp>
#include <string>
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<Uint32>( 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

View File

@@ -1,116 +1,21 @@
#include <args/args.hxx>
#include <eepp/core/small_vector.hpp>
#include <eepp/ee.hpp>
#include <eepp/ui/iconmanager.hpp>
#include <eepp/ui/tools/uitabwidgetsplitter.hpp>
#include <eepp/ui/tools/uiwidgetinspector.hpp>
#include <eepp/ui/uiapplication.hpp>
#include <eepp/ui/uilinearlayout.hpp>
#include <eepp/ui/uimessagebox.hpp>
#include <eterm/ui/uiterminal.hpp>
#include "eterm.hpp"
#include "settingspanel.hpp"
#include <algorithm>
#include <iostream>
#include <iterator>
#include <map>
#include <unordered_map>
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<std::string> 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<std::string, TerminalColorScheme> terminalColorSchemes;
const TerminalColorScheme* selectedColorScheme{ nullptr };
Float terminalFontSize{ 12 };
bool warnBeforeClose{ false };
bool closeApproved{ false };
bool benchmarkMode{ false };
Clock secondsCounter;
SmallVector<UITab*, 8> 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<UITerminal>()
: 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<void( UITerminal* )>& fn ) {
if ( !tabSplitter )
return;
tabSplitter->forEachWidgetType(
UI_TYPE_TERMINAL, [&fn]( UIWidget* widget ) { fn( widget->asType<UITerminal>() ); } );
}
void App::createNewTerminal() {
auto* current = tabSplitter ? tabSplitter->getCurWidget() : nullptr;
auto* terminal =
current && current->isType( UI_TYPE_TERMINAL ) ? current->asType<UITerminal>() : 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<Uint32>( 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<UIWidget>(),
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<const ContextMenuEvent*>( 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<const ContextMenuEvent*>( 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<eterm::AppConfig>( Sys::getConfigPath( "eterm" ) );
config->load();
args::ArgumentParser parser( "eterm" );
args::HelpFlag help( parser, "help", "Display this help menu", { 'h', "help" } );
args::ValueFlag<std::string> shell( parser, "shell", "Shell name or path", { 's', "shell" },
"" );
config->terminal.shell );
args::ValueFlag<std::string> shellArgs( parser, "shell-args", "Shell command line arguments",
{ "shell-args" }, "" );
{ "shell-args" }, config->terminal.shellArguments );
args::ValueFlag<size_t> 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<std::string> fontPath( parser, "fontpath", "Font path", { 'f', "font" } );
args::ValueFlag<std::string> fontPath( parser, "fontpath", "Font path", { 'f', "font" },
config->font.path );
args::ValueFlag<std::string> fallbackFontPath( parser, "fallback-fontpath",
"Fallback Font path", { "fallback-font" } );
args::ValueFlag<Float> fontSize( parser, "fontsize", "Font size (in dp)", { "fontsize" }, 11 );
"Fallback Font path", { "fallback-font" },
config->font.fallbackPath );
args::ValueFlag<Float> fontSize( parser, "fontsize", "Font size (in dp)", { "fontsize" },
config->font.size );
const std::unordered_map<std::string, FontHinting> fontHintingMap{
{ "none", FontHinting::None },
{ "slight", FontHinting::Slight },
@@ -604,7 +639,7 @@ int EtermApp::run( int argc, char* argv[] ) {
};
args::MapFlag<std::string, FontHinting> 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<std::string, FontAntialiasing> fontAntialiasingMap{
{ "none", FontAntialiasing::None },
{ "grayscale", FontAntialiasing::Grayscale },
@@ -613,33 +648,35 @@ int EtermApp::run( int argc, char* argv[] ) {
args::MapFlag<std::string, FontAntialiasing> fontAntialiasing(
parser, "font-antialiasing",
"Font antialiasing mode (accepted values: none, grayscale, subpixel)",
{ "font-antialiasing" }, fontAntialiasingMap, FontAntialiasing::Grayscale );
args::ValueFlag<Float> width( parser, "winwidth", "Window width (in dp)", { "width" }, 1280 );
{ "font-antialiasing" }, fontAntialiasingMap, config->font.antialiasing );
args::ValueFlag<Float> width( parser, "winwidth", "Window width (in dp)", { "width" },
config->windowState.size.getWidth() );
args::ValueFlag<Float> height( parser, "winheight", "Window height (in dp)", { "height" },
720 );
config->windowState.size.getHeight() );
args::ValueFlag<Float> pixelDensity( parser, "pixel-density",
"Set default application pixel density",
{ 'd', "pixel-density" } );
args::Positional<std::string> 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<std::string> executeInShell(
parser, "execute-in-shell", "execute program in shell", { 'e', "execute" }, "" );
args::ValueFlag<std::string> executeInShell( parser, "execute-in-shell",
"execute program in shell", { 'e', "execute" },
config->terminal.executeInShell );
args::Flag vsync( parser, "vsync", "Enable vsync", { "vsync" } );
args::ValueFlag<std::string> 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<Uint32> 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<std::string, TerminalCursorMode> 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<size_t> 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<int>( width.Get() ),
static_cast<int>( 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<int>( displaySize.getWidth() * 0.8f ) );
if ( displaySize.getHeight() > 0 && windowSize.getHeight() >= displaySize.getHeight() )
windowSize.setHeight( static_cast<int>( 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<Int32>( 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<Int32>( 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<std::string>{};
terminalConfig.program = launchExecutable ? launchFile.getFilepath() : config->terminal.shell;
terminalConfig.arguments = config->terminal.shellArguments.empty()
? std::vector<std::string>{}
: 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<size_t>( 1 ), initialTabs.Get() ); ++tab ) {
for ( size_t tab = 0; tab < eemax( static_cast<size_t>( 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 );
}

112
src/tools/eterm/eterm.hpp Normal file
View File

@@ -0,0 +1,112 @@
#pragma once
#include "appconfig.hpp"
#include <args/args.hxx>
#include <eepp/core/small_vector.hpp>
#include <eepp/ee.hpp>
#include <eepp/ui/iconmanager.hpp>
#include <eepp/ui/tools/uifontpickerdialog.hpp>
#include <eepp/ui/tools/uisettingspanel.hpp>
#include <eepp/ui/tools/uitabwidgetsplitter.hpp>
#include <eepp/ui/tools/uiwidgetinspector.hpp>
#include <eepp/ui/uiapplication.hpp>
#include <eepp/ui/uilinearlayout.hpp>
#include <eepp/ui/uimessagebox.hpp>
#include <eterm/ui/uiterminal.hpp>
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<std::string> 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<void( UITerminal* )>& 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<eterm::AppConfig> config;
std::map<std::string, TerminalColorScheme> terminalColorSchemes;
const TerminalColorScheme* selectedColorScheme{ nullptr };
Float terminalFontSize{ 12 };
bool warnBeforeClose{ false };
bool closeApproved{ false };
bool benchmarkMode{ false };
Clock secondsCounter;
SmallVector<UITab*, 8> pendingExitCloseTabs;
TerminalSplitterClient splitterClient{ *this };
};
} // namespace eterm

View File

@@ -0,0 +1,468 @@
#include "settingspanel.hpp"
#include "eterm.hpp"
#include <eepp/ui/tools/uisettingspanel.hpp>
#include <eepp/ui/uiscenenode.hpp>
#include <eepp/ui/uiwindow.hpp>
#include <string>
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<size_t>( 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<size_t>( 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<Uint32>( 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<String> 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<size_t>( 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<size_t>( 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<size_t>( app.config->terminal.newTerminalBehavior ); },
[&app]( size_t selected ) {
app.config->terminal.newTerminalBehavior =
static_cast<NewTerminalBehavior>( 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<std::string> knownShells{ "bash", "sh", "zsh", "fish", "nu", "csh",
"tcsh", "ksh", "dash", "cmd", "powershell" };
std::vector<String> 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<int>::max(),
[&app] {
return static_cast<int>( std::min<size_t>( app.config->terminal.historySize,
std::numeric_limits<int>::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<String> schemeNames{ app.i18n( "default", "Default" ) };
std::vector<std::string> 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<size_t>( 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<size_t>( 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

View File

@@ -0,0 +1,21 @@
#pragma once
#include <eepp/core/string.hpp>
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