From c530e4bd6e4f6c0d010d13591393efade9782d34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Thu, 17 Sep 2026 11:04:25 -0300 Subject: [PATCH] feat: add cross-platform file association management Add an EE::System::FileAssociation abstraction with XDG, Windows, and macOS implementations. Expose extensions from syntax predefinitions and add custom widget support to UISettingsPanel. Add an ecode settings page for selecting file associations and installing an XDG desktop entry. Apply changes asynchronously with lifecycle-safe UI handling, disable controls while applying, and avoid redundant XDG database updates. Add translations and coverage for the new functionality. Implements SpartanJ/ecode#192 --- bin/assets/i18n/de.xml | 8 + bin/assets/i18n/en.xml | 8 + bin/assets/i18n/fr.xml | 8 + bin/assets/i18n/zh.xml | 8 + include/eepp/system.hpp | 1 + include/eepp/system/fileassociation.hpp | 66 ++ .../eepp/ui/doc/syntaxdefinitionmanager.hpp | 5 + include/eepp/ui/tools/uisettingspanel.hpp | 12 +- premake4.lua | 2 +- premake5.lua | 2 +- src/eepp/system/fileassociation.cpp | 790 ++++++++++++++++++ src/eepp/ui/doc/syntaxdefinitionmanager.cpp | 55 ++ src/eepp/ui/tools/uisettingspanel.cpp | 21 +- .../unit_tests/fileassociation_tests.cpp | 32 + .../unit_tests/uisettingspanel_tests.cpp | 28 + src/tools/ecode/settingspanel.cpp | 297 ++++++- src/tools/ecode/settingspanel.hpp | 3 + 17 files changed, 1340 insertions(+), 6 deletions(-) create mode 100644 include/eepp/system/fileassociation.hpp create mode 100644 src/eepp/system/fileassociation.cpp create mode 100644 src/tests/unit_tests/fileassociation_tests.cpp 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,