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
This commit is contained in:
Martín Lucas Golini
2026-09-17 11:04:25 -03:00
parent 9956f38c2a
commit c530e4bd6e
17 changed files with 1340 additions and 6 deletions

View File

@@ -1121,6 +1121,14 @@ Für sichtbare Änderung ecode neu starten.</string>
<string name="enable_exclusive_mode_by_default">Exklusivmodus standardmäßig aktivieren</string>
<string name="enable_exclusive_mode_by_default_tooltip">Deaktiviert globale Tastenkürzel in neu erstellten Terminals.</string>
<string name="file_associations">Dateizuordnungen</string>
<string name="apply">Anwenden</string>
<string name="extension">Erweiterung</string>
<string name="registered">Registriert</string>
<string name="install_desktop_entry">Anwendungsstarter installieren</string>
<string name="system_file_associations">System-Dateizuordnungen</string>
<string name="system_file_associations_desc">Ecode für die ausgewählten Dateierweiterungen verfügbar machen. Das Betriebssystem kann weiterhin eine Bestätigung der Standardanwendung verlangen.</string>
<string name="file_associations_updated">Dateizuordnungen aktualisiert.</string>
<string name="file_associations_update_failed">Dateizuordnungen konnten nicht aktualisiert werden.</string>
<string name="full">Voll</string>
<string name="grayscale">Graustufen</string>
<string name="hide_tabbar">Tableiste ausblenden</string>

View File

@@ -1106,6 +1106,14 @@ Restart ecode to see the changes.</string>
<string name="enable_exclusive_mode_by_default">Enable Exclusive Mode by Default</string>
<string name="enable_exclusive_mode_by_default_tooltip">Disable global keybindings in newly created terminals.</string>
<string name="file_associations">File Associations</string>
<string name="apply">Apply</string>
<string name="extension">Extension</string>
<string name="registered">Registered</string>
<string name="install_desktop_entry">Install application launcher</string>
<string name="system_file_associations">System File Associations</string>
<string name="system_file_associations_desc">Make ecode available for the selected file extensions. The operating system may still ask you to confirm the default application.</string>
<string name="file_associations_updated">File associations updated.</string>
<string name="file_associations_update_failed">Could not update file associations.</string>
<string name="full">Full</string>
<string name="grayscale">Grayscale</string>
<string name="hide_tabbar">Hide Tab Bar</string>

View File

@@ -1104,6 +1104,14 @@ Redémarrer ecode pour voir les changements.</string>
<string name="enable_exclusive_mode_by_default">Activer le mode exclusif par défaut</string>
<string name="enable_exclusive_mode_by_default_tooltip">Désactive les raccourcis globaux dans les nouveaux terminaux.</string>
<string name="file_associations">Associations de fichiers</string>
<string name="apply">Appliquer</string>
<string name="extension">Extension</string>
<string name="registered">Associé</string>
<string name="install_desktop_entry">Installer le lanceur dapplication</string>
<string name="system_file_associations">Associations de fichiers système</string>
<string name="system_file_associations_desc">Rendre ecode disponible pour les extensions sélectionnées. Le système dexploitation peut encore demander de confirmer lapplication par défaut.</string>
<string name="file_associations_updated">Associations de fichiers mises à jour.</string>
<string name="file_associations_update_failed">Impossible de mettre à jour les associations de fichiers.</string>
<string name="full">Complet</string>
<string name="grayscale">Niveaux de gris</string>
<string name="hide_tabbar">Masquer la barre d'onglets</string>

View File

@@ -890,6 +890,14 @@ file in the directory tree.</string>
<string name="enable_exclusive_mode_by_default">默认启用独占模式</string>
<string name="enable_exclusive_mode_by_default_tooltip">在新建终端中禁用全局快捷键。</string>
<string name="file_associations">文件关联</string>
<string name="apply">应用</string>
<string name="extension">扩展名</string>
<string name="registered">已注册</string>
<string name="install_desktop_entry">安装应用程序启动器</string>
<string name="system_file_associations">系统文件关联</string>
<string name="system_file_associations_desc">让 ecode 可用于所选文件扩展名。操作系统可能仍会要求确认默认应用程序。</string>
<string name="file_associations_updated">文件关联已更新。</string>
<string name="file_associations_update_failed">无法更新文件关联。</string>
<string name="folds_refresh_freq">折叠区域刷新频率</string>
<string name="full">完全</string>
<string name="grayscale">灰度</string>

