diff --git a/bin/assets/i18n/de.xml b/bin/assets/i18n/de.xml
index 73a0d9918..1a6ab169f 100644
--- a/bin/assets/i18n/de.xml
+++ b/bin/assets/i18n/de.xml
@@ -1121,6 +1121,14 @@ Für sichtbare Änderung ecode neu starten.
Exklusivmodus standardmäßig aktivieren
Deaktiviert globale Tastenkürzel in neu erstellten Terminals.
Dateizuordnungen
+ Anwenden
+ Erweiterung
+ Registriert
+ Anwendungsstarter installieren
+ System-Dateizuordnungen
+ Ecode für die ausgewählten Dateierweiterungen verfügbar machen. Das Betriebssystem kann weiterhin eine Bestätigung der Standardanwendung verlangen.
+ Dateizuordnungen aktualisiert.
+ Dateizuordnungen konnten nicht aktualisiert werden.
Voll
Graustufen
Tableiste ausblenden
diff --git a/bin/assets/i18n/en.xml b/bin/assets/i18n/en.xml
index 86f9c50bc..500223772 100644
--- a/bin/assets/i18n/en.xml
+++ b/bin/assets/i18n/en.xml
@@ -1106,6 +1106,14 @@ Restart ecode to see the changes.
Enable Exclusive Mode by Default
Disable global keybindings in newly created terminals.
File Associations
+ Apply
+ Extension
+ Registered
+ Install application launcher
+ System File Associations
+ Make ecode available for the selected file extensions. The operating system may still ask you to confirm the default application.
+ File associations updated.
+ Could not update file associations.
Full
Grayscale
Hide Tab Bar
diff --git a/bin/assets/i18n/fr.xml b/bin/assets/i18n/fr.xml
index 771ec4d5f..2901b8ef4 100644
--- a/bin/assets/i18n/fr.xml
+++ b/bin/assets/i18n/fr.xml
@@ -1104,6 +1104,14 @@ Redémarrer ecode pour voir les changements.
Activer le mode exclusif par défaut
Désactive les raccourcis globaux dans les nouveaux terminaux.
Associations de fichiers
+ Appliquer
+ Extension
+ Associé
+ Installer le lanceur d’application
+ Associations de fichiers système
+ Rendre ecode disponible pour les extensions sélectionnées. Le système d’exploitation peut encore demander de confirmer l’application par défaut.
+ Associations de fichiers mises à jour.
+ Impossible de mettre à jour les associations de fichiers.
Complet
Niveaux de gris
Masquer la barre d'onglets
diff --git a/bin/assets/i18n/zh.xml b/bin/assets/i18n/zh.xml
index 03873377e..08bbcd4b7 100644
--- a/bin/assets/i18n/zh.xml
+++ b/bin/assets/i18n/zh.xml
@@ -890,6 +890,14 @@ file in the directory tree.
默认启用独占模式
在新建终端中禁用全局快捷键。
文件关联
+ 应用
+ 扩展名
+ 已注册
+ 安装应用程序启动器
+ 系统文件关联
+ 让 ecode 可用于所选文件扩展名。操作系统可能仍会要求确认默认应用程序。
+ 文件关联已更新。
+ 无法更新文件关联。
折叠区域刷新频率
完全
灰度
diff --git a/include/eepp/system.hpp b/include/eepp/system.hpp
index b059bb9a0..ca22be668 100644
--- a/include/eepp/system.hpp
+++ b/include/eepp/system.hpp
@@ -10,6 +10,7 @@
#include
#include
#include
+#include
#include
#include
#include
diff --git a/include/eepp/system/fileassociation.hpp b/include/eepp/system/fileassociation.hpp
new file mode 100644
index 000000000..c763c1666
--- /dev/null
+++ b/include/eepp/system/fileassociation.hpp
@@ -0,0 +1,66 @@
+#ifndef EE_SYSTEM_FILEASSOCIATION_HPP
+#define EE_SYSTEM_FILEASSOCIATION_HPP
+
+#include
+#include
+#include
+#include
+
+namespace EE::System {
+
+struct EE_API FileAssociationApplication {
+ std::string id;
+ std::string name;
+ std::string executablePath;
+ std::string iconPath;
+};
+
+/** Registers a desktop application as a handler for filename extensions.
+ *
+ * Registrations are per-user. On Windows this adds the application to the Open With list, since
+ * current Windows versions do not allow applications to silently change the user's default app.
+ * XDG desktops use a generated desktop entry and the shared MIME database. macOS uses Launch
+ * Services and therefore treats a selected extension as a request to make the application its
+ * editor.
+ */
+class EE_API FileAssociation {
+ public:
+ explicit FileAssociation( FileAssociationApplication application );
+
+ static bool isSupported();
+
+ /** XDG platforms can additionally expose the generated desktop entry in application menus. */
+ static bool supportsDesktopEntries();
+
+ /** Returns the extensions from @p supportedExtensions currently registered for this app. */
+ std::vector
+ getRegisteredExtensions( const std::vector& supportedExtensions ) const;
+
+ /** Makes @p registeredExtensions the registered subset of @p supportedExtensions. */
+ bool setRegisteredExtensions( const std::vector& registeredExtensions,
+ const std::vector& supportedExtensions );
+
+ /** Makes @p registeredExtensions the registered subset of @p supportedExtensions and updates
+ * the desktop-entry visibility in the same operation. @p desktopEntryInstalled is ignored on
+ * platforms that do not support desktop entries. */
+ bool setRegisteredExtensions( const std::vector& registeredExtensions,
+ const std::vector& supportedExtensions,
+ bool desktopEntryInstalled );
+
+ bool isDesktopEntryInstalled() const;
+
+ bool setDesktopEntryInstalled( bool installed );
+
+ const std::string& getLastError() const { return mLastError; }
+
+ /** Returns a lower-case extension without a leading dot, or an empty string if invalid. */
+ static std::string normalizeExtension( std::string_view extension );
+
+ private:
+ FileAssociationApplication mApplication;
+ mutable std::string mLastError;
+};
+
+} // namespace EE::System
+
+#endif
diff --git a/include/eepp/ui/doc/syntaxdefinitionmanager.hpp b/include/eepp/ui/doc/syntaxdefinitionmanager.hpp
index 50256903e..ad23b2798 100644
--- a/include/eepp/ui/doc/syntaxdefinitionmanager.hpp
+++ b/include/eepp/ui/doc/syntaxdefinitionmanager.hpp
@@ -82,6 +82,11 @@ class EE_API SyntaxDefinitionManager {
std::vector getExtensionsPatternsSupported() const;
+ /** Returns the literal filename extensions declared by syntax definitions.
+ * Filename-only patterns and patterns that cannot be represented as extensions are omitted.
+ */
+ std::vector getFileExtensions() const;
+
const SyntaxDefinition* getPtrByLSPName( const std::string& name ) const;
bool loadFromStream( IOStream& stream, std::vector* addedLangs );
diff --git a/include/eepp/ui/tools/uisettingspanel.hpp b/include/eepp/ui/tools/uisettingspanel.hpp
index a11d9122c..fc6930831 100644
--- a/include/eepp/ui/tools/uisettingspanel.hpp
+++ b/include/eepp/ui/tools/uisettingspanel.hpp
@@ -15,7 +15,8 @@ class xml_node;
namespace EE::UI {
class UICheckBox;
-}
+class UIWidget;
+} // namespace EE::UI
namespace EE::UI::Tools {
@@ -79,9 +80,13 @@ struct EE_API ActionSetting {
std::function action;
};
+struct EE_API CustomWidgetSetting {
+ std::function create;
+};
+
using SettingValue =
std::variant;
+ IntegerSetting, TextSetting, FloatSetting, ActionSetting, CustomWidgetSetting>;
struct EE_API SettingDefinition {
SettingDescriptor descriptor;
@@ -165,6 +170,9 @@ class EE_API UISettingsPanel : public UILinearLayout {
bool addAction( SettingDescriptor descriptor, String buttonText, std::function action );
+ bool addCustomWidget( SettingDescriptor descriptor,
+ std::function create );
+
void build();
void selectCategory( const std::string& category );
diff --git a/premake4.lua b/premake4.lua
index 6b77673e5..3b74bcbb4 100644
--- a/premake4.lua
+++ b/premake4.lua
@@ -696,7 +696,7 @@ function generate_os_links()
elseif os.is_real("mingw64") then
multiple_insert( os_links, { "opengl32", "glu32", "gdi32", "ws2_32", "winmm", "ole32", "uuid", "dwrite" } )
elseif os.is_real("macosx") then
- multiple_insert( os_links, { "eepp-macos-helper-static", "Cocoa.framework", "OpenGL.framework", "CoreFoundation.framework", "CoreText.framework" } )
+ multiple_insert( os_links, { "eepp-macos-helper-static", "Cocoa.framework", "OpenGL.framework", "CoreFoundation.framework", "CoreServices.framework", "CoreText.framework" } )
elseif os.is_real("freebsd") then
multiple_insert( os_links, { "rt", "pthread", "GL" } )
elseif os.is_real("haiku") then
diff --git a/premake5.lua b/premake5.lua
index 2a65bffd7..a83ffea23 100644
--- a/premake5.lua
+++ b/premake5.lua
@@ -657,7 +657,7 @@ function generate_os_links()
elseif os.istarget("mingw32") then
multiple_insert( os_links, { "opengl32", "glu32", "gdi32", "ws2_32", "winmm", "ole32", "uuid", "dwrite" } )
elseif os.istarget("macosx") then
- multiple_insert( os_links, { "eepp-macos-helper-static", "Cocoa.framework", "OpenGL.framework", "CoreFoundation.framework", "CoreText.framework" } )
+ multiple_insert( os_links, { "eepp-macos-helper-static", "Cocoa.framework", "OpenGL.framework", "CoreFoundation.framework", "CoreServices.framework", "CoreText.framework" } )
elseif os.istarget("bsd") then
multiple_insert( os_links, { "rt", "pthread", "GL" } )
elseif os.istarget("haiku") then
diff --git a/src/eepp/system/fileassociation.cpp b/src/eepp/system/fileassociation.cpp
new file mode 100644
index 000000000..7eb8ebc31
--- /dev/null
+++ b/src/eepp/system/fileassociation.cpp
@@ -0,0 +1,790 @@
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#if EE_PLATFORM == EE_PLATFORM_WIN
+#include
+
+#include
+#elif EE_PLATFORM == EE_PLATFORM_MACOS
+#include
+#endif
+
+namespace EE::System {
+
+namespace {
+
+static std::vector normalizeExtensions( const std::vector& extensions ) {
+ std::vector normalized;
+ normalized.reserve( extensions.size() );
+ for ( const auto& extension : extensions ) {
+ auto value = FileAssociation::normalizeExtension( extension );
+ if ( !value.empty() )
+ normalized.emplace_back( std::move( value ) );
+ }
+ std::sort( normalized.begin(), normalized.end() );
+ normalized.erase( std::unique( normalized.begin(), normalized.end() ), normalized.end() );
+ return normalized;
+}
+
+#if EE_PLATFORM == EE_PLATFORM_LINUX || EE_PLATFORM == EE_PLATFORM_BSD
+
+static std::string applicationSlug( std::string_view id ) {
+ std::string slug;
+ slug.reserve( id.size() );
+ for ( unsigned char character : id ) {
+ if ( std::isalnum( character ) )
+ slug += static_cast( std::tolower( character ) );
+ else if ( slug.empty() || slug.back() != '-' )
+ slug += '-';
+ }
+ while ( !slug.empty() && slug.back() == '-' )
+ slug.pop_back();
+ return slug.empty() ? "application" : slug;
+}
+
+static std::string xdgDataHome() {
+ auto path = Sys::getEnv( "XDG_DATA_HOME" );
+ if ( path.empty() ) {
+ path = Sys::getUserDirectory();
+ FileSystem::dirAddSlashAtEnd( path );
+ path += ".local/share";
+ }
+ FileSystem::dirRemoveSlashAtEnd( path );
+ return path;
+}
+
+static std::string xdgConfigHome() {
+ auto path = Sys::getEnv( "XDG_CONFIG_HOME" );
+ if ( path.empty() ) {
+ path = Sys::getUserDirectory();
+ FileSystem::dirAddSlashAtEnd( path );
+ path += ".config";
+ }
+ FileSystem::dirRemoveSlashAtEnd( path );
+ return path;
+}
+
+static std::string desktopEntryName( const FileAssociationApplication& application ) {
+ return applicationSlug( application.id ) + ".desktop";
+}
+
+static std::string desktopEntryPath( const FileAssociationApplication& application ) {
+ return xdgDataHome() + "/applications/" + desktopEntryName( application );
+}
+
+static std::string mimePackagePath( const FileAssociationApplication& application ) {
+ return xdgDataHome() + "/mime/packages/" + applicationSlug( application.id ) + ".xml";
+}
+
+static std::string desktopEscape( std::string_view value ) {
+ std::string escaped;
+ escaped.reserve( value.size() + 8 );
+ for ( char character : value ) {
+ switch ( character ) {
+ case '\\':
+ case '"':
+ case '`':
+ case '$':
+ escaped += '\\';
+ [[fallthrough]];
+ default:
+ escaped += character;
+ }
+ }
+ return escaped;
+}
+
+static std::string desktopExecPath( std::string_view path ) {
+ if ( path.find_first_of( " \t\n\"'\\><~|&;$*?#()`" ) == std::string_view::npos )
+ return std::string( path );
+ auto environment = Sys::which( "env" );
+ if ( environment.empty() )
+ environment = "env";
+ return environment + " \"" + desktopEscape( path ) + '"';
+}
+
+static std::string xmlEscape( std::string_view value ) {
+ std::string escaped;
+ escaped.reserve( value.size() );
+ for ( char character : value ) {
+ switch ( character ) {
+ case '&':
+ escaped += "&";
+ break;
+ case '<':
+ escaped += "<";
+ break;
+ case '>':
+ escaped += ">";
+ break;
+ case '"':
+ escaped += """;
+ break;
+ default:
+ escaped += character;
+ }
+ }
+ return escaped;
+}
+
+static std::vector desktopExtensions( const FileAssociationApplication& application ) {
+ std::string contents;
+ if ( !FileSystem::fileGet( desktopEntryPath( application ), contents ) )
+ return {};
+ constexpr std::string_view key = "X-EEPP-File-Extensions=";
+ auto position = contents.find( key );
+ if ( position == std::string::npos )
+ return {};
+ position += key.size();
+ auto end = contents.find( '\n', position );
+ auto values = String::split( contents.substr( position, end - position ), ';' );
+ return normalizeExtensions( values );
+}
+
+static bool desktopEntryVisible( const FileAssociationApplication& application ) {
+ std::string contents;
+ if ( !FileSystem::fileGet( desktopEntryPath( application ), contents ) )
+ return false;
+ return contents.find( "NoDisplay=false" ) != std::string::npos;
+}
+
+static void loadMimeGlobs( const std::string& path, const std::unordered_set& wanted,
+ std::unordered_map>& types ) {
+ std::string contents;
+ if ( !FileSystem::fileGet( path, contents ) )
+ return;
+ for ( const auto& line : String::split( contents, '\n' ) ) {
+ if ( line.empty() || line[0] == '#' )
+ continue;
+ auto fields = String::split( line, ':' );
+ if ( fields.size() < 2 )
+ continue;
+ int weight = 50;
+ std::string mime;
+ std::string glob;
+ if ( fields.size() >= 3 ) {
+ Int32 parsedWeight = weight;
+ String::fromString( parsedWeight, fields[0] );
+ weight = parsedWeight;
+ mime = fields[1];
+ glob = fields[2];
+ } else {
+ mime = fields[0];
+ glob = fields[1];
+ }
+ if ( !String::startsWith( glob, "*." ) )
+ continue;
+ auto extension = FileAssociation::normalizeExtension( glob.substr( 2 ) );
+ if ( extension.empty() || !wanted.contains( extension ) )
+ continue;
+ auto found = types.find( extension );
+ if ( found == types.end() || found->second.first < weight )
+ types[extension] = { weight, std::move( mime ) };
+ }
+}
+
+static std::unordered_map
+mimeTypesForExtensions( const std::vector& extensions,
+ const FileAssociationApplication& application ) {
+ std::unordered_set wanted( extensions.begin(), extensions.end() );
+ std::unordered_map> weightedTypes;
+ std::vector dataDirectories{ xdgDataHome() };
+ auto systemDirectories = Sys::getEnv( "XDG_DATA_DIRS" );
+ if ( systemDirectories.empty() )
+ systemDirectories = "/usr/local/share:/usr/share";
+ for ( auto& directory : String::split( systemDirectories, ':' ) )
+ dataDirectories.emplace_back( std::move( directory ) );
+ for ( auto& directory : dataDirectories ) {
+ FileSystem::dirRemoveSlashAtEnd( directory );
+ loadMimeGlobs( directory + "/mime/globs2", wanted, weightedTypes );
+ loadMimeGlobs( directory + "/mime/globs", wanted, weightedTypes );
+ }
+
+ std::unordered_map types;
+ types.reserve( extensions.size() );
+ const auto slug = applicationSlug( application.id );
+ for ( const auto& extension : extensions ) {
+ auto found = weightedTypes.find( extension );
+ types[extension] = found != weightedTypes.end()
+ ? std::move( found->second.second )
+ : "application/x-" + slug + '-' + applicationSlug( extension );
+ }
+ return types;
+}
+
+static bool runDatabaseUpdate( const std::string& command, const std::string& directory ) {
+ auto executable = Sys::which( command );
+ if ( executable.empty() )
+ return true;
+ Process process;
+ if ( !process.create( executable, std::vector{ directory },
+ Process::getDefaultOptions() ) )
+ return false;
+ int exitCode = -1;
+ return process.join( &exitCode ) && exitCode == 0;
+}
+
+static bool writeFileIfChanged( const std::string& path, const std::string& contents,
+ bool& changed ) {
+ std::string current;
+ if ( FileSystem::fileExists( path ) ) {
+ if ( !FileSystem::fileGet( path, current ) )
+ return false;
+ if ( current == contents )
+ return true;
+ }
+ if ( !FileSystem::fileWrite( path, contents ) )
+ return false;
+ changed = true;
+ return true;
+}
+
+static bool removeFileIfExists( const std::string& path, bool& changed ) {
+ if ( !FileSystem::fileExists( path ) )
+ return true;
+ if ( !FileSystem::fileRemove( path ) )
+ return false;
+ changed = true;
+ return true;
+}
+
+static void updateMimeAppsSection( std::vector& lines, std::string_view sectionName,
+ const std::unordered_set& selected,
+ const std::unordered_set& removed,
+ std::string_view desktopName, bool addSelected ) {
+ const std::string header = '[' + std::string( sectionName ) + ']';
+ auto section = std::find( lines.begin(), lines.end(), header );
+ if ( section == lines.end() ) {
+ if ( !addSelected || selected.empty() )
+ return;
+ if ( !lines.empty() && !lines.back().empty() )
+ lines.emplace_back();
+ lines.emplace_back( header );
+ section = std::prev( lines.end() );
+ }
+
+ auto sectionEnd =
+ std::find_if( std::next( section ), lines.end(), []( const std::string& line ) {
+ return line.size() >= 2 && line.front() == '[' && line.back() == ']';
+ } );
+ std::unordered_set found;
+ for ( auto line = std::next( section ); line != sectionEnd; ) {
+ const auto separator = line->find( '=' );
+ if ( separator == std::string::npos ) {
+ ++line;
+ continue;
+ }
+ const auto mime = line->substr( 0, separator );
+ const bool isSelected = selected.contains( mime );
+ if ( !isSelected && !removed.contains( mime ) ) {
+ ++line;
+ continue;
+ }
+ found.emplace( mime );
+ auto applications = String::split( line->substr( separator + 1 ), ';' );
+ applications.erase( std::remove( applications.begin(), applications.end(), desktopName ),
+ applications.end() );
+ if ( addSelected && isSelected )
+ applications.insert( applications.begin(), std::string( desktopName ) );
+ if ( applications.empty() ) {
+ line = lines.erase( line );
+ sectionEnd = std::find_if( line, lines.end(), []( const std::string& value ) {
+ return value.size() >= 2 && value.front() == '[' && value.back() == ']';
+ } );
+ continue;
+ }
+ *line = mime + '=';
+ for ( const auto& application : applications )
+ *line += application + ';';
+ ++line;
+ }
+
+ if ( addSelected ) {
+ std::vector missing;
+ for ( const auto& mime : selected ) {
+ if ( !found.contains( mime ) )
+ missing.emplace_back( mime );
+ }
+ std::sort( missing.begin(), missing.end() );
+ for ( const auto& mime : missing ) {
+ sectionEnd = lines.insert( sectionEnd, mime + '=' + std::string( desktopName ) + ';' );
+ ++sectionEnd;
+ }
+ }
+}
+
+static bool updateMimeAppsFile( const std::string& path,
+ const std::unordered_set& selected,
+ const std::unordered_set& removed,
+ std::string_view desktopName, std::string& error ) {
+ std::string contents;
+ if ( FileSystem::fileExists( path ) && !FileSystem::fileGet( path, contents ) ) {
+ error = "Could not read the XDG MIME application preferences.";
+ return false;
+ }
+ auto lines = String::split( contents, '\n', true );
+ if ( lines.size() == 1 && lines.front().empty() )
+ lines.clear();
+ updateMimeAppsSection( lines, "Default Applications", selected, removed, desktopName, true );
+ updateMimeAppsSection( lines, "Added Associations", selected, removed, desktopName, true );
+ updateMimeAppsSection( lines, "Removed Associations", selected, removed, desktopName, false );
+ std::string updated;
+ for ( const auto& line : lines )
+ updated += line + '\n';
+ if ( updated == contents )
+ return true;
+ if ( !FileSystem::fileWrite( path, updated ) ) {
+ error = "Could not write the XDG MIME application preferences.";
+ return false;
+ }
+ return true;
+}
+
+static bool updateMimeAppsRegistration( const FileAssociationApplication& application,
+ const std::unordered_set& selected,
+ const std::unordered_set& removed,
+ std::string& error ) {
+ const auto configHome = xdgConfigHome();
+ if ( !FileSystem::isDirectory( configHome ) && !FileSystem::makeDir( configHome, true ) ) {
+ error = "Could not create the XDG configuration directory.";
+ return false;
+ }
+ std::vector paths{ configHome + "/mimeapps.list" };
+ for ( auto desktop : String::split( Sys::getEnv( "XDG_CURRENT_DESKTOP" ), ':' ) ) {
+ String::toLowerInPlace( desktop );
+ if ( !desktop.empty() )
+ paths.emplace_back( configHome + '/' + desktop + "-mimeapps.list" );
+ }
+ const auto legacyPath = xdgDataHome() + "/applications/mimeapps.list";
+ if ( FileSystem::fileExists( legacyPath ) )
+ paths.emplace_back( legacyPath );
+ std::sort( paths.begin(), paths.end() );
+ paths.erase( std::unique( paths.begin(), paths.end() ), paths.end() );
+ for ( const auto& path : paths ) {
+ if ( !updateMimeAppsFile( path, selected, removed, desktopEntryName( application ),
+ error ) )
+ return false;
+ }
+ return true;
+}
+
+static bool writeXdgRegistration( const FileAssociationApplication& application,
+ const std::vector& extensions, bool visible,
+ std::string& error ) {
+ const auto previousExtensions = desktopExtensions( application );
+ const auto dataHome = xdgDataHome();
+ const auto applicationsDirectory = dataHome + "/applications";
+ const auto mimeDirectory = dataHome + "/mime";
+ const auto packageDirectory = mimeDirectory + "/packages";
+ if ( ( !FileSystem::isDirectory( applicationsDirectory ) &&
+ !FileSystem::makeDir( applicationsDirectory, true ) ) ||
+ ( !FileSystem::isDirectory( packageDirectory ) &&
+ !FileSystem::makeDir( packageDirectory, true ) ) ) {
+ error = "Could not create the XDG application directories.";
+ return false;
+ }
+
+ const auto previousMimeTypes = mimeTypesForExtensions( previousExtensions, application );
+ const auto mimeTypes = mimeTypesForExtensions( extensions, application );
+ std::unordered_set uniqueMimeTypes;
+ std::string package =
+ "\n"
+ "\n";
+ const auto slug = applicationSlug( application.id );
+ bool hasCustomMimeTypes = false;
+ for ( const auto& extension : extensions ) {
+ const auto& mime = mimeTypes.at( extension );
+ uniqueMimeTypes.insert( mime );
+ if ( !String::startsWith( mime, "application/x-" + slug + '-' ) )
+ continue;
+ hasCustomMimeTypes = true;
+ package += " \n";
+ package += " " + xmlEscape( application.name ) + " " + extension +
+ " document\n";
+ package += " \n";
+ package += " \n";
+ }
+ package += "\n";
+ const auto packagePath = mimePackagePath( application );
+ bool mimeDatabaseChanged = false;
+ if ( hasCustomMimeTypes ) {
+ if ( !writeFileIfChanged( packagePath, package, mimeDatabaseChanged ) ) {
+ error = "Could not write the XDG MIME package.";
+ return false;
+ }
+ } else if ( !removeFileIfExists( packagePath, mimeDatabaseChanged ) ) {
+ error = "Could not remove the obsolete XDG MIME package.";
+ return false;
+ }
+
+ const auto desktopPath = desktopEntryPath( application );
+ bool desktopDatabaseChanged = false;
+ if ( extensions.empty() && !visible ) {
+ if ( !removeFileIfExists( desktopPath, desktopDatabaseChanged ) ) {
+ error = "Could not remove the XDG desktop entry.";
+ return false;
+ }
+ } else {
+ std::vector sortedMimeTypes( uniqueMimeTypes.begin(), uniqueMimeTypes.end() );
+ std::sort( sortedMimeTypes.begin(), sortedMimeTypes.end() );
+ std::string desktop = "[Desktop Entry]\nType=Application\nVersion=1.0\n";
+ desktop += "Name=" + desktopEscape( application.name ) + "\n";
+ desktop += "Comment=Edit source code and text files\n";
+ desktop += "Exec=" + desktopExecPath( application.executablePath ) + " %F\n";
+ desktop += "TryExec=" + application.executablePath + "\n";
+ if ( !application.iconPath.empty() )
+ desktop += "Icon=" + application.iconPath + "\n";
+ desktop += "Terminal=false\nCategories=Development;TextEditor;\n";
+ desktop += std::string( "NoDisplay=" ) + ( visible ? "false\n" : "true\n" );
+ desktop += "MimeType=";
+ for ( const auto& mime : sortedMimeTypes )
+ desktop += mime + ';';
+ desktop += "\nX-EEPP-File-Extensions=";
+ for ( const auto& extension : extensions )
+ desktop += extension + ';';
+ desktop += '\n';
+ if ( !writeFileIfChanged( desktopPath, desktop, desktopDatabaseChanged ) ) {
+ error = "Could not write the XDG desktop entry.";
+ return false;
+ }
+ }
+
+ if ( ( mimeDatabaseChanged && !runDatabaseUpdate( "update-mime-database", mimeDirectory ) ) ||
+ ( desktopDatabaseChanged &&
+ !runDatabaseUpdate( "update-desktop-database", applicationsDirectory ) ) ) {
+ error = "The XDG registration was written, but a desktop database update failed.";
+ return false;
+ }
+ std::unordered_set selectedMimeTypes;
+ for ( const auto& [extension, mime] : mimeTypes )
+ selectedMimeTypes.emplace( mime );
+ std::unordered_set removedMimeTypes;
+ for ( const auto& [extension, mime] : previousMimeTypes ) {
+ if ( !selectedMimeTypes.contains( mime ) )
+ removedMimeTypes.emplace( mime );
+ }
+ if ( !updateMimeAppsRegistration( application, selectedMimeTypes, removedMimeTypes, error ) )
+ return false;
+ return true;
+}
+
+#elif EE_PLATFORM == EE_PLATFORM_WIN
+
+static std::wstring toWide( std::string_view value ) {
+ return String::fromUtf8( std::string( value ) ).toWideString();
+}
+
+static bool setRegistryString( HKEY root, const std::wstring& key, const wchar_t* valueName,
+ const std::wstring& value, std::string& error ) {
+ HKEY handle = nullptr;
+ auto status = RegCreateKeyExW( root, key.c_str(), 0, nullptr, 0, KEY_SET_VALUE, nullptr,
+ &handle, nullptr );
+ if ( status != ERROR_SUCCESS ) {
+ error = "Could not create a file-association registry key (error " +
+ std::to_string( status ) + ").";
+ return false;
+ }
+ status = RegSetValueExW( handle, valueName, 0, REG_SZ,
+ reinterpret_cast( value.c_str() ),
+ static_cast( ( value.size() + 1 ) * sizeof( wchar_t ) ) );
+ RegCloseKey( handle );
+ if ( status != ERROR_SUCCESS ) {
+ error = "Could not write a file-association registry value (error " +
+ std::to_string( status ) + ").";
+ return false;
+ }
+ return true;
+}
+
+static bool registryValueExists( const std::wstring& key, const std::wstring& valueName ) {
+ HKEY handle = nullptr;
+ if ( RegOpenKeyExW( HKEY_CURRENT_USER, key.c_str(), 0, KEY_QUERY_VALUE, &handle ) !=
+ ERROR_SUCCESS )
+ return false;
+ const auto status =
+ RegQueryValueExW( handle, valueName.c_str(), nullptr, nullptr, nullptr, nullptr );
+ RegCloseKey( handle );
+ return status == ERROR_SUCCESS;
+}
+
+static bool deleteRegistryValue( const std::wstring& key, const std::wstring& valueName,
+ std::string& error ) {
+ HKEY handle = nullptr;
+ if ( RegOpenKeyExW( HKEY_CURRENT_USER, key.c_str(), 0, KEY_SET_VALUE, &handle ) !=
+ ERROR_SUCCESS )
+ return true;
+ const auto status = RegDeleteValueW( handle, valueName.c_str() );
+ RegCloseKey( handle );
+ if ( status != ERROR_SUCCESS && status != ERROR_FILE_NOT_FOUND ) {
+ error = "Could not remove a file-association registry value (error " +
+ std::to_string( status ) + ").";
+ return false;
+ }
+ return true;
+}
+
+#elif EE_PLATFORM == EE_PLATFORM_MACOS
+
+class CFRef {
+ public:
+ explicit CFRef( CFTypeRef value = nullptr ) : mValue( value ) {}
+ ~CFRef() {
+ if ( mValue )
+ CFRelease( mValue );
+ }
+ CFRef( const CFRef& ) = delete;
+ CFRef& operator=( const CFRef& ) = delete;
+ CFTypeRef get() const { return mValue; }
+
+ private:
+ CFTypeRef mValue;
+};
+
+static CFStringRef cfString( const std::string& value ) {
+ return CFStringCreateWithCString( kCFAllocatorDefault, value.c_str(), kCFStringEncodingUTF8 );
+}
+
+static CFStringRef typeForExtension( const std::string& extension ) {
+ CFRef extensionString( cfString( extension ) );
+ if ( !extensionString.get() )
+ return nullptr;
+ return UTTypeCreatePreferredIdentifierForTag(
+ kUTTagClassFilenameExtension, static_cast( extensionString.get() ), nullptr );
+}
+
+#endif
+
+} // namespace
+
+FileAssociation::FileAssociation( FileAssociationApplication application ) :
+ mApplication( std::move( application ) ) {}
+
+bool FileAssociation::isSupported() {
+#if EE_PLATFORM == EE_PLATFORM_WIN || EE_PLATFORM == EE_PLATFORM_MACOS || \
+ EE_PLATFORM == EE_PLATFORM_LINUX || EE_PLATFORM == EE_PLATFORM_BSD
+ return true;
+#else
+ return false;
+#endif
+}
+
+bool FileAssociation::supportsDesktopEntries() {
+#if EE_PLATFORM == EE_PLATFORM_LINUX || EE_PLATFORM == EE_PLATFORM_BSD
+ return true;
+#else
+ return false;
+#endif
+}
+
+std::string FileAssociation::normalizeExtension( std::string_view extension ) {
+ while ( !extension.empty() && extension.front() == '.' )
+ extension.remove_prefix( 1 );
+ if ( extension.empty() || extension.size() > 64 )
+ return {};
+ std::string normalized;
+ normalized.reserve( extension.size() );
+ for ( unsigned char character : extension ) {
+ if ( !std::isalnum( character ) && character != '_' && character != '-' &&
+ character != '+' && character != '.' )
+ return {};
+ normalized += static_cast( std::tolower( character ) );
+ }
+ return normalized;
+}
+
+std::vector FileAssociation::getRegisteredExtensions(
+ const std::vector& supportedExtensions ) const {
+ mLastError.clear();
+ const auto supported = normalizeExtensions( supportedExtensions );
+ std::vector registered;
+#if EE_PLATFORM == EE_PLATFORM_LINUX || EE_PLATFORM == EE_PLATFORM_BSD
+ const auto current = desktopExtensions( mApplication );
+ std::set_intersection( supported.begin(), supported.end(), current.begin(), current.end(),
+ std::back_inserter( registered ) );
+#elif EE_PLATFORM == EE_PLATFORM_WIN
+ const auto progId = toWide( mApplication.id );
+ for ( const auto& extension : supported ) {
+ if ( registryValueExists(
+ L"Software\\Classes\\." + toWide( extension ) + L"\\OpenWithProgids", progId ) )
+ registered.emplace_back( extension );
+ }
+#elif EE_PLATFORM == EE_PLATFORM_MACOS
+ CFRef applicationId( cfString( mApplication.id ) );
+ if ( !applicationId.get() ) {
+ mLastError = "The application identifier is not valid UTF-8.";
+ return {};
+ }
+ for ( const auto& extension : supported ) {
+ CFRef type( typeForExtension( extension ) );
+ if ( !type.get() )
+ continue;
+ CFRef handler( LSCopyDefaultRoleHandlerForContentType(
+ static_cast( type.get() ), kLSRolesEditor | kLSRolesViewer ) );
+ if ( handler.get() && CFEqual( handler.get(), applicationId.get() ) )
+ registered.emplace_back( extension );
+ }
+#else
+ (void)supported;
+ mLastError = "File associations are not supported on this platform.";
+#endif
+ return registered;
+}
+
+bool FileAssociation::setRegisteredExtensions(
+ const std::vector& registeredExtensions,
+ const std::vector& supportedExtensions ) {
+ return setRegisteredExtensions( registeredExtensions, supportedExtensions,
+ supportsDesktopEntries() ? isDesktopEntryInstalled() : false );
+}
+
+bool FileAssociation::setRegisteredExtensions( const std::vector& registeredExtensions,
+ const std::vector& supportedExtensions,
+ bool desktopEntryInstalled ) {
+ mLastError.clear();
+ const auto supported = normalizeExtensions( supportedExtensions );
+ const auto requested = normalizeExtensions( registeredExtensions );
+ std::vector selected;
+ selected.reserve( std::min( requested.size(), supported.size() ) );
+ std::set_intersection( requested.begin(), requested.end(), supported.begin(), supported.end(),
+ std::back_inserter( selected ) );
+#if EE_PLATFORM == EE_PLATFORM_LINUX || EE_PLATFORM == EE_PLATFORM_BSD
+ return writeXdgRegistration( mApplication, selected, desktopEntryInstalled, mLastError );
+#elif EE_PLATFORM == EE_PLATFORM_WIN
+ (void)desktopEntryInstalled;
+ const auto progId = toWide( mApplication.id );
+ const auto executable = toWide( mApplication.executablePath );
+ const auto executableName =
+ toWide( FileSystem::fileNameFromPath( mApplication.executablePath ) );
+ const auto classes = std::wstring( L"Software\\Classes\\" );
+ const auto applicationKey = classes + L"Applications\\" + executableName;
+ const auto command = L"\"" + executable + L"\" \"%1\"";
+ if ( !setRegistryString( HKEY_CURRENT_USER, classes + progId, nullptr,
+ toWide( mApplication.name + " document" ), mLastError ) ||
+ !setRegistryString( HKEY_CURRENT_USER, applicationKey, L"FriendlyAppName",
+ toWide( mApplication.name ), mLastError ) ||
+ !setRegistryString( HKEY_CURRENT_USER, classes + progId + L"\\shell\\open\\command",
+ nullptr, command, mLastError ) ||
+ !setRegistryString( HKEY_CURRENT_USER, applicationKey + L"\\shell\\open\\command", nullptr,
+ command, mLastError ) )
+ return false;
+ if ( !mApplication.iconPath.empty() &&
+ ( !setRegistryString( HKEY_CURRENT_USER, classes + progId + L"\\DefaultIcon", nullptr,
+ toWide( mApplication.iconPath ), mLastError ) ||
+ !setRegistryString( HKEY_CURRENT_USER, applicationKey + L"\\DefaultIcon", nullptr,
+ toWide( mApplication.iconPath ), mLastError ) ) )
+ return false;
+ for ( const auto& extension : supported ) {
+ const bool shouldRegister =
+ std::binary_search( selected.begin(), selected.end(), extension );
+ const auto dotExtension = toWide( '.' + extension );
+ const auto openWithKey = classes + dotExtension + L"\\OpenWithProgids";
+ const auto supportedTypesKey = applicationKey + L"\\SupportedTypes";
+ if ( shouldRegister ) {
+ if ( !setRegistryString( HKEY_CURRENT_USER, openWithKey, progId.c_str(), L"",
+ mLastError ) ||
+ !setRegistryString( HKEY_CURRENT_USER, supportedTypesKey, dotExtension.c_str(),
+ L"", mLastError ) )
+ return false;
+ } else if ( !deleteRegistryValue( openWithKey, progId, mLastError ) ||
+ !deleteRegistryValue( supportedTypesKey, dotExtension, mLastError ) ) {
+ return false;
+ }
+ }
+ SHChangeNotify( SHCNE_ASSOCCHANGED, SHCNF_IDLIST, nullptr, nullptr );
+ return true;
+#elif EE_PLATFORM == EE_PLATFORM_MACOS
+ (void)desktopEntryInstalled;
+ CFRef bundleUrl( CFBundleCopyBundleURL( CFBundleGetMainBundle() ) );
+ if ( !bundleUrl.get() ||
+ LSRegisterURL( static_cast( bundleUrl.get() ), true ) != noErr ) {
+ mLastError = "Launch Services could not register the application bundle.";
+ return false;
+ }
+ CFRef applicationId( cfString( mApplication.id ) );
+ if ( !applicationId.get() ) {
+ mLastError = "The application identifier is not valid UTF-8.";
+ return false;
+ }
+ for ( const auto& extension : supported ) {
+ CFRef type( typeForExtension( extension ) );
+ if ( !type.get() )
+ continue;
+ const bool shouldRegister =
+ std::binary_search( selected.begin(), selected.end(), extension );
+ if ( shouldRegister ) {
+ const auto status = LSSetDefaultRoleHandlerForContentType(
+ static_cast( type.get() ), kLSRolesEditor | kLSRolesViewer,
+ static_cast( applicationId.get() ) );
+ if ( status != noErr ) {
+ mLastError = "Launch Services could not set a file association (error " +
+ std::to_string( status ) + ").";
+ return false;
+ }
+ continue;
+ }
+ CFRef current( LSCopyDefaultRoleHandlerForContentType(
+ static_cast( type.get() ), kLSRolesEditor | kLSRolesViewer ) );
+ if ( !current.get() || !CFEqual( current.get(), applicationId.get() ) )
+ continue;
+ CFRef handlers( LSCopyAllRoleHandlersForContentType( static_cast( type.get() ),
+ kLSRolesEditor | kLSRolesViewer ) );
+ if ( !handlers.get() )
+ continue;
+ auto array = static_cast( handlers.get() );
+ bool reassigned = false;
+ for ( CFIndex index = 0; index < CFArrayGetCount( array ); ++index ) {
+ auto handler = static_cast( CFArrayGetValueAtIndex( array, index ) );
+ if ( CFEqual( handler, applicationId.get() ) )
+ continue;
+ if ( LSSetDefaultRoleHandlerForContentType( static_cast( type.get() ),
+ kLSRolesEditor | kLSRolesViewer,
+ handler ) == noErr ) {
+ reassigned = true;
+ break;
+ }
+ }
+ if ( !reassigned ) {
+ mLastError = "Launch Services has no alternative handler for ." + extension + ".";
+ return false;
+ }
+ }
+ return true;
+#else
+ (void)selected;
+ (void)desktopEntryInstalled;
+ (void)supported;
+ mLastError = "File associations are not supported on this platform.";
+ return false;
+#endif
+}
+
+bool FileAssociation::isDesktopEntryInstalled() const {
+#if EE_PLATFORM == EE_PLATFORM_LINUX || EE_PLATFORM == EE_PLATFORM_BSD
+ return desktopEntryVisible( mApplication );
+#else
+ return false;
+#endif
+}
+
+bool FileAssociation::setDesktopEntryInstalled( bool installed ) {
+ mLastError.clear();
+#if EE_PLATFORM == EE_PLATFORM_LINUX || EE_PLATFORM == EE_PLATFORM_BSD
+ return writeXdgRegistration( mApplication, desktopExtensions( mApplication ), installed,
+ mLastError );
+#else
+ (void)installed;
+ mLastError = "Desktop entry installation is not supported on this platform.";
+ return false;
+#endif
+}
+
+} // namespace EE::System
diff --git a/src/eepp/ui/doc/syntaxdefinitionmanager.cpp b/src/eepp/ui/doc/syntaxdefinitionmanager.cpp
index e8c8c7983..4e4fd32d2 100644
--- a/src/eepp/ui/doc/syntaxdefinitionmanager.cpp
+++ b/src/eepp/ui/doc/syntaxdefinitionmanager.cpp
@@ -20,6 +20,7 @@
#include
#include
+#include
#include
#include
@@ -706,6 +707,60 @@ std::vector SyntaxDefinitionManager::getExtensionsPatternsSupported
return vexts;
}
+static std::vector fileExtensionsFromPattern( std::string_view pattern ) {
+ if ( pattern.size() < 4 || !pattern.starts_with( "%." ) || pattern.back() != '$' )
+ return {};
+ pattern.remove_prefix( 2 );
+ pattern.remove_suffix( 1 );
+ std::vector extensions( 1 );
+ for ( size_t i = 0; i < pattern.size(); ++i ) {
+ char character = pattern[i];
+ if ( character == '%' ) {
+ if ( ++i >= pattern.size() || std::isalnum( static_cast( pattern[i] ) ) )
+ return {};
+ character = pattern[i];
+ } else if ( !std::isalnum( static_cast( character ) ) && character != '_' ) {
+ return {};
+ }
+ for ( auto& extension : extensions )
+ extension += character;
+ if ( i + 1 < pattern.size() && pattern[i + 1] == '?' ) {
+ const auto variants = extensions.size();
+ for ( size_t variant = 0; variant < variants; ++variant ) {
+ auto withoutOptional = extensions[variant];
+ withoutOptional.pop_back();
+ extensions.emplace_back( std::move( withoutOptional ) );
+ }
+ ++i;
+ }
+ }
+ return extensions;
+}
+
+std::vector SyntaxDefinitionManager::getFileExtensions() const {
+ Lock l( mMutex );
+ std::unordered_set extensions;
+ auto addPatterns = [&extensions]( const auto& definition ) {
+ for ( const auto& pattern : definition.getFiles() ) {
+ for ( auto& extension : fileExtensionsFromPattern( pattern ) ) {
+ String::toLowerInPlace( extension );
+ extensions.emplace( std::move( extension ) );
+ }
+ }
+ };
+ for ( const auto& definition : mDefinitions )
+ addPatterns( *definition );
+ for ( const auto& definition : mPreDefinitions )
+ addPatterns( definition );
+
+ std::vector sortedExtensions;
+ sortedExtensions.reserve( extensions.size() );
+ for ( auto& extension : extensions )
+ sortedExtensions.emplace_back( std::move( extension ) );
+ std::sort( sortedExtensions.begin(), sortedExtensions.end() );
+ return sortedExtensions;
+}
+
const SyntaxDefinition* SyntaxDefinitionManager::getPtrByLSPName( const std::string& name ) const {
Lock l( mMutex );
for ( const auto& definition : mDefinitions ) {
diff --git a/src/eepp/ui/tools/uisettingspanel.cpp b/src/eepp/ui/tools/uisettingspanel.cpp
index 93639f8b7..c4f7af922 100644
--- a/src/eepp/ui/tools/uisettingspanel.cpp
+++ b/src/eepp/ui/tools/uisettingspanel.cpp
@@ -308,6 +308,15 @@ static const SettingsLayoutTemplate SETTINGS_TEXT_ROW_LAYOUT( settingsRowLayout(
R"xml()xml" ) );
static const SettingsLayoutTemplate SETTINGS_ACTION_ROW_LAYOUT( settingsRowLayout(
R"xml()xml" ) );
+static const SettingsLayoutTemplate SETTINGS_CUSTOM_WIDGET_ROW_LAYOUT( R"xml(
+
+
+
+
+
+
+
+)xml" );
static void disableTabFocusTree( Node* node ) {
if ( node->isWidget() )
@@ -501,6 +510,13 @@ bool UISettingsPanel::addAction( SettingDescriptor descriptor, String buttonText
ActionSetting{ std::move( buttonText ), std::move( action ) } } );
}
+bool UISettingsPanel::addCustomWidget( SettingDescriptor descriptor,
+ std::function create ) {
+ return !mImpl->built && create &&
+ mImpl->model.addSetting(
+ { std::move( descriptor ), CustomWidgetSetting{ std::move( create ) } } );
+}
+
void UISettingsPanel::build() {
if ( mImpl->built )
return;
@@ -723,7 +739,7 @@ void UISettingsPanel::materializeCategory( Impl& panel, const std::string& categ
if ( auto* value = std::get_if( &setting.value ) ) {
auto* check = createBoolControl( panel, setting, view );
auto binding = UIDataBind::New( value->value, check,
- UIValueConverter::converterBool() );
+ UIValueConverter::converterBool() );
binding->onValueChangeCb = value->apply;
panel.bindingGroup += std::move( binding );
} else if ( auto* value = std::get_if( &setting.value ) ) {
@@ -832,6 +848,9 @@ void UISettingsPanel::materializeCategory( Impl& panel, const std::string& categ
if ( event->asMouseEvent()->getFlags() & EE_BUTTON_LMASK )
value->action();
} );
+ } else if ( auto* value = std::get_if( &setting.value ) ) {
+ auto* row = createRow( panel, setting, view, SETTINGS_CUSTOM_WIDGET_ROW_LAYOUT.root() );
+ value->create( row->find( "setting_custom_widget" ) );
}
if ( view.row && !setting.enabled )
setNodeTreeEnabled( view.row, false );
diff --git a/src/tests/unit_tests/fileassociation_tests.cpp b/src/tests/unit_tests/fileassociation_tests.cpp
new file mode 100644
index 000000000..c63aec88b
--- /dev/null
+++ b/src/tests/unit_tests/fileassociation_tests.cpp
@@ -0,0 +1,32 @@
+#include "utest.hpp"
+
+#include
+#include
+#include
+
+using namespace EE::System;
+using namespace EE::UI::Doc;
+
+UTEST( FileAssociation, normalizesExtensions ) {
+ EXPECT_STDSTREQ( "cpp", FileAssociation::normalizeExtension( ".CPP" ) );
+ EXPECT_STDSTREQ( "d.ts", FileAssociation::normalizeExtension( "..D.TS" ) );
+ EXPECT_STDSTREQ( "c++", FileAssociation::normalizeExtension( "c++" ) );
+ EXPECT_TRUE( FileAssociation::normalizeExtension( "bad/ext" ).empty() );
+ EXPECT_TRUE( FileAssociation::normalizeExtension( "." ).empty() );
+}
+
+UTEST( SyntaxDefinitionManager, listsLiteralFileExtensionsWithoutLoadingDefinitions ) {
+ auto* manager = SyntaxDefinitionManager::createSingleton();
+ manager->addPreDefinition( { "Unit Test Language",
+ []() -> SyntaxDefinition& {
+ static SyntaxDefinition definition;
+ return definition;
+ },
+ { "%.unitext$", "^unitfile$" } } );
+ const auto extensions = manager->getFileExtensions();
+
+ EXPECT_TRUE( std::binary_search( extensions.begin(), extensions.end(), "cpp" ) );
+ EXPECT_TRUE( std::binary_search( extensions.begin(), extensions.end(), "json" ) );
+ EXPECT_TRUE( std::binary_search( extensions.begin(), extensions.end(), "unitext" ) );
+ EXPECT_FALSE( std::binary_search( extensions.begin(), extensions.end(), "unitfile" ) );
+}
diff --git a/src/tests/unit_tests/uisettingspanel_tests.cpp b/src/tests/unit_tests/uisettingspanel_tests.cpp
index a4db1c0ee..3077a8193 100644
--- a/src/tests/unit_tests/uisettingspanel_tests.cpp
+++ b/src/tests/unit_tests/uisettingspanel_tests.cpp
@@ -87,6 +87,12 @@ UTEST( UISettingsPanel, buildsAndMaterializesCategoriesLazily ) {
EXPECT_TRUE( firstRow->isVisible() );
EXPECT_EQ( nullptr, panel->find( "setting_second" ) );
EXPECT_FALSE( panel->addCategory( "late.category", "Late", "Category" ) );
+ EXPECT_FALSE( panel->addCustomWidget(
+ { "lateWidget", "general.behavior", "Late", "Late widget", {} }, []( UIWidget* parent ) {
+ auto* widget = UITextView::New();
+ widget->setParent( parent );
+ return widget;
+ } ) );
panel->selectCategory( "editor.display" );
auto* secondRow = panel->find( "setting_second" );
@@ -102,6 +108,28 @@ UTEST( UISettingsPanel, buildsAndMaterializesCategoriesLazily ) {
EXPECT_TRUE( secondRow->isEnabled() );
}
+UTEST( UISettingsPanel, materializesCustomWidgets ) {
+ UIApplication app(
+ WindowSettings( 800, 600, "eepp - UISettingsPanel Custom Widget Test", WindowStyle::Default,
+ WindowBackend::Default, 32 ),
+ UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) );
+ auto* panel = UISettingsPanel::New( app.getUI()->getRoot() );
+ EXPECT_TRUE( panel->addCategory( "general.integration", "General", "Integration" ) );
+ EXPECT_TRUE( panel->addCustomWidget(
+ { "associations", "general.integration", "Associations", "Select extensions", {} },
+ []( UIWidget* parent ) {
+ auto* widget = UITextView::New();
+ widget->setId( "custom_associations" );
+ widget->setParent( parent );
+ return widget;
+ } ) );
+
+ panel->build();
+
+ EXPECT_NE( nullptr, panel->find( "setting_associations" ) );
+ EXPECT_NE( nullptr, panel->find( "custom_associations" ) );
+}
+
UTEST( UISettingsPanel, filtersAcrossUnmaterializedCategories ) {
UIApplication app(
WindowSettings( 800, 600, "eepp - UISettingsPanel Filter Test", WindowStyle::Default,
diff --git a/src/tools/ecode/settingspanel.cpp b/src/tools/ecode/settingspanel.cpp
index 04cd59d5a..8596a3580 100644
--- a/src/tools/ecode/settingspanel.cpp
+++ b/src/tools/ecode/settingspanel.cpp
@@ -6,10 +6,187 @@
#include "settingsdocument.hpp"
#include "settingspage.hpp"
#include "uitreeviewfs.hpp"
+#include
+#include
#include
+#include
namespace ecode {
+class FileAssociationsModel final : public Model {
+ public:
+ enum Columns { Registered, Extension, Count };
+
+ struct Entry {
+ std::string extension;
+ bool registered{ false };
+ };
+
+ FileAssociationsModel( std::vector extensions,
+ const std::vector& registered, String registeredColumn,
+ String extensionColumn ) :
+ mRegisteredColumn( std::move( registeredColumn ) ),
+ mExtensionColumn( std::move( extensionColumn ) ) {
+ std::unordered_set registeredSet( registered.begin(), registered.end() );
+ mEntries.reserve( extensions.size() );
+ for ( auto& extension : extensions )
+ mEntries.push_back( { extension, registeredSet.contains( extension ) } );
+ }
+
+ size_t rowCount( const ModelIndex& = {} ) const { return mEntries.size(); }
+
+ size_t columnCount( const ModelIndex& = {} ) const { return Count; }
+
+ std::string columnName( const size_t& column ) const {
+ return column == Registered ? mRegisteredColumn.toUtf8() : mExtensionColumn.toUtf8();
+ }
+
+ Variant data( const ModelIndex& index, ModelRole role = ModelRole::Display ) const {
+ if ( !index.isValid() || static_cast( index.row() ) >= mEntries.size() )
+ return {};
+ const auto& entry = mEntries[index.row()];
+ if ( role == ModelRole::Data && index.column() == Registered )
+ return Variant( entry.registered );
+ if ( role == ModelRole::Display && index.column() == Extension )
+ return Variant( '.' + entry.extension );
+ return {};
+ }
+
+ void setRegistered( size_t row, bool registered ) {
+ if ( row < mEntries.size() )
+ mEntries[row].registered = registered;
+ }
+
+ void setAllRegistered( bool registered ) {
+ for ( auto& entry : mEntries )
+ entry.registered = registered;
+ invalidate( Model::UpdateFlag::DontInvalidateIndexes );
+ }
+
+ std::vector extensions() const {
+ std::vector extensions;
+ extensions.reserve( mEntries.size() );
+ for ( const auto& entry : mEntries )
+ extensions.emplace_back( entry.extension );
+ return extensions;
+ }
+
+ std::vector registeredExtensions() const {
+ std::vector extensions;
+ for ( const auto& entry : mEntries ) {
+ if ( entry.registered )
+ extensions.emplace_back( entry.extension );
+ }
+ return extensions;
+ }
+
+ private:
+ std::vector mEntries;
+ String mRegisteredColumn;
+ String mExtensionColumn;
+};
+
+class FileAssociationTableCell final : public UITableCell {
+ public:
+ static FileAssociationTableCell* New( const std::string& tag, FileAssociationsModel* model,
+ ModelIndex index ) {
+ return eeNew( FileAssociationTableCell, ( tag, model, index ) );
+ }
+
+ FileAssociationTableCell( const std::string& tag, FileAssociationsModel* model,
+ ModelIndex index ) :
+ UITableCell( tag,
+ [model, index]( UIPushButton* ) -> UITextView* {
+ auto* check = UICheckBox::New();
+ check->setCheckMode( UICheckBox::Button );
+ check->setChecked( model->data( index, ModelRole::Data ).asBool() );
+ return check;
+ } ),
+ mModel( model ) {}
+
+ void updateCell( Model* model ) {
+ if ( mTextBox->isType( UI_TYPE_CHECKBOX ) ) {
+ auto* check = mTextBox->asType();
+ mUpdating = true;
+ check->setChecked( model->data( getCurIndex(), ModelRole::Data ).asBool() );
+ mUpdating = false;
+ if ( !mListening ) {
+ check->on( Event::OnValueChange, [this, check]( const Event* ) {
+ if ( !mUpdating )
+ mModel->setRegistered( getCurIndex().row(), check->isChecked() );
+ } );
+ mListening = true;
+ }
+ }
+ }
+
+ private:
+ FileAssociationsModel* mModel{ nullptr };
+ bool mUpdating{ false };
+ bool mListening{ false };
+};
+
+class UIFileAssociationsTableView final : public UITableView {
+ public:
+ static UIFileAssociationsTableView* New() { return eeNew( UIFileAssociationsTableView, () ); }
+
+ UIWidget* createCell( UIWidget* rowWidget, const ModelIndex& index ) {
+ if ( index.column() == FileAssociationsModel::Registered ) {
+ auto* cell = FileAssociationTableCell::New(
+ mTag + "::cell", static_cast( getModel() ), index );
+ cell->getTextView()->setEnabled( true );
+ cell->setDontAutoHideEmptyTextBox( true );
+ return setupCell( cell, rowWidget, index );
+ }
+ return UITableView::createCell( rowWidget, index );
+ }
+
+ private:
+ UIFileAssociationsTableView() : UITableView() {}
+};
+
+struct FileAssociationsViewState {
+ std::shared_ptr model;
+ std::atomic applying{ false };
+ UIWidget* layout{ nullptr };
+ UICheckBox* desktopEntry{ nullptr };
+ UIFileAssociationsTableView* table{ nullptr };
+ UIPushButton* selectAll{ nullptr };
+ UIPushButton* clear{ nullptr };
+ UIPushButton* apply{ nullptr };
+
+ void setControlsEnabled( bool enabled ) {
+ if ( desktopEntry )
+ desktopEntry->setEnabled( enabled );
+ if ( table )
+ table->setEnabled( enabled );
+ if ( selectAll )
+ selectAll->setEnabled( enabled );
+ if ( clear )
+ clear->setEnabled( enabled );
+ if ( apply )
+ apply->setEnabled( enabled );
+ }
+
+ void clearControls() {
+ layout = nullptr;
+ desktopEntry = nullptr;
+ table = nullptr;
+ selectAll = nullptr;
+ clear = nullptr;
+ apply = nullptr;
+ }
+};
+
+static FileAssociationApplication fileAssociationApplication( App* app ) {
+ auto executablePath = Sys::getProcessFilePath();
+#if EE_PLATFORM == EE_PLATFORM_WIN
+ return { "ecode", "ecode", executablePath, "\"" + executablePath + "\",0" };
+#else
+ return { "ecode", "ecode", std::move( executablePath ), app->resPath() + "icon/ecode.png" };
+#endif
+}
+
SettingsPanel::SettingsPanel( App* app ) :
mApp( app ), mLifetime( this, app ? app->getUISceneNode() : nullptr ) {}
@@ -160,6 +337,11 @@ void SettingsPanel::addAction( PanelState& state, SettingDescriptor binding,
state.panel->addAction( std::move( binding ), buttonText, std::move( action ) );
}
+void SettingsPanel::addCustomWidget( PanelState& state, SettingDescriptor binding,
+ std::function create ) {
+ state.panel->addCustomWidget( std::move( binding ), std::move( create ) );
+}
+
void SettingsPanel::refreshTextSetting( PanelState& state, const std::string& id ) {
state.panel->refreshTextSetting( id );
}
@@ -212,6 +394,119 @@ void SettingsPanel::addUserSettings( PanelState& panel ) {
&mApp->getConfig().ui.smoothScroll,
[this]( bool value ) { mApp->getUISceneNode()->setSmoothScrollEnabled( value, true ); } );
+ if ( FileAssociation::isSupported() ) {
+ addCategory( panel, "general.file_associations", mApp->i18n( "general", "General" ),
+ mApp->i18n( "file_associations", "File Associations" ) );
+ auto application = fileAssociationApplication( mApp );
+ auto extensions = SyntaxDefinitionManager::instance()->getFileExtensions();
+ FileAssociation association( application );
+ auto registered = association.getRegisteredExtensions( extensions );
+ auto viewState = std::make_shared();
+ viewState->model = std::make_shared(
+ std::move( extensions ), registered, mApp->i18n( "registered", "Registered" ),
+ mApp->i18n( "extension", "Extension" ) );
+ const bool desktopEntryInstalled = association.isDesktopEntryInstalled();
+ addCustomWidget(
+ panel,
+ { "systemFileAssociations", "general.file_associations",
+ mApp->i18n( "system_file_associations", "System File Associations" ),
+ mApp->i18n(
+ "system_file_associations_desc",
+ "Make ecode available for the selected file extensions. The operating system may "
+ "still ask you to confirm the default application." ) },
+ [this, application = std::move( application ), viewState,
+ desktopEntryInstalled]( UIWidget* parent ) {
+ static constexpr const char* layoutSource = R"xml(
+
+
+
+
+
+
+
+
+
+)xml";
+ auto* layout =
+ parent->getUISceneNode()->loadLayoutFromString( layoutSource, parent );
+ auto* desktopEntry = layout->find( "install_desktop_entry" );
+ viewState->layout = layout;
+ viewState->desktopEntry = desktopEntry;
+ layout->on( Event::OnClose, [viewState, layout]( const Event* ) {
+ if ( viewState->layout == layout )
+ viewState->clearControls();
+ } );
+ desktopEntry->setText(
+ mApp->i18n( "install_desktop_entry", "Install application launcher" ) );
+ desktopEntry->setChecked( desktopEntryInstalled );
+ desktopEntry->setVisible( FileAssociation::supportsDesktopEntries() );
+
+ auto* table = UIFileAssociationsTableView::New();
+ viewState->table = table;
+ table->setParent( layout->find( "file_associations_table_container" ) );
+ table->setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::MatchParent );
+ table->setColumnWidthMode( UIAbstractTableView::ColumnWidthMode::Percentage );
+ table->setColumnsWidthPercentage( { 0.2f, 0.8f } );
+ table->setModel( viewState->model );
+ table->setHeadersVisible( true );
+
+ auto* selectAll = layout->find( "select_all_associations" );
+ viewState->selectAll = selectAll;
+ selectAll->setText( mApp->i18n( "select_all", "Select All" ) );
+ selectAll->onClick( [viewState]( const MouseEvent* ) {
+ viewState->model->setAllRegistered( true );
+ } );
+ auto* clear = layout->find( "clear_associations" );
+ viewState->clear = clear;
+ clear->setText( mApp->i18n( "clear", "Clear" ) );
+ clear->onClick( [viewState]( const MouseEvent* ) {
+ viewState->model->setAllRegistered( false );
+ } );
+ auto* apply = layout->find( "apply_associations" );
+ viewState->apply = apply;
+ apply->setText( mApp->i18n( "apply", "Apply" ) );
+ apply->onClick( [this, application, viewState]( const MouseEvent* ) {
+ if ( viewState->applying.exchange( true ) )
+ return;
+ viewState->setControlsEnabled( false );
+ auto selected = viewState->model->registeredExtensions();
+ auto supported = viewState->model->extensions();
+ const bool installDesktopEntry =
+ ( viewState->desktopEntry && viewState->desktopEntry->isChecked() ) ||
+ ( FileAssociation::supportsDesktopEntries() && !selected.empty() );
+ auto lifetime = mLifetime.weakHandle();
+ mApp->getThreadPool()->run( [application, selected = std::move( selected ),
+ supported = std::move( supported ),
+ installDesktopEntry, viewState,
+ lifetime]() mutable {
+ FileAssociation fileAssociation( std::move( application ) );
+ const bool success = fileAssociation.setRegisteredExtensions(
+ selected, supported, installDesktopEntry );
+ auto error = fileAssociation.getLastError();
+ viewState->applying = false;
+ lifetime.run( [success, installDesktopEntry, viewState,
+ error = std::move( error )]( SettingsPanel* settings ) {
+ viewState->setControlsEnabled( true );
+ if ( success ) {
+ if ( viewState->desktopEntry )
+ viewState->desktopEntry->setChecked( installDesktopEntry );
+ settings->mApp->getNotificationCenter()->addNotification(
+ settings->mApp->i18n( "file_associations_updated",
+ "File associations updated." ) );
+ } else {
+ settings->mApp->errorMsgBox(
+ settings->mApp->i18n( "file_associations_update_failed",
+ "Could not update file associations." ) +
+ ( error.empty() ? String{}
+ : "\n" + String::fromUtf8( error ) ) );
+ }
+ } );
+ } );
+ } );
+ return layout;
+ } );
+ }
+
addCategory( panel, "editor.appearance", mApp->i18n( "editor", "Editor" ),
mApp->i18n( "appearance", "Appearance" ) );
std::vector editorSchemeNames;
@@ -1165,7 +1460,7 @@ void SettingsPanel::addUserSettings( PanelState& panel ) {
[this, monitorRefreshRate, unlimitedFrameRate] {
const auto value = mApp->getConfig().context.FrameRateLimit;
return value == ContextSettings::FrameRateLimitScreenRefreshRate ? monitorRefreshRate
- : value == 0 ? unlimitedFrameRate
+ : value == 0 ? unlimitedFrameRate
: String( String::toString( value ) );
},
[this, monitorRefreshRate, unlimitedFrameRate]( const String& selection ) {
diff --git a/src/tools/ecode/settingspanel.hpp b/src/tools/ecode/settingspanel.hpp
index cdfdebac4..4cc851d15 100644
--- a/src/tools/ecode/settingspanel.hpp
+++ b/src/tools/ecode/settingspanel.hpp
@@ -77,6 +77,9 @@ class SettingsPanel {
void addAction( PanelState& state, SettingDescriptor binding, const String& buttonText,
std::function action );
+ void addCustomWidget( PanelState& state, SettingDescriptor binding,
+ std::function create );
+
void refreshTextSetting( PanelState& state, const std::string& id );
void setCategoryEnabled( PanelState& state, const std::string& category, bool enabled,