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,