View File

@@ -10,6 +10,7 @@
#include <eepp/system/container.hpp>
#include <eepp/system/cpu.hpp>
#include <eepp/system/directorypack.hpp>
#include <eepp/system/fileassociation.hpp>
#include <eepp/system/fileinfo.hpp>
#include <eepp/system/filemapped.hpp>
#include <eepp/system/filesystem.hpp>

View File

@@ -0,0 +1,66 @@
#ifndef EE_SYSTEM_FILEASSOCIATION_HPP
#define EE_SYSTEM_FILEASSOCIATION_HPP
#include <eepp/config.hpp>
#include <string>
#include <string_view>
#include <vector>
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<std::string>
getRegisteredExtensions( const std::vector<std::string>& supportedExtensions ) const;
/** Makes @p registeredExtensions the registered subset of @p supportedExtensions. */
bool setRegisteredExtensions( const std::vector<std::string>& registeredExtensions,
const std::vector<std::string>& 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<std::string>& registeredExtensions,
const std::vector<std::string>& 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

View File

@@ -82,6 +82,11 @@ class EE_API SyntaxDefinitionManager {
std::vector<std::string> 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<std::string> getFileExtensions() const;
const SyntaxDefinition* getPtrByLSPName( const std::string& name ) const;
bool loadFromStream( IOStream& stream, std::vector<std::string>* addedLangs );

View File

@@ -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<void()> action;
};
struct EE_API CustomWidgetSetting {
std::function<UIWidget*( UIWidget* parent )> create;
};
using SettingValue =
std::variant<BoolPointerSetting, BoolSetting, ChoiceSetting, EditableChoiceSetting,
IntegerSetting, TextSetting, FloatSetting, ActionSetting>;
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<void()> action );
bool addCustomWidget( SettingDescriptor descriptor,
std::function<UIWidget*( UIWidget* parent )> create );
void build();
void selectCategory( const std::string& category );

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,790 @@
#include <eepp/system/fileassociation.hpp>
#include <algorithm>
#include <cctype>
#include <eepp/core/string.hpp>
#include <eepp/system/filesystem.hpp>
#include <eepp/system/process.hpp>
#include <eepp/system/sys.hpp>
#include <iterator>
#include <unordered_map>
#include <unordered_set>
#if EE_PLATFORM == EE_PLATFORM_WIN
#include <windows.h>
#include <shlobj.h>
#elif EE_PLATFORM == EE_PLATFORM_MACOS
#include <CoreServices/CoreServices.h>
#endif
namespace EE::System {
namespace {
static std::vector<std::string> normalizeExtensions( const std::vector<std::string>& extensions ) {
std::vector<std::string> 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<char>( 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 += "&amp;";
break;
case '<':
escaped += "&lt;";
break;
case '>':
escaped += "&gt;";
break;
case '"':
escaped += "&quot;";
break;
default:
escaped += character;
}
}
return escaped;
}
static std::vector<std::string> 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<std::string>& wanted,
std::unordered_map<std::string, std::pair<int, std::string>>& 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<std::string, std::string>
mimeTypesForExtensions( const std::vector<std::string>& extensions,
const FileAssociationApplication& application ) {
std::unordered_set<std::string> wanted( extensions.begin(), extensions.end() );
std::unordered_map<std::string, std::pair<int, std::string>> weightedTypes;
std::vector<std::string> 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<std::string, std::string> 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<std::string>{ 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<std::string>& lines, std::string_view sectionName,
const std::unordered_set<std::string>& selected,
const std::unordered_set<std::string>& 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<std::string> 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<std::string> 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<std::string>& selected,
const std::unordered_set<std::string>& 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<std::string>& selected,
const std::unordered_set<std::string>& 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<std::string> 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<std::string>& 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<std::string> uniqueMimeTypes;
std::string package =
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
"<mime-info xmlns=\"http://www.freedesktop.org/standards/shared-mime-info\">\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 += " <mime-type type=\"" + mime + "\">\n";
package += " <comment>" + xmlEscape( application.name ) + " " + extension +
" document</comment>\n";
package += " <glob pattern=\"*." + extension + "\"/>\n";
package += " </mime-type>\n";
}
package += "</mime-info>\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<std::string> 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<std::string> selectedMimeTypes;
for ( const auto& [extension, mime] : mimeTypes )
selectedMimeTypes.emplace( mime );
std::unordered_set<std::string> 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<const BYTE*>( value.c_str() ),
static_cast<DWORD>( ( 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<CFStringRef>( 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<char>( std::tolower( character ) );
}
return normalized;
}
std::vector<std::string> FileAssociation::getRegisteredExtensions(
const std::vector<std::string>& supportedExtensions ) const {
mLastError.clear();
const auto supported = normalizeExtensions( supportedExtensions );
std::vector<std::string> 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<CFStringRef>( 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<std::string>& registeredExtensions,
const std::vector<std::string>& supportedExtensions ) {
return setRegisteredExtensions( registeredExtensions, supportedExtensions,
supportsDesktopEntries() ? isDesktopEntryInstalled() : false );
}
bool FileAssociation::setRegisteredExtensions( const std::vector<std::string>& registeredExtensions,
const std::vector<std::string>& supportedExtensions,
bool desktopEntryInstalled ) {
mLastError.clear();
const auto supported = normalizeExtensions( supportedExtensions );
const auto requested = normalizeExtensions( registeredExtensions );
std::vector<std::string> 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<CFURLRef>( 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<CFStringRef>( type.get() ), kLSRolesEditor | kLSRolesViewer,
static_cast<CFStringRef>( 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<CFStringRef>( type.get() ), kLSRolesEditor | kLSRolesViewer ) );
if ( !current.get() || !CFEqual( current.get(), applicationId.get() ) )
continue;
CFRef handlers( LSCopyAllRoleHandlersForContentType( static_cast<CFStringRef>( type.get() ),
kLSRolesEditor | kLSRolesViewer ) );
if ( !handlers.get() )
continue;
auto array = static_cast<CFArrayRef>( handlers.get() );
bool reassigned = false;
for ( CFIndex index = 0; index < CFArrayGetCount( array ); ++index ) {
auto handler = static_cast<CFStringRef>( CFArrayGetValueAtIndex( array, index ) );
if ( CFEqual( handler, applicationId.get() ) )
continue;
if ( LSSetDefaultRoleHandlerForContentType( static_cast<CFStringRef>( 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

View File

@@ -20,6 +20,7 @@
#include <eepp/ui/doc/languages/xml.hpp>
#include <eepp/ui/doc/syntaxdefinitionmanager.hpp>
#include <cctype>
#include <nlohmann/json.hpp>
#include <unordered_set>
@@ -706,6 +707,60 @@ std::vector<std::string> SyntaxDefinitionManager::getExtensionsPatternsSupported
return vexts;
}
static std::vector<std::string> 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<std::string> 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<unsigned char>( pattern[i] ) ) )
return {};
character = pattern[i];
} else if ( !std::isalnum( static_cast<unsigned char>( 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<std::string> SyntaxDefinitionManager::getFileExtensions() const {
Lock l( mMutex );
std::unordered_set<std::string> 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<std::string> 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 ) {

View File

@@ -308,6 +308,15 @@ static const SettingsLayoutTemplate SETTINGS_TEXT_ROW_LAYOUT( settingsRowLayout(
R"xml(<TextInput id="setting_control_widget" class="settings_text" />)xml" ) );
static const SettingsLayoutTemplate SETTINGS_ACTION_ROW_LAYOUT( settingsRowLayout(
R"xml(<PushButton id="setting_control_widget" class="settings_action" />)xml" ) );
static const SettingsLayoutTemplate SETTINGS_CUSTOM_WIDGET_ROW_LAYOUT( R"xml(
<vbox lw="mp" lh="wc" class="settings_option">
<vbox id="setting_info" lw="mp" lh="wc">
<TextView id="setting_name" lw="mp" lh="wc" class="settings_option_name" focusable="false" />
<TextView id="setting_description" lw="mp" lh="wc" class="settings_option_description" focusable="false" />
</vbox>
<vbox id="setting_custom_widget" lw="mp" lh="wc" margin-top="8dp" />
</vbox>
)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<UIWidget*( UIWidget* parent )> 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<BoolPointerSetting>( &setting.value ) ) {
auto* check = createBoolControl( panel, setting, view );
auto binding = UIDataBind<bool>::New( value->value, check,
UIValueConverter<bool>::converterBool() );
UIValueConverter<bool>::converterBool() );
binding->onValueChangeCb = value->apply;
panel.bindingGroup += std::move( binding );
} else if ( auto* value = std::get_if<BoolSetting>( &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<CustomWidgetSetting>( &setting.value ) ) {
auto* row = createRow( panel, setting, view, SETTINGS_CUSTOM_WIDGET_ROW_LAYOUT.root() );
value->create( row->find<UIWidget>( "setting_custom_widget" ) );
}
if ( view.row && !setting.enabled )
setNodeTreeEnabled( view.row, false );

View File

@@ -0,0 +1,32 @@
#include "utest.hpp"
#include <algorithm>
#include <eepp/system/fileassociation.hpp>
#include <eepp/ui/doc/syntaxdefinitionmanager.hpp>
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" ) );
}

View File

@@ -87,6 +87,12 @@ UTEST( UISettingsPanel, buildsAndMaterializesCategoriesLazily ) {
EXPECT_TRUE( firstRow->isVisible() );
EXPECT_EQ( nullptr, panel->find<UIWidget>( "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<UIWidget>( "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<UIWidget>( "setting_associations" ) );
EXPECT_NE( nullptr, panel->find<UIWidget>( "custom_associations" ) );
}
UTEST( UISettingsPanel, filtersAcrossUnmaterializedCategories ) {
UIApplication app(
WindowSettings( 800, 600, "eepp - UISettingsPanel Filter Test", WindowStyle::Default,

View File

@@ -6,10 +6,187 @@
#include "settingsdocument.hpp"
#include "settingspage.hpp"
#include "uitreeviewfs.hpp"
#include <atomic>
#include <eepp/system/fileassociation.hpp>
#include <limits>
#include <unordered_set>
namespace ecode {
class FileAssociationsModel final : public Model {
public:
enum Columns { Registered, Extension, Count };
struct Entry {
std::string extension;
bool registered{ false };
};
FileAssociationsModel( std::vector<std::string> extensions,
const std::vector<std::string>& registered, String registeredColumn,
String extensionColumn ) :
mRegisteredColumn( std::move( registeredColumn ) ),
mExtensionColumn( std::move( extensionColumn ) ) {
std::unordered_set<std::string> 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<size_t>( 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<std::string> extensions() const {
std::vector<std::string> extensions;
extensions.reserve( mEntries.size() );
for ( const auto& entry : mEntries )
extensions.emplace_back( entry.extension );
return extensions;
}
std::vector<std::string> registeredExtensions() const {
std::vector<std::string> extensions;
for ( const auto& entry : mEntries ) {
if ( entry.registered )
extensions.emplace_back( entry.extension );
}
return extensions;
}
private:
std::vector<Entry> 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<UICheckBox>();
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<FileAssociationsModel*>( 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<FileAssociationsModel> model;
std::atomic<bool> 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<UIWidget*( UIWidget* parent )> 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<FileAssociationsViewState>();
viewState->model = std::make_shared<FileAssociationsModel>(
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(
<vbox id="file_associations_container" lw="mp" lh="wc">
<CheckBox id="install_desktop_entry" lw="wc" lh="wc" margin-bottom="6dp" />
<vbox id="file_associations_table_container" lw="mp" lh="320dp" />
<hbox lw="mp" lh="wc" margin-top="8dp" gravity="right">
<PushButton id="select_all_associations" lw="wc" lh="wc" margin-right="4dp" />
<PushButton id="clear_associations" lw="wc" lh="wc" margin-right="4dp" />
<PushButton id="apply_associations" lw="wc" lh="wc" />
</hbox>
</vbox>
)xml";
auto* layout =
parent->getUISceneNode()->loadLayoutFromString( layoutSource, parent );
auto* desktopEntry = layout->find<UICheckBox>( "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<UIWidget>( "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<UIPushButton>( "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<UIPushButton>( "clear_associations" );
viewState->clear = clear;
clear->setText( mApp->i18n( "clear", "Clear" ) );
clear->onClick( [viewState]( const MouseEvent* ) {
viewState->model->setAllRegistered( false );
} );
auto* apply = layout->find<UIPushButton>( "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<String> 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 ) {

View File

@@ -77,6 +77,9 @@ class SettingsPanel {
void addAction( PanelState& state, SettingDescriptor binding, const String& buttonText,
std::function<void()> action );
void addCustomWidget( PanelState& state, SettingDescriptor binding,
std::function<UIWidget*( UIWidget* parent )> create );
void refreshTextSetting( PanelState& state, const std::string& id );
void setCategoryEnabled( PanelState& state, const std::string& category, bool enabled